Skip to content
IC Reactor

Testing Utilities

@ic-reactor/core/testing runs the code under test against a fake replica. The fake answers an HttpAgent’s requests with canisters written as typed handlers, and signs its answers so the agent verifies them as it would a replica’s. ClientManager, Reactor, DisplayReactor, their query keys and error unwrapping, and the React hooks all run unmodified.

It is a separate entry point. The main entry never imports it, so it adds nothing to an app bundle. The Testing guide walks through writing tests with it.

import {
createTestCanister,
installFakeReplica,
type FakeReplica,
} from "@ic-reactor/core/testing"
// The same bindings, for an app that depends on @ic-reactor/react alone
import {
createTestCanister,
installFakeReplica,
} from "@ic-reactor/react/testing"
function installFakeReplica(options?: FakeReplicaOptions): FakeReplica

Replaces globalThis.fetch with a fake replica and returns it. Install it before building the agents under test: an HttpAgent keeps the fetch it found when it was built.

Option Type Default Description
host string See below The origin the fake answers for. Build the agents under test with this host.
canisters Record<string, FakeCanister> {} The canisters the fake runs, by canister ID. A call to any other canister is rejected.

A key of canisters that is not a canister ID throws.

With no host, the fake answers where a ClientManager built with no host sends its calls: the page’s origin when the test environment has a local one (http://localhost:3000 in Vitest’s jsdom and happy-dom), and http://127.0.0.1:4943 otherwise, as in plain Node.

Member Type Description
host string The origin the fake answers for.
rootKey Uint8Array The DER root key the fake signs with. An agent on a local host fetches it from the fake.
requests readonly FakeReplicaRequest[] Every request an agent sent, in order.
restore() () => void Takes the fake out of fetch. Safe to call twice; several fakes restore in any order.

Each FakeReplicaRequest has an endpoint ("status", "query", "call" or "read_state"), and where they apply its canisterId, methodName and caller (as text). A request the fake refused carries a refused reason.

Situation What the agent gets
A query or update call a handler answers The reply, certified (update) or signed by a node (query), as from a replica
A handler throws A trap’s reject (code 5, IC0503), which a reactor throws as a CallError
A call to a canister the fake does not run A reject with code 3 (DestinationInvalid)
A canister with no query (or update) handler is called A reject with code 3
A request whose signature or delegation does not verify HTTP 400, recorded with a refused reason
An IC API request to another origin A network error naming the fake’s host, logged once with console.error
Any other request, on any origin Passed to the fetch the fake replaced

The fake checks signatures by Ed25519, ECDSA P-256 and secp256k1 keys, and delegation chains between them, as a replica does: the signer, each delegation’s signature, expiry and targets. It refuses any other kind of key. The anonymous principal needs no signature.

A call is answered by the canister it names; an effectiveCanisterId only routes it, as on a replica. Update calls are answered synchronously; nothing polls. A second fake installed while one is installed answers its own host and passes the IC API on any other origin to the earlier one.

function createTestCanister<A = BaseActor>(
idlFactory: ReactorParameters["idlFactory"], // what a Reactor takes
handlers: TestCanisterHandlers<A>
): FakeCanister

Builds a canister for installFakeReplica from the service’s Candid interface and a handler per method. The fake decodes each call’s arguments with the interface, runs the handler and encodes its result.

import {
createTestCanister,
installFakeReplica,
} from "@ic-reactor/core/testing"
import { idlFactory, type _SERVICE } from "./declarations/backend"
const replica = installFakeReplica({
canisters: {
"bkyz2-fmaaa-aaaaa-qaaaq-cai": createTestCanister<_SERVICE>(idlFactory, {
greet: ([name]) => `Hello, ${name}!`,
whoami: (_args, { caller }) => caller,
transfer: async ([{ amount }]) =>
amount > 100n
? { Err: { InsufficientFunds: { balance: 100n } } }
: { Ok: amount },
}),
},
})

TestCanisterHandlers<A> maps each method name of the service A to an optional handler:

type TestCanisterHandler<A, M extends FunctionName<A>> = (
args: ActorMethodParameters<A[M]>,
context: FakeCallContext
) => ActorMethodReturnType<A[M]> | Promise<ActorMethodReturnType<A[M]>>
  • args is the method’s decoded argument tuple, as callMethod takes it.
  • context.caller is the Principal the request was signed as, after the fake checked the signature.
  • The result is the method’s Candid result: one value, a tuple for several results, nothing for none. Return { Err: ... } to make a reactor throw a CanisterError.
  • Throwing rejects the call as a trap, and a reactor throws a CallError.

The values are the raw Candid ones (bigint, Principal, [] | [T] for an opt) whichever reactor the test calls through.

  • A query call to a method the interface does not mark query or composite_query is rejected, as a replica rejects it.
  • A call to a method with no handler is rejected with a message that names the method.
  • A result the method’s Candid type does not accept rejects the call with a message that names the handler.
  • A handler for a method the service does not have throws when the canister is created.

installFakeReplica runs any object of this shape. Implement it directly to answer with bytes the service’s types would not produce.

interface FakeCanister {
query?(
method: string,
arg: Uint8Array,
context: FakeCallContext
): Uint8Array | Promise<Uint8Array>
update?(
method: string,
arg: Uint8Array,
context: FakeCallContext
): Uint8Array | Promise<Uint8Array>
}

Each handler receives the Candid-encoded argument and returns the Candid-encoded reply. Throwing rejects the call as a trap.