Testing
@usebutr/testing: fake adapters, fake connected wallets and fake persistence, so unit tests never touch a real wallet or browser storage.
@usebutr/testing provides test doubles that behave like real wallets on
each platform. Install it as a dev dependency:
npm install --save-dev @usebutr/testingDrive a manager
Fakes go into sources through fromAdapters, like any built adapter, and
createFakePersistence replaces browser storage:
import { createWalletManager, fromAdapters } from "@usebutr/core";
import { createFakeAdapter, createFakePersistence } from "@usebutr/testing";
const metamask = createFakeAdapter({ id: "io.metamask", name: "MetaMask" });
const phantom = createFakeAdapter({ chainPlatform: "svm", id: "phantom", omit: ["signIn"] });
const locked = createFakeAdapter({
id: "locked",
overrides: { connect: () => Promise.reject(new Error("wallet is locked")) },
});
const storage = createFakePersistence();
const manager = createWalletManager({
sources: [fromAdapters([metamask, phantom, locked])],
storage,
});
const stop = manager.start();
await manager.connect("io.metamask");
metamask.emit({ type: "disconnected" }); // the wallet ends the session
storage.saves.at(-1); // what the manager persistedFor components, pass the same config to WalletManagerProvider. A test may
build it inline: the provider reads it once per mount.
import type { WalletManagerConfig } from "@usebutr/core";
import { fromAdapters } from "@usebutr/core";
import { WalletManagerProvider } from "@usebutr/react";
import { createFakeAdapter, createFakePersistence } from "@usebutr/testing";
const config: WalletManagerConfig = {
sources: [fromAdapters(createFakeAdapter({ id: "io.metamask", name: "MetaMask" }))],
storage: createFakePersistence(),
};
render(
<WalletManagerProvider config={config}>
<WalletPicker />
</WalletManagerProvider>,
);createFakeAdapter(options?): FakeAdapter<P>
An EVM adapter unless chainPlatform says otherwise, with the members a real
wallet on that platform has, typed to it: createFakeAdapter({ chainPlatform: "svm" })
is a FakeAdapter<"svm">, an SvmAdapter plus the test controls.
| Platform | Optional members |
|---|---|
evm | disconnect, getBalance, getTransactionReceipt, requestAccounts, sendTx, signMessage, subscribe, switchChain |
svm | disconnect, sendTx, signIn, signMessage, signTransaction, subscribe |
sui | disconnect, sendTx, signMessage, signTransaction, subscribe |
bitcoin | disconnect, sendTx, signMessage, signTransaction, subscribe |
polkadot | disconnect, signMessage, subscribe |
It follows the adapter contract, so code under test meets the same failures it would in production:
- An
accountthe fake does not expose rejects. Afterdisconnect()or adisconnectedevent,getAccounts()resolves[]and signing, sending and balance calls reject with aNotConnectedConnectionErroruntil the nextconnect(). - A
chainfrom another namespace rejects. The EVM fake switches its accounts to a new chain beforesendTx, as a real EVM wallet does, and tells its listeners. - Hashes and signatures are distinct on every call, so two results never
compare equal by accident.
signMessageresolves your message assignedMessage. getBalancereportsbalancein the native asset (ETH,SOL,SUI,BTC,DOT), one unit by default, and rejects atoken: override it to fake token balances. Only the EVM fake has it unless you passbalance, matching the real adapters.getSigner()resolves thesigneroption and rejects without one. The package registers no signer kind of its own, so your app's exhaustiveswitch (signer.kind)never grows a test-only case.
FakeAdapterOptions<P>
| Field | Default | Notes |
|---|---|---|
chainPlatform | "evm" | Picks the platform and the adapter type. |
id | "fake" | |
name | "Fake Wallet" | |
icon | none | |
accounts | one plausible address on the platform's mainnet | Exposed accounts, active first. |
balance | EVM: one unit of the native asset | In base units. On non-EVM platforms, passing it adds getBalance. |
omit | [] | Optional members to leave off, to exercise the "this wallet cannot" path. |
overrides | none | Replace any member but chainPlatform, e.g. a connect that rejects. |
signer | none | What getSigner() resolves: any WalletSigner. |
import type { WalletSigner } from "@usebutr/core";
const signer: WalletSigner = {
kind: "eip1193",
provider: { on: () => {}, removeListener: () => {}, request: () => Promise.resolve(null) },
};
const metamask = createFakeAdapter({ id: "io.metamask", signer });Controls
FakeAdapterControls, on every fake:
emit(event)delivers aConnectorEventto the manager, as the wallet would.getAccounts()follows it: a new account list replaces the exposed accounts, anddisconnectedexposes none until the nextconnect().listenerCount()returns the livesubscribelisteners: zero once the manager has detached.
createFakeConnectedWallet(options?): FakeConnectedWallet<P>
A pool entry as the manager builds it after connect: a fake adapter plus
the accounts it exposes, so the two never disagree. Hand it to components and
helpers that take a ConnectedWallet; drive it through
wallet.connector.emit.
import { EVM_CHAINS } from "@usebutr/core";
import { createFakeConnectedWallet } from "@usebutr/testing";
const phantom = createFakeConnectedWallet({ chainPlatform: "svm", id: "phantom" });
render(<WalletCard wallet={phantom} />);
const multi = createFakeConnectedWallet({
addresses: ["0xabc…", "0xdef…"],
chain: EVM_CHAINS.base,
});FakeConnectedWalletOptions<P> takes every adapter option plus:
| Field | Default | Notes |
|---|---|---|
addresses | one plausible address for the platform | Exposed on chain, active first. Ignored with accounts. |
chain | the platform's mainnet (EVM_CHAINS.ethereum, SVM_CHAINS.mainnet, …) | For the generated accounts. |
account is always accounts[0], as on a real pool entry.
createFakePersistence(seed?): FakePersistence
A WalletPersistence over the production createWalletStorage with
in-memory drivers, so load() decodes and validates exactly as it does in the
browser. seed (a Partial<PersistedWalletState>) is what the first
load() finds, as if a previous session had saved it. saves lists every
state the manager handed to save, oldest first.
Seed it to test hydration: the manager silently reconnects the stored wallet once its fake adapter is announced.
import { createWalletManager, fromAdapters } from "@usebutr/core";
import { createFakeConnectedWallet, createFakePersistence } from "@usebutr/testing";
const wallet = createFakeConnectedWallet({ id: "io.metamask", name: "MetaMask" });
const storage = createFakePersistence({
activeConnectorId: "io.metamask",
pool: {
"io.metamask": {
account: wallet.account,
accounts: wallet.accounts,
chainPlatform: "evm",
connectorId: "io.metamask",
name: "MetaMask",
},
},
selection: { evm: "io.metamask" },
});
const manager = createWalletManager({
onHydrated: ({ restoredIds }) => {
// ["io.metamask"]
},
sources: [fromAdapters(wallet.connector)],
storage,
});
manager.start();Types
FakeAdapter, FakeAdapterControls, FakeAdapterOptions,
FakeConnectedWallet, FakeConnectedWalletOptions, FakePersistence.
Source: packages/testing/src (fake-adapter.ts, fake-connected-wallet.ts,
fake-persistence.ts) in the butr
repository.