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 call request, or to the next ones it names |
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 |
maxElements |
how many values a decoded value may hold beyond one per byte, for the client and the mocks. Default 1,000,000 |
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 call request runs, and its reply is lost: an update, or a certified read or an update read, which go out as calls | an update: outcome_unknown, mayHaveExecuted: true; never run twice. A read: the client sends it again as a new request, which can be answered, so its handler runs again |
dropNextReply({ method, canister, times }) |
the same, aimed: only the call requests that name method and are addressed to canister (each when given) lose their reply; every other request goes through |
dropNextReply({ method: "icrc1_transfer" }): the transfer’s reply is lost, the reads before it are answered |
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.
The aimed form of dropNextReply matches call requests by the same rules,
and an armed drop is used up in the order drops were armed. The bare form
loses the reply of whatever call request comes next, so a certified read made
before the write takes it: aim it at the write when reads go 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. A session also ends by the clock, eight hours after its sign-in (advance fake timers to get there) |
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.