Testing
createTestClient gives you a real client for a test. Nothing in it is a stub
of the client. Calls are signed by the principal that is signed in, sent
through an HttpAgent to an in-memory replica that checks the signature and
certifies the reply, and the client verifies the certificate. Only the canister
is replaced, by plain functions you write. So a test sees what an app would
see: a refused write, a lost reply, a re-send after HTTP 429, a read that
moves to another caller’s key.
Nothing global is replaced. globalThis.fetch is untouched, and two test
clients run side by side, each with its own replica and sign-in.
Import
Section titled “Import”import { createTestClient, type TestHandlers } from "@ic-reactor/core/testing"That subpath exports createTestClient and the type TestHandlers, and
nothing else. The fake replica signs with @noble/curves, an optional peer of
@ic-reactor/core that @icp-sdk/core already installs. If your package
manager does not let core resolve it, add it to devDependencies.
A first test
Section titled “A first test”import { principal } from "@candid-core/schema"import { createTestClient } from "@ic-reactor/core/testing"import { afterEach, beforeEach, expect, it } from "vitest"import { actor, type Actor } from "./generated/icrc1"
const LEDGER = "ryjl3-tyaaa-aaaaa-aaaba-cai"
let test: ReturnType<typeof createTestClient>
beforeEach(() => { test = createTestClient() // signed in as the identity of seed 1})
afterEach(() => { test.client.dispose()})
it("reads the balance of the caller", async () => { const { client, auth, mock } = test mock<Actor>(actor, LEDGER, { icrc1_balance_of: ({ owner }, { caller }) => (owner === caller ? 7n : 0n), })
const ledger = client.canister<Actor>(actor, { id: LEDGER }) const me = principal(auth.getPrincipal()!.toText())
expect(await ledger.icrc1_balance_of({ owner: me, subaccount: null })).toBe( 7n )})mock<Actor>(actor, id, handlers) runs a canister at id, replacing any
canister already there. actor is the service schema of the generated module
and Actor its type, as for client.canister<Actor>(actor, { id }).
What it returns and what it takes
Section titled “What it returns and what it takes”import { createTestClient } from "@ic-reactor/core/testing"
const { client, auth, mock, reject, dropNextReply, refuseNext, requests } = createTestClient({ identity: 1, signedIn: true, })| Returned | What it is |
|---|---|
client |
a real client from createClient, whose auth is auth. Dispose it at the end of each test |
auth |
the sign-in the client calls as (see Sign-in) |
mock |
runs a canister at an id, answering with handlers |
reject |
rejects the call being handled with a reject code, from inside a handler |
dropNextReply |
loses the reply to the next update |
refuseNext |
answers the next canister requests with an HTTP error status |
requests |
every request the replica received, in order |
| Option | Meaning |
|---|---|
identity |
who is signed in at first: a real signing identity such as Ed25519KeyIdentity, or a number that stands for one. The same number is the same principal in every run. Default: seed 1 |
signedIn |
true by default. With false the client starts anonymous, every update is refused, and auth.signIn() signs in as identity |
network |
the network the client believes it is on, for the key segment and the host. Give the host with a scheme. The root key is always the fake’s |
allowEnvConfig |
as for createClient. Only a test that resolves { name } canisters from an ic_env cookie needs it, and a { name } resolves only on a page (jsdom or happy-dom), never in Node |
maxDepth |
how deep a decoded value may nest, for the client and the mocks. Default 256 |
createTestClient throws a TypeError for an option that does not exist or
cannot be used.
Writing handlers
Section titled “Writing handlers”A handler is a function of domain values, the same shapes the generated
Actor uses (see Values and units). It takes the
method’s arguments, then a context { caller }: the principal that sent the
call, as checked principal text, after the replica verified the signature.
TestHandlers<Actor> types every method, and a handler that returns a
number for a nat does not compile.
- Ok and Err. For a method whose one result is
variant { Ok : T; Err : E }, return{ tag: "Ok", value }or{ tag: "Err", value }. The client unwraps it as in production: the call resolves with theOkvalue, or rejects ascanister_errwith theErrvalue inerr. - No handler. A call to a method with no handler traps, with a message that names the method.
- Throwing. A handler that throws traps the call, as a canister does: reject code 5 with the error’s message. So does a call whose arguments do not decode, and a handler that returns something the method’s result cannot encode.
- Query or update. A method answers by its mode: a
queryon the query path, anupdateon the call path. Aquerymethod also answers a replicated call, which is how acertified: truecanister reads it. - The management canister.
mocktakes"aaaaa-aa"as an id.
import { principal } from "@candid-core/schema"import { createTestClient, type TestHandlers } from "@ic-reactor/core/testing"import { expect, it } from "vitest"import { actor, type Actor } from "./generated/icrc1"
const LEDGER = "ryjl3-tyaaa-aaaaa-aaaba-cai"
it("turns an Err into canister_err", async () => { const { client, mock } = createTestClient() const handlers: TestHandlers<Actor> = { icrc1_transfer: () => ({ tag: "Err", value: { tag: "InsufficientFunds", value: { balance: 0n } }, }), } mock<Actor>(actor, LEDGER, handlers)
const ledger = client.canister<Actor>(actor, { id: LEDGER }) const arg = { to: { owner: principal("aaaaa-aa"), subaccount: null }, amount: 1n, fee: null, memo: null, from_subaccount: null, created_at_time: null, } await expect(ledger.icrc1_transfer(arg)).rejects.toMatchObject({ kind: "canister_err", mayHaveExecuted: false, err: { tag: "InsufficientFunds", value: { balance: 0n } }, }) client.dispose()})Injecting failures
Section titled “Injecting failures”Each of these makes the client meet a failure it classifies, so a test can check what the app does with it. See Errors for the kinds.
| Call | What the client meets | Result |
|---|---|---|
reject(code, msg) |
inside a handler: the call is rejected with reject code 1 to 6 | the kind of that code, see the classification table |
dropNextReply() |
the next update runs, and its reply is lost | outcome_unknown, mayHaveExecuted: true; never run twice |
refuseNext(status, times = 1) |
the next times canister requests (queries or calls) get HTTP status (400 to 599) before any canister sees them |
refuseNext(429): one re-send; refuseNext(429, 3): not_delivered |
refuseNext(status, { method, canister, times }) |
the same, aimed: only the queries and calls that name method and are addressed to canister (each when given) are refused; every other request goes through |
refuseNext(429, { method: "icrc1_transfer" }): the transfer is throttled, the reads around it are answered |
The aimed form of refuseNext matches a query and a replicated call of the
same method alike. canister is the canister a request names (aaaaa-aa for a
call to the management canister), and need not be mocked: a gateway refuses a
request for a canister that does not exist. times (default 1) counts matching
requests, each re-send included. The status and read_state requests an agent
makes on its own never match, and a request that several refusals match is
refused by the one armed first.
dropNextReply keeps its reply lost whenever the same request is asked again,
so a test cannot run the update twice by accident. The client does not re-send
after it: it cannot know that is safe.
import { principal } from "@candid-core/schema"import { createTestClient } from "@ic-reactor/core/testing"import { afterEach, beforeEach, describe, expect, it } from "vitest"import { actor, type Actor, type TransferArg } from "./generated/icrc1"
const LEDGER = "ryjl3-tyaaa-aaaaa-aaaba-cai"const arg: TransferArg = { to: { owner: principal("aaaaa-aa"), subaccount: null }, amount: 100n, fee: null, memo: null, from_subaccount: null, created_at_time: null,}
describe("a transfer under failure", () => { let test: ReturnType<typeof createTestClient>
beforeEach(() => { test = createTestClient() test.mock<Actor>(actor, LEDGER, { icrc1_transfer: (_arg, { caller }) => caller === principal("aaaaa-aa") ? test.reject(4, "no funds") : { tag: "Ok", value: 1n }, }) }) afterEach(() => test.client.dispose())
const sends = () => test.requests.filter( (r) => r.endpoint === "call" && r.methodName === "icrc1_transfer" )
it("loses a reply: the outcome is unknown", async () => { const ledger = test.client.canister<Actor>(actor, { id: LEDGER }) test.dropNextReply()
await expect(ledger.icrc1_transfer(arg)).rejects.toMatchObject({ kind: "outcome_unknown", mayHaveExecuted: true, }) expect(sends()).toHaveLength(1) // the client never sent it again expect(sends()[0]?.dropped).toBe(true) })
it("sends again once after a 429, and it goes through", async () => { const ledger = test.client.canister<Actor>(actor, { id: LEDGER }) test.refuseNext(429)
expect(await ledger.icrc1_transfer(arg)).toBe(1n) expect(sends()).toHaveLength(2) expect(sends()[0]?.refused).toContain("429") expect(sends()[1]?.refused).toBeUndefined() })
it("gives up after three 429s: not delivered, certainly not run", async () => { const ledger = test.client.canister<Actor>(actor, { id: LEDGER }) test.refuseNext(429, 3)
await expect(ledger.icrc1_transfer(arg)).rejects.toMatchObject({ kind: "not_delivered", httpStatus: 429, mayHaveExecuted: false, }) expect(sends()).toHaveLength(3) // the first send and the two re-sends the client allows itself })})The client waits 300 ms and then 600 ms before the re-sends, so the last two tests take about a second.
Sign-in
Section titled “Sign-in”auth is the sign-in the client calls as. It has the surface of an
@icp-sdk/auth 10 AuthClient, driven by calls instead of an identity
provider, plus the controls a test needs to play out what a user does. Every
change is synchronous and reaches the client’s listeners once, so a test
reads the result without waiting.
| Call | Effect |
|---|---|
auth.signOut() |
nobody is signed in. A later signIn() returns to the same identity |
auth.signIn(identityOrSeed?) |
signs in as that identity, or as the one it last signed in as |
auth.switchTo(identityOrSeed) |
signs in as another identity in one step, with no signed-out state in between: a user picking another account |
auth.expire() |
the session of the last identity ends: status expired |
auth.elsewhere() |
that identity is signed in on this domain but not held by this origin: status signed-in-elsewhere |
getPrincipal(), getStatus() and getIdentity() read the current state.
The client owns the auth once anything has asked it who the caller is, and
disposes it with itself.
import { createTestClient } from "@ic-reactor/core/testing"import { expect, it } from "vitest"
it("follows the sign-in", () => { const { client, auth } = createTestClient({ identity: 1 }) const alice = client.caller() expect(client.authState().status).toBe("signed-in")
auth.switchTo(2) expect(client.caller()).not.toBe(alice)
auth.expire() // an expired session calls as the anonymous principal expect(client.authState().status).toBe("expired") expect(client.caller()).toBe("2vxsx-fae")
auth.signOut() expect(client.authState().status).toBe("anonymous") client.dispose()})Asserting on requests
Section titled “Asserting on requests”requests is the log of what the replica received, in order. The same array
grows, so copy it ([...requests]) to keep one moment of it. An entry has:
| Field | Meaning |
|---|---|
endpoint |
"status", "query", "call" or "read_state" |
canisterId |
the canister the request was addressed to |
effectiveCanisterId |
the canister it was routed by: differs for a call to aaaaa-aa |
methodName |
the method a query or call named |
caller |
the principal the request came from, after the replica checked its signature |
refused |
why the replica answered before any canister saw the request: a bad signature, delegation or target, or a refuseNext status |
dropped |
true when dropNextReply() lost the reply |
Use it to count sends (an update must not run twice), to check that a write was
refused before sending (no "call" entry), and to check who signed.
Testing React
Section titled “Testing React”Give the provider the test client as a borrowed one, client={() => test.client},
and dispose it yourself after the test. See
@ic-reactor/react for what the provider owns.
// @vitest-environment jsdomimport { principal } from "@candid-core/schema"import { createTestClient } from "@ic-reactor/core/testing"import { ReactorProvider, useAuth, useClient } from "@ic-reactor/react"import { useQuery } from "@tanstack/react-query"import { act, cleanup, render, screen } from "@testing-library/react"import { afterEach, expect, it } from "vitest"import { actor, type Actor } from "./generated/icrc1"
const LEDGER = "ryjl3-tyaaa-aaaaa-aaaba-cai"
function MyBalance() { const client = useClient() const { principal: caller } = useAuth() const ledger = client.canister<Actor>(actor, { id: LEDGER }) const balance = useQuery( client.queryOptions(ledger, "icrc1_balance_of", { owner: principal(caller), subaccount: null, }) ) return ( <p>{balance.data === undefined ? "loading" : `balance ${balance.data}`}</p> )}
afterEach(cleanup)
it("shows each caller's own balance", async () => { const test = createTestClient({ identity: 1 }) const alice = principal(test.auth.getPrincipal()!.toText()) test.mock<Actor>(actor, LEDGER, { icrc1_balance_of: ({ owner }) => (owner === alice ? 7n : 0n), })
render( <ReactorProvider client={() => test.client}> <MyBalance /> </ReactorProvider> ) await screen.findByText("balance 7")
await act(async () => { test.auth.switchTo(2) // another account: the keys move, never another's data }) await screen.findByText("balance 0")
test.client.dispose()})After the switch the component reads the new caller’s key, which is empty until
it loads, so the test never sees the first caller’s 7 under the second.
Examples
Section titled “Examples”The examples ship their test kits. Read them for larger setups.
- ICRC-1 ledger:
src/sandbox.tsarms each failure above, andsandbox.test.tsasserts the re-sends and the unknown outcome. - Vite wallet:
src/test/test-wallet.tsxrenders components in a provider over a test client withnetwork: "env"and anic_envcookie written as the Vite plugin writes it. - Node agent tool:
src/test-kit.tsruns a command-line tool against the in-memory replica.