Docs / Packages / @zudojs/testing
v1.2.0

@zudojs/testing

Testing utilities for the Zudojs ecosystem. Mock functions, spies, stubs, test clock, cleanup management, HTTP builders, assertion helpers, fixture factories, and full test context — everything you need for comprehensive testing.

TESTING MOCKS FIXTURES ASSERTIONS VITEST

OVERVIEW

@zudojs/testing is a box of parts for testing Zudojs applications. It does not run your tests — you keep using a test runner such as Vitest. What it gives you are the stand-ins and the checks that make a test fast and repeatable.

A running application talks to a database, a clock, a queue and an HTTP server. In a test you usually want none of that: real time makes tests slow, and a real database makes them flaky. This package hands you fakes you control instead.

Its assertion helpers compare values structurally, so a Map, Set or Date inside a body is compared by contents, not by how it prints.

The words, first

If any of these terms are new, here is each one in a single sentence.

TermIn one sentence
Unit testA small program that runs one piece of your code and checks that the result is what you expected.
FixtureA ready-made piece of test data produced by a factory function, so you do not retype the same object in every test.
MockA fake function you configure to return whatever you want, which also remembers every call it received.
StubA fake object whose methods all do nothing until you supply the one or two you actually care about.
SpyA wrapper around a real function that still runs it, but records every call along the way.
AssertionA check that throws an error when reality does not match your expectation — a test fails because an assertion threw.
WHEN YOU NEED IT
  • Your code reads the clock, and you want to test what happens an hour later without waiting an hour.
  • Your code calls a service you do not want to run for real (payments, email, a database).
  • You want to assert on the events, messages, jobs or log lines your code produced.
  • A test opens connections or timers that must be closed afterwards, in a fixed order.
  • You want to send real HTTP requests to your app and check status, headers, body and cookies.
WHEN YOU DON'T
  • Pure functions with no dependencies — plain expect() from your test runner is enough.
  • End-to-end tests where the whole point is to hit the real database. (For requests against your real HTTP server, the HTTP test client is the part you do want.)
  • Production code. Nothing here belongs in a shipped bundle; install it as a dev dependency.

INSTALLATION

Install the package and a test runner. The examples on this page use Vitest, which is what the package itself is tested with.

$ npm install --save-dev @zudojs/testing $ npm install --save-dev vitest

You do not need to install anything else. @zudojs/testing depends on the Zudojs packages it wraps — @zudojs/container, @zudojs/logger, @zudojs/events, @zudojs/messaging, @zudojs/queue, @zudojs/config, @zudojs/serialization, @zudojs/http and others — so your package manager pulls them in for you.

Source of truth: These docs follow the framework source. If an export shown here is missing from the version you installed, update to the latest @zudojs release.

Run your tests with npx vitest run.

QUICK START

Here is a complete test file. Save it as tests/session.test.ts and run npx vitest run. It tests a tiny function against a clock you control, so nothing waits for real time.

// tests/session.test.ts import { describe, it, expect } from "vitest"; import { createTestClock, createMockFn } from "@zudojs/testing"; // The code under test. interface Session { readonly id: string; readonly expiresAt: number; } function isExpired(session: Session, nowMs: number): boolean { return session.expiresAt <= nowMs; } describe("session expiry", () => { it("expires exactly one minute in", () => { const clock = createTestClock(0); // pinned to 1970-01-01T00:00:00.000Z const session: Session = { id: "s_1", expiresAt: 60_000 }; expect(isExpired(session, clock.timestamp)).toBe(false); clock.advance(60_000); // jump forward one minute, instantly expect(isExpired(session, clock.timestamp)).toBe(true); }); it("records how the notifier was called", () => { const notify = createMockFn<[string], void>(); notify("s_1"); expect(notify.callCount).toBe(1); expect(notify.calls[0]).toEqual(["s_1"]); }); });

What you should see: Vitest prints 2 passed. Change advance(60_000) to advance(59_999) and the first test fails — proof that the clock is really driving the result.

MOCKS, SPIES AND STUBS

These three are all "test doubles" — stand-ins for something real. They differ in how much of the real thing survives.

HelperWhat it doesReach for it when
createMockFn()Makes a brand-new fake function that records calls and returns what you configure.You need one function replaced and want to control its answer.
createSpyFn(fn)Wraps a real function; it still runs, and every call, result and thrown error is recorded.The real behaviour is fine — you only want to know it was called.
createSpyMethod(obj, "name")Same, but it replaces a method on an object in place until you call restore().You cannot pass the function in; the code reaches for obj.name itself.
createStub<T>()Makes an object where every method returns undefined, except the ones you override.An interface has ten methods and your test only uses two.
createStubClass(C, {…})Makes a constructor whose instances pass instanceof C but run no real code.The code under test checks instanceof, or calls new itself.

A mock function

A mock records every call in calls and every returned value in results, index for index. A call whose implementation throws keeps its results slot (undefined) and the thrown value is recorded in errors. This test replaces a repository lookup with a mock that resolves to a fixed user.

import { describe, it, expect } from "vitest"; import { createMockFn } from "@zudojs/testing"; interface User { readonly id: string; readonly name: string; } describe("greetUser", () => { it("asks the repository once, by id", async () => { const findUser = createMockFn<[string], Promise<User>>(); findUser.mockResolvedValue({ id: "u_1", name: "Ada" }); const greet = async (id: string) => `Hello, ${(await findUser(id)).name}`; expect(await greet("u_1")).toBe("Hello, Ada"); expect(findUser.callCount).toBe(1); expect(findUser.calls[0]).toEqual(["u_1"]); expect(findUser.invoked).toBe(true); }); });

What you should see: one passing test. mockResolvedValue returns a real promise, so await behaves as it would against the real repository.

The rest of the surface: mockReturnValue(v) for a plain value, mockRejectedValue(err) for a promise that rejects, mockImplementation(fn) to run your own logic, mockClear() to forget the recorded calls, and mockReset() to forget the calls and go back to the default you passed to createMockFn.

Watch out: mockRejectedValue(new Error("boom")) makes the mock return a rejected promise; it does not throw as the call happens. Assert with await expect(mock()).rejects.toThrow("boom"), not expect(() => mock()).toThrow().

A spy on a method

A spy leaves the real behaviour alone. Here the method still computes its answer, and the spy tells you it ran once.

import { it, expect } from "vitest"; import { createSpyMethod } from "@zudojs/testing"; it("records the call and keeps the real result", () => { const service = { prefix: "user", label(id: string): string { return `${this.prefix}:${id}`; }, }; const spy = createSpyMethod(service, "label"); expect(service.label("7")).toBe("user:7"); expect(spy.callCount).toBe(1); expect(spy.calls[0]).toEqual(["7"]); expect(spy.results[0]).toBe("user:7"); spy.restore(); // puts the original method back });

What you should see: a pass — this.prefix still resolves inside the spied method, because the spy forwards the receiver. Anything the wrapped function throws lands in spy.errors.

In plain words: createSpyMethod gives you restore(), because it changed an object and must put it back. createSpyFn gives you reset(), because it changed nothing — there is only the recording to clear.

A stub of an interface

A stub answers every method with undefined unless you override it, so you write only the part the test depends on.

import { it, expect } from "vitest"; import { createStub } from "@zudojs/testing"; interface UserService { find(id: string): string; remove(id: string): void; } it("only implements what the test uses", () => { const users = createStub<UserService>({ find: (id) => `user:${id}`, }); expect(users.find("7")).toBe("user:7"); expect(users.remove("7")).toBeUndefined(); });

What you should see: both expectations pass. A stub is also safe to await or return from an async factory — then, catch and finally answer undefined rather than pretending to be a promise.

Stable identity (changed in v1.1.2): a generated no-op is memoised per property, so the same property always answers with the same function object. Before v1.1.2 every access built a fresh closure, so stub.handler !== stub.handler and any code that registered and then unregistered the same stub property removed nothing.

const deps = createStub<{ handler(): void }>(); expect(deps.handler).toBe(deps.handler); // v1.1.2: true. Before: false. bus.on("ping", deps.handler); bus.off("ping", deps.handler); // v1.1.2: removes it. Before: leaked to the next test.

Overrides you supply are returned untouched and were always stable. The memoisation applies only to the no-ops the stub generates, and only to string-keyed properties — a symbol property still answers undefined.

When the code under test uses new or checks instanceof, stub the class instead. Instances keep the real prototype, and any method you did not override exists as a no-op.

const StubDatabase = createStubClass(RealDatabase, { query: async () => [] }); const db = new StubDatabase(); // db instanceof RealDatabase === true, and db.connect() returns undefined.

TEST CLOCK

A test clock is an object that reports a time you choose. Pass its now or timestamp into the code under test instead of letting that code call Date.now() itself.

It exists so that a test about expiry, retries or timeouts finishes in a millisecond and gives the same answer every time it runs.

import { it, expect } from "vitest"; import { createTestClock } from "@zudojs/testing"; it("moves time on demand", () => { const clock = createTestClock("2026-01-01T00:00:00Z"); clock.advance(60_000); expect(clock.now.toISOString()).toBe("2026-01-01T00:01:00.000Z"); clock.add({ hours: 1, minutes: 30 }); expect(clock.now.toISOString()).toBe("2026-01-01T01:31:00.000Z"); clock.set(0); expect(clock.timestamp).toBe(0); });

What you should see: one passing test. createTestClock accepts a Date, an ISO string or a millisecond number; with no argument it starts at the real current time. add understands seconds, minutes, hours and days.

Watch out: reset() jumps to the real current time — it does not return to the time you passed to createTestClock. To get back to a fixed start, call clock.set(startTime) again. An unparseable time such as createTestClock("last tuesday") throws a TypeError instead of quietly producing NaN.

CLEANUP MANAGER

A cleanup manager is a list of "undo" functions. You register each one as you open a resource, and a single dispose() runs them all in reverse order — last opened, first closed.

Reverse order matters: a server started after a database connection must stop before that connection closes.

import { it, expect } from "vitest"; import { createCleanupManager } from "@zudojs/testing"; it("closes resources in reverse order", async () => { const closed: string[] = []; const cleanup = createCleanupManager(); cleanup.register(() => { closed.push("database"); }, "database"); cleanup.register(async () => { closed.push("server"); }, "server"); expect(cleanup.count).toBe(2); await cleanup.dispose(); expect(closed).toEqual(["server", "database"]); expect(cleanup.disposed).toBe(true); });

What you should see: the array is ["server", "database"] — the opposite of the registration order.

Every cleanup runs even if an earlier one throws. If any of them failed, dispose() then rejects with an AggregateError naming the labels that failed, so a leaked connection cannot pass unnoticed.

// Two failures produce: "2 of 2 cleanup functions failed: server, database" await expect(cleanup.dispose()).rejects.toThrow(/cleanup functions failed/);
Watch out: always await cleanup.dispose(). If you drop the promise, a cleanup failure becomes an unhandled rejection that may be blamed on a different test. Registering after dispose() throws — build a fresh manager per test.

ASSERTIONS

An assertion here is a plain function that throws an Error when the value is wrong and returns quietly when it is right. Your test runner turns that thrown error into a failing test, so these work alongside expect() rather than replacing it.

They compare structurally. deepEqual(a, b) answers true or false; findDifference(actual, expected) returns undefined when they match, or a { path, reason } object pointing at the first place they differ. Objects must share a prototype ({} and Object.create(null) count as the same); Errors compare name, message and cause; boxed primitives compare their value; typed arrays must share a constructor; distinct Promises/WeakMaps/WeakSets are never equal.

import { it, expect } from "vitest"; import { deepEqual, findDifference } from "@zudojs/testing"; it("compares by contents, not by printing", () => { expect(deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true); expect(deepEqual(new Set([1]), new Set())).toBe(false); const difference = findDifference( { user: { roles: ["admin", "editor"] } }, { user: { roles: ["admin", "viewer"] } }, ); console.log(difference?.path); // "value.user.roles[1]" expect(difference?.path).toBe("value.user.roles[1]"); });

What you should see: value.user.roles[1] printed, and a passing test. Key order does not matter; Map, Set, Date, RegExp, BigInt and typed arrays are compared by value, and a circular object reports a mismatch instead of crashing.

Asserting on errors

Use these when the correct behaviour is a failure. assertThrows is for synchronous code and assertRejects for async; both hand you back the error so you can keep checking it.

import { it, expect } from "vitest"; import { assertThrows, assertRejects, assertErrorType } from "@zudojs/testing"; it("rejects an empty name", async () => { const validate = (name: string): string => { if (name === "") throw new RangeError("name is required"); return name; }; const error = assertThrows(() => validate(""), "name is required"); assertErrorType(error, RangeError); await assertRejects(async () => { throw new Error("offline"); }, "offline"); expect(error.message).toBe("name is required"); });

What you should see: a pass. The second argument is a substring of the message, not a regular expression. For Zudojs errors there are also assertErrorCode(error, "ERR_NOT_FOUND") and assertErrorMetadata(error, "userId", "u_1"), which compares metadata structurally so arrays and objects can match.

Asserting on events and messages

These take the recorded array, not a bus — pass bus.published or bus.dispatched. assertEventPublished and assertMessageDispatched require at least one entry of that type; assertEventNotPublished and assertMessageNotDispatched require none.

For a single event there are assertEventType(event, type), assertRecordedEventType(recorded, type) and assertEventPayload(event, expected), which compares the payload structurally. There is a worked example in Doubles for framework services.

Serialization round-trips

Four helpers check that a value survives being turned into JSON and back: assertSerializesCorrectly, assertSerializesTo, assertDeserializesTo and assertTypePreservesRoundTrip. The last one takes a checker function you write, so it can verify anything.

import { it } from "vitest"; import { assertSerializesTo, assertTypePreservesRoundTrip, } from "@zudojs/testing"; it("keeps a Date a Date", () => { assertSerializesTo({ a: 1 }, '{"a":1}'); assertTypePreservesRoundTrip( { date: new Date("2026-01-01T00:00:00Z") }, (restored) => restored.date instanceof Date, "Date", ); });
Good to know: assertSerializesCorrectly and assertDeserializesTo compare structurally with findDifference, so Map and Set contents are checked. assertSerializesTo compares the JSON string by design, and assertTypePreservesRoundTrip uses your own checker; failures are rendered with describeValue, so a BigInt or circular value produces a readable assertion error rather than a serialisation exception.

TESTING AN HTTP APP

createHttpTestClient(target) sends real HTTP requests to your app and checks the answers, in the style of supertest. You hand it your router (or server, or handler); it starts it on a free port when it needs one, and you write one chain per request: build it, send it, check it.

Because the request really travels through the server, the parts you would otherwise skip — the request guard, body size limits, error mapping, headers and cookies — behave exactly as they do in production.

import { it, beforeEach, afterEach } from "vitest"; import { createRouter, createResponseContext } from "@zudojs/http"; import { createCleanupManager, createHttpTestClient } from "@zudojs/testing"; import type { CleanupManager } from "@zudojs/testing"; const router = createRouter(); router.get("/users/:id", (ctx) => createResponseContext().json({ id: ctx.params.id })); let cleanup: CleanupManager; beforeEach(() => { cleanup = createCleanupManager(); }); // a fresh one per test afterEach(() => cleanup.dispose()); // closes the server the client started it("returns one user", async () => { const client = createHttpTestClient(router, { cleanup }); const response = await client .get("/users/7") .query({ expand: "roles" }) .auth("token-123") // Authorization: Bearer token-123 .expect(200) .expect("content-type", /json/) .expectJson({ id: "7" }); response.json<{ id: string }>(); // typed body: { id: "7" } });

What you should see: a pass. Change .expect(200) to .expect(404) and the test fails with Expected status 404, got 200. Nothing is sent until the chain is awaited, and it is sent once.

What you can point it at

TargetHow it is reached
An @zudojs/http HttpRouter, HttpServer, NodeHttpAdapter or HttpMiddlewarePipelineServed through a real NodeHttpAdapter on a free port. A running HttpServer is used where it already listens.
An @zudojs/http HttpHandler functionThe same, but pass { kind: "zudo" }: a one-argument function is otherwise taken to be a fetch handler.
A Node http.ServerStarted on 127.0.0.1:0 if it is not listening, and closed by close(). A server that is already listening is used where it is and left running.
A Node listener, (req, res) => voidWrapped in a Node server on a free port.
A fetch handler, (request: Request) => Response, or an object with a fetch methodCalled in-process, no port at all. The request's signal aborts on timeout. Handy for createApiFetchHandler and createRPCFetchHandler.
A base URL, "http://127.0.0.1:3000/api"Over the network. A path prefix in the URL applies to every request.

Building a request and checking the answer

CallWhat it doesNotes
.get / .post / .put / .patch / .delete / .head / .options(path)Starts a request.Or client.request(method, path). A built createTestHTTPRequest() request works too, with :params filled in.
.set(name, value) or .set({ … })Sets headers.
.query({ … })Adds query parameters.
.send(body)Sets the body.Objects as JSON, strings as text, bytes as octet-stream, URLSearchParams as a form.
.auth(token) or .auth(user, password)Bearer or Basic authorization.
.timeout(ms)Per-request time limit.Default 5 s, or { timeout } on the client. Rejects with a TimeoutError. Values beyond Node's timer limit (about 24.8 days) are clamped to it, so a huge timeout means "wait a very long time"; it used to overflow and fire after 1 ms.
.expect(status)Checks the status code.
.expect(header, "value" | /re/)Checks a header.
.expect((response) => …)Runs your own check.
.expectJson(partial)Checks the JSON body.Extra object keys are allowed; arrays must match in length.
.expectText("…" | /re/)Checks the body as text.

The awaited response has status, headers, header(name), text, body (parsed for JSON types), json<T>(), bytes, cookies and setCookies. It is a test response, so assertResponseStatus, assertResponseBody and the other assertions below accept it. Redirects are not followed.

When a check fails: it throws an AssertionError from node:assert. The message names the request and previews the body, and the stack points at the .expect…() line that failed, so Vitest shows you exactly which check broke. A connection failure is a NetworkError and a timeout a TimeoutError, both from @zudojs/errors.

Cookies

The client keeps a cookie jar, like a browser: cookies from Set-Cookie are stored in client.cookies and sent back on later requests, honouring Path, Max-Age and Expires. A login flow therefore just works:

// router.post("/login", ...) answers with .cookie("session", "abc") await client.post("/login").send({ user: "ada" }).expect(200); client.cookies.get("session"); // "abc" // Sent with Cookie: session=abc automatically. await client.get("/me").expect(200);

An explicit Cookie header replaces the jar for that one request, and createHttpTestClient(target, { cookies: false }) turns the jar off. The jar also has set, delete, clear and toJSON.

Cookie paths are matched against the path the server saw, including any path in the base URL. With a client made for http://127.0.0.1:3000/api, client.get("/me") requests /api/me, so a cookie the server set with Path=/api is sent back, exactly as a browser would. Before 1.2.0 the jar compared Path=/api with /me and never returned the cookie.

Closing

client.close() closes only what the client started itself. Pass { cleanup } and it registers close() with your cleanup manager, as in the first example. Servers it starts are unref'd, so a forgotten close() cannot keep the test process alive — but close anyway, so each test starts clean. Other client options: timeout, headers (sent on every request), kind, origin and adapter.

HTTP TESTING

These builders make plain request and response objects in memory. Nothing listens on a port and nothing is sent over a network — you hand the request to your handler and assert on what comes back. To send real requests to a running app instead, use createHttpTestClient (see Testing an HTTP app); the assertions below accept its responses too.

Build a request with the fluent createTestHTTPRequest(), or in one call with createHTTPRequest(method, path, options).

import { it, expect } from "vitest"; import { createTestHTTPRequest, jsonResponse, assertOK, assertResponseHeader, assertResponseBodyContains, } from "@zudojs/testing"; it("answers a user lookup", () => { const request = createTestHTTPRequest() .GET("/api/users") .withHeader("Authorization", "Bearer token123") .withQuery({ page: "1" }) .build(); expect(request.path).toBe("/api/users"); expect(request.headers.get("authorization")).toBe("Bearer token123"); expect(request.query.page).toBe("1"); // Whatever your handler returned, described as a test response: const response = jsonResponse({ id: "u_1", name: "Ada" }); assertOK(response); assertResponseHeader(response, "Content-Type", "application/json"); assertResponseBodyContains(response, { id: "u_1" }); });

What you should see: a pass. Header names are matched case-insensitively. assertResponseBody requires the body to match exactly; assertResponseBodyContains only checks the keys you list, which is what you want when the body carries a generated id or timestamp.

Ready-made responses cover the common statuses: jsonResponse(body) (200), createdResponse(body) (201), noContentResponse() (204), badRequestResponse(message) (400), notFoundResponse() (404) and serverErrorResponse() (500). Matching assertions: assertOK, assertCreated, assertNoContent, assertBadRequest, assertNotFound, assertServerError, plus assertResponseStatus(response, code) for anything else.

For a response you want to shape yourself, use the builder. json, text and html each set the matching content-type and mark the response as sent.

const response = createTestHTTPResponse() .status(202) .header("X-Request-Id", "req_1") .json({ accepted: true }) .build();

FIXTURES

A fixture factory builds a complete, valid object from the one or two fields your test actually cares about. Everything else — the id, the timestamp — is filled in for you.

This keeps tests short and keeps them honest: if the shape of an event changes, one factory changes, not fifty tests.

import { it, expect } from "vitest"; import { createEvent, createEvents, createMessage } from "@zudojs/testing"; it("builds events without repeating boilerplate", () => { const event = createEvent<{ userId: string }>({ type: "user.created", payload: { userId: "u_1" }, }); expect(event.type).toBe("user.created"); expect(event.payload).toEqual({ userId: "u_1" }); expect(event.id).toBeDefined(); expect(event.timestamp).toBeInstanceOf(Date); const batch = createEvents<{ index: number }>(3, (i) => ({ type: "item.added", payload: { index: i }, })); expect(batch).toHaveLength(3); expect(batch[2]?.payload.index).toBe(2); const message = createMessage({ type: "email.send" }); expect(message.type).toBe("email.send"); });

What you should see: a pass. With no options at all, createEvent() produces type "test.event" and createMessage() produces "test.message", each with an empty payload. createEventInput and createMessageInput build the smaller "input" shape — type, payload and optional metadata — that buses accept for publishing.

TEST CONTEXT

A test context bundles the per-test pieces into one object: a clock, a cleanup manager, and three recorders for logs, events and messages. Create one per test and dispose it in teardown.

import { describe, it, expect, afterEach } from "vitest"; import { createTestContext } from "@zudojs/testing"; describe("order flow", () => { let ctx = createTestContext(); afterEach(async () => { await ctx.dispose(); ctx = createTestContext(); }); it("records what happened", () => { ctx.logs.record("info", "order placed", { orderId: "o_1" }); ctx.events.record("order.placed", { id: "o_1" }); expect(ctx.logs.findByLevel("info")).toHaveLength(1); expect(ctx.logs.findByMessage("order")).toHaveLength(1); expect(ctx.events.findByType("order.placed")).toHaveLength(1); }); });

What you should see: a pass. Every recorder has entries, record(), clear() and findByType; the log recorder has findByLevel and findByMessage instead.

In plain words: these recorders are notebooks, not wiretaps. Nothing writes into them automatically — your code (or your test) calls record(). To capture what a real logger or bus did, use createSpyLogger or createTestEventBus below.

DOUBLES FOR FRAMEWORK SERVICES

The remaining factories wrap real Zudojs services in a recording shell. You get the genuine behaviour plus a list of everything that went through.

FactoryWhat you getWhere the recording lives
createSpyLogger(name?, level?)A full logger that writes nowhere and remembers everything.logger.calls
createTestContainer({ overrides })A started DI container with your fakes registered.— (use resolve / has)
createTestEventBus(options?)A started EventBus that records every publish, publishEvent and emit. It is the bus: testBus.bus === testBus.testBus.published, findByType(type)
createTestMessageBus(options?)A MessageBus that records every send and dispatch, on the double or on .bus.testBus.dispatched, findByType(type)
createTestQueue(name, options?)A Queue (in memory) that records every add, on the double or on the underlying .queue.testQueue.jobs, findByName(name)
createTestConfigManager(values?)A ConfigManager pre-loaded with values, auto-loading off.— (use get / set)
createTestApplication(options?)A container, logger, clock and cleanup manager wired together. Silent, with a fixed clock, by default.app.logger.calls
new InMemoryTestStorage()A key-value store with optional TTL. A TTL of 0 expires immediately; only an omitted TTL never expires.— (use keys() / size)

Example: spy logger and in-memory storage

This test checks that the code logged what it should and stored what it should, with nothing on disk and nothing on the console.

import { it, expect } from "vitest"; import { createSpyLogger, InMemoryTestStorage } from "@zudojs/testing"; it("logs and stores the new user", () => { const logger = createSpyLogger("app"); const store = new InMemoryTestStorage(); // A child logger writes into the same recording as its parent. logger.child({ name: "users" }).info("user saved", { userId: "u_1" }); store.set("users:u_1", { id: "u_1" }); expect(logger.calls).toHaveLength(1); expect(logger.findByMessage("saved")).toHaveLength(1); expect(logger.findByMetadata("userId", "u_1")).toHaveLength(1); expect(store.has("users:u_1")).toBe(true); expect(store.size).toBe(1); });

What you should see: a pass. Loggers made with child() or withContext() share the parent's recording, so you can assert on the parent no matter which derived logger the code used. child({ metadata }) metadata appears on every call the child writes (call metadata wins) and child({ level }) overrides the level. The logger honours its level: createSpyLogger("app", 2) records fatal, error and warn only.

Expiry in InMemoryTestStorage

set(key, value, ttlMs) takes an optional time-to-live in milliseconds. The entry carries a deadline of Date.now() + ttlMs, and the next get, has, keys() or size drops it once that deadline has passed. Only an omitted (or undefined) TTL means "never expires". A NaN TTL throws a RangeError (TTL for "k" is NaN; pass a number of milliseconds or omit it.); before 1.2.0 it was stored and silently never expired. delete() returns false for a key that has already expired, as if it were not there.

const store = new InMemoryTestStorage(); store.set("fresh", 1); // no TTL — never expires store.set("stale", 1, 0); // deadline of now — already expired expect(store.has("fresh")).toBe(true); expect(store.has("stale")).toBe(false); expect(store.get("stale")).toBeNull(); expect(store.size).toBe(1);
Changed in v1.1.2: a TTL of 0 is now a real deadline of "now", so the entry is already expired on the next read. Before v1.1.2 a zero TTL was treated as falsy and stored no deadline at all, so set(key, value, 0) produced an entry that never expired — the opposite of what a test writing 0 to mean "already stale" intended. If a test of yours relied on 0 meaning "no expiry", drop the argument instead.

Example: recording event bus

The test bus publishes through a real EventBus and keeps a copy of every publication. Dispose it when the test ends.

import { it, expect } from "vitest"; import { createTestEventBus, assertEventPublished, assertEventNotPublished, } from "@zudojs/testing"; it("publishes user.created and nothing else", async () => { const testBus = createTestEventBus(); await testBus.publish({ type: "user.created", payload: { id: "u_1" } }); expect(testBus.published).toHaveLength(1); assertEventPublished(testBus.published, "user.created"); assertEventNotPublished(testBus.published, "user.deleted"); testBus.dispose(); });

What you should see: a pass. testBus.published hands back a copy, so holding on to it will not show later publications. createTestMessageBus works the same way with send() and dispatched, and createTestQueue with add() and jobs.

Every path is recorded

In a real test you rarely call publish yourself. You hand the bus to the code under test and it publishes however it likes. Since 1.2.0 the doubles record every way in: the test event bus is an EventBus (so you can pass it wherever one is expected), and it records publish, publishEvent and emit. testBus.bus is the same object, kept so older tests still compile.

import { it, expect } from "vitest"; import { createTestEventBus } from "@zudojs/testing"; import type { EventBus } from "@zudojs/events"; // The code under test only knows it gets an EventBus. async function registerUser(events: EventBus, name: string): Promise<void> { await events.publishEvent({ type: "user.created", payload: { name } }); } it("records a publish made by code that was handed the bus", async () => { const events = createTestEventBus(); await registerUser(events, "ann"); // the double itself is an EventBus await registerUser(events.bus, "bob"); // .bus is the same object expect(events.bus).toBe(events); expect(events.findByType("user.created")).toHaveLength(2); console.log(events.published.map((entry) => entry.event.payload)); // [ { name: 'ann' }, { name: 'bob' } ] events.dispose(); });

What you should see: a pass, and the two payloads printed. publish now accepts a full Event, as EventBus.publish does, as well as the short { type, payload } input it always took. The same goes for the other doubles: createTestMessageBus() is a MessageBus that records both send and dispatch, and createTestQueue() is a Queue that records add whether you call it on the double or on testQueue.queue. Destructured methods (const { publish } = createTestEventBus()) keep working.

The test queue also passes onJobReady through to the real queue, so a Worker from @zudojs/queue running on a test queue wakes up as soon as a job is added instead of waiting for its next poll.

Changed in v1.2.0: before this release only the double's own wrapper method recorded. createTestEventBus().bus.publishEvent(...), createTestMessageBus().bus.send(...) and createTestQueue().queue.add(...) ran but recorded nothing, so a test asserting on published, dispatched or jobs could pass while checking an empty list. If one of your assertions such as toHaveLength(0) now fails, it was hiding a real publication.

A test application: quiet and fixed in time

createTestApplication() gives you a container, a logger, a clock and a cleanup manager in one object. By default it prints nothing and always starts at the same moment. The logger is a createSpyLogger(name) that records every line in app.logger.calls, and the clock is a test clock pinned at DEFAULT_TEST_APPLICATION_TIME (2026-01-01T00:00:00.000Z).

import { it, expect } from "vitest"; import { createTestApplication, DEFAULT_TEST_APPLICATION_TIME } from "@zudojs/testing"; it("logs quietly and reads a fixed time", async () => { const app = createTestApplication({ name: "billing" }); app.logger.info("invoice created", { invoiceId: "inv_1" }); // prints nothing expect(app.logger.calls).toHaveLength(1); expect(app.logger.findByMessage("invoice")).toHaveLength(1); console.log(app.clock.now.toISOString()); // 2026-01-01T00:00:00.000Z expect(app.clock.timestamp).toBe(DEFAULT_TEST_APPLICATION_TIME); await app.dispose(); // closes the container, then the logger });

What you should see: a pass, the date 2026-01-01T00:00:00.000Z printed, and no log line in the output. To opt back in to the old behaviour, pass logger: createLogger({ name }) from @zudojs/logger to print, clock for a clock of your own, or startTime to move the default clock (startTime: Date.now() for the wall-clock time). app.logger is typed as the logger you passed, or as a SpyLogger when you passed none.

Changed in v1.2.0: the application used to get a real logger that printed to the console and a clock that started at the current time. A test that read app.clock.now and expected "today" now gets 2026-01-01; pass startTime: Date.now() if it really needs the current time.

API REFERENCE

Everything below is exported from the package root: import { … } from "@zudojs/testing".

Factories

NameWhat it doesNotes
createMockFn(default?)Fake function that records calls and returns configured values.Pass a default return value, or nothing.
createSpyFn(fn)Wraps a function, recording calls, results and errors.Clear with reset().
createSpyMethod(obj, key)Replaces a method in place with a recording wrapper.Undo with restore(); throws if the property is not a function.
createStub<T>(overrides?)Object whose methods return undefined unless overridden.Safe to await. A generated no-op is memoised per property, so stub.a === stub.a (since v1.1.2).
createStubClass(C, overrides?)Constructor whose instances pass instanceof C.Non-overridden methods become no-ops.
createTestClock(initialTime?)Clock you move by hand.Accepts Date / string / number; throws on an unparseable value.
createCleanupManager(options?)Runs registered cleanups in reverse order.Options: label, onError.
createTestContext(options?)Clock + cleanup + log/event/message recorders.Options: clock, cleanup.
createLogRecorder() createEventRecorder() createMessageRecorder()The individual recorders, if you want them on their own.Used internally by createTestContext.
createTestContainer(options?)Started DI container with overrides registered.Exposes resolve, has, dispose.
createTestApplication(options?)Container, logger, clock and cleanup in one object.Options: name, container, logger (default: silent createSpyLogger(name)), clock, startTime (default: DEFAULT_TEST_APPLICATION_TIME), cleanup. dispose() closes the container, then the logger. See A test application.
createSpyLogger(name?, level?)Recording logger; children share the recording.Defaults: name "test", level LEVELS.trace.
createTestConfigManager(values?, options?)Config manager pre-loaded with values.autoLoad is off by default.
createTestEventBus(options?)Started EventBus that records publish, publishEvent and emit..bus is the same instance. published, findByType, clear. Call dispose().
createTestMessageBus(options?)MessageBus that records send and dispatch.dispatched, findByType, clear. Call dispose().
createTestQueue(name, options?)In-memory Queue that records add, on the double and on .queue.jobs, findByName, clear; forwards onJobReady. Call close().
createHttpTestClient(target, options?)Sends real HTTP requests to your app, supertest style.Options: cleanup, timeout, headers, cookies, kind, origin, adapter. See Testing an HTTP app.
createHttpTestCookieJar()A standalone cookie jar, the one the client uses.get, set, delete, clear, toJSON, size.
createTestHTTPRequest()Fluent request builder.Finish with build().
createHTTPRequest(method, path, options?)Request in one call.Options: headers, query, body, params. query and params are copied, so changing your object afterwards does not change the request.
createTestHTTPResponse()Fluent response builder.json / text / html set the content type.
createHTTPResponse(status, body?, headers?)Response in one call.Always marked sent.
jsonResponse createdResponse noContentResponse badRequestResponse notFoundResponse serverErrorResponseReady-made 200 / 201 / 204 / 400 / 404 / 500 responses.The 4xx and 5xx ones use a { error } body.
createEvent createEventInput createEventsEvent fixtures.Defaults to type "test.event".
createMessage createMessageInput createMessagesMessage fixtures.Defaults to type "test.message".

Assertions and comparison

NameWhat it doesNotes
deepEqual(a, b)Structural equality. Set members and Map keys are matched by value, not identity.Returns a boolean; never throws.
findDifference(actual, expected, rootPath?)First difference as { path, reason }.undefined when equal; root path defaults to "value".
describeValue(value, depth?)Renders a value for a message.Handles circular and BigInt input.
assertResponseStatus assertOK assertCreated assertNoContent assertBadRequest assertNotFound assertServerErrorStatus-code checks on a test response.
assertResponseHeader(response, name, value)Exact header match.Name is lower-cased first.
assertResponseBody(response, expected)Whole-body structural match.Extra keys in the body fail.
assertResponseBodyContains(response, expected)Partial match on the listed keys.Body must be an object.
assertEventType assertRecordedEventType assertEventPayloadChecks on a single event.Payload is compared structurally.
assertEventPublished assertEventNotPublishedChecks over an array of recorded events.Pass bus.published.
assertMessageDispatched assertMessageNotDispatchedChecks over an array of recorded messages.Pass bus.dispatched.
assertThrows(fn, message?)Requires a synchronous throw; returns the error. Passing an async (promise-returning) function throws "use assertRejects" and handles the rejection, so nothing leaks as an unhandled rejection.message is a substring.
assertRejects(fn, message?)Requires a rejection; resolves to the error.Must be awaited.
assertErrorType(error, Class)Requires instanceof Class.Narrows the type for TypeScript.
assertErrorCode(error, code)Checks a Zudojs error's code.
assertErrorMetadata(error, key, value)Checks one metadata entry.Compared structurally.
assertSerializesCorrectly assertSerializesTo assertDeserializesTo assertTypePreservesRoundTripSerialization round-trip checks.assertSerializesCorrectly and assertDeserializesTo compare structurally; assertSerializesTo compares the JSON string. See the note above.

Classes, constants and helpers

NameWhat it doesNotes
InMemoryTestStorageKey-value store: get, set, delete, has, clear, keys(), size.set(key, value, ttlMs) expires entries at now + ttlMs; ttlMs: 0 is already expired and only an omitted TTL never expires (changed in v1.1.2). A NaN TTL throws a RangeError, and delete returns false for an expired key (both since v1.2.0). has tells a stored null from a miss.
DEFAULT_TEST_APPLICATION_TIMEWhere createTestApplication's default clock starts.Date.UTC(2026, 0, 1), i.e. 2026-01-01T00:00:00.000Z.
LEVELSNumeric log levels: fatal 0 → trace 5.Use with createSpyLogger's second argument.
createRecordingLogger, deepMatches, mergeContext, mergeLoggerContextInternals the spy logger is built from.Exported, but rarely needed directly.

Types

HTTP test client: HttpTestClient, HttpTestClientOptions, HttpTestTarget, HttpTestTargetKind, HttpTestRequest, HttpTestResponse, HttpTestExpectation, HttpTestCookieJar, FetchHandler, FetchApplication and NodeRequestListener.

All of these are exported as types only. Mocking: MockFn, SpyFn, SpyMethod. Clock and cleanup: TestClock, CleanupManager, CleanupEntry, CleanupManagerOptions. Context: TestContext, TestContextOptions, LogRecorder, EventRecorder, MessageRecorder, CapturedLogEntry, CapturedEvent, CapturedMessage. Services: TestContainer, TestContainerOptions, DependencyOverride, TestApplication, TestApplicationOptions, TestConfigManager, TestEventBus, RecordedEvent, TestMessageBus, RecordedMessage, TestQueue, RecordedJob. HTTP: TestHTTPRequest, HTTPRequestBuilder, TestHTTPResponse, HTTPResponseBuilder. Logger: SpyLogger, LogCall, Recorder, DerivedOptions. Fixtures and comparison: CreateEventOptions, CreateMessageOptions, Difference.

COMMON MISTAKES

  • Expecting mockRejectedValue to throw as you call it. Your expect(() => mock()).toThrow() fails and the rejection surfaces later as an unhandled promise. Fix: await expect(mock()).rejects.toThrow("boom").
  • Not awaiting cleanup.dispose(). A failing cleanup rejects with an AggregateError that lands on whichever test happens to be running. Fix: await ctx.dispose() in afterEach.
  • Reusing a cleanup manager after disposing it. register() throws "Cannot register cleanup after manager has been disposed." Fix: build a fresh manager (or a fresh test context) per test.
  • Expecting clock.reset() to return to the time you started with. It jumps to the real current time, so later expiry checks silently pass. Fix: call clock.set(startTime) with your fixed time instead.
  • Passing the bus itself to an event assertion. assertEventPublished(bus, "user.created") is a type error, and at runtime nothing matches. Fix: pass the recorded array — assertEventPublished(bus.published, "user.created").
  • Checking a Map or Set with assertSerializesTo. It compares JSON strings, and those types stringify to {}, so assertSerializesTo(new Map([["a", 1]]), "{}") passes while the contents are lost. Fix: use assertSerializesCorrectly, which compares the round-tripped value structurally, or assertTypePreservesRoundTrip with your own checker.
  • Expecting createTestApplication() to print its logs or use today's date. Since v1.2.0 its logger is silent and its clock starts at 2026-01-01. Fix: read app.logger.calls, or pass logger / startTime: Date.now().
  • Not awaiting an HTTP test request. client.get("/users/7").expect(200) on its own sends nothing and checks nothing, so the test passes whatever the app does. Fix: await every chain.
  • Forgetting spy.restore() after createSpyMethod. The object keeps the wrapper for the rest of the file, and later tests count calls they did not make. Fix: restore in afterEach, or register it with the cleanup manager.

COMPLETE EXPORT INDEX

Every name @zudojs/testing exports from its package root at v1.2.0 — 125 in total, generated from the package’s own entry point rather than written by hand. The sections above explain the ones you reach for most; this is the exhaustive list, so nothing shipped is undocumented. Names not covered above are typically internal helpers and supporting types.

Show all 125 exports
Classes (1)
InMemoryTestStorage
Functions (70)
assertBadRequest assertCreated assertDeserializesTo assertErrorCode assertErrorMetadata assertErrorType assertEventNotPublished assertEventPayload assertEventPublished assertEventType assertMessageDispatched assertMessageNotDispatched assertNoContent assertNotFound assertOK assertRecordedEventType assertRejects assertResponseBody assertResponseBodyContains assertResponseHeader assertResponseStatus assertSerializesCorrectly assertSerializesTo assertServerError assertThrows assertTypePreservesRoundTrip badRequestResponse createCleanupManager createdResponse createEvent createEventInput createEventRecorder createEvents createHTTPRequest createHTTPResponse createHttpTestClient createHttpTestCookieJar createLogRecorder createMessage createMessageInput createMessageRecorder createMessages createMockFn createRecordingLogger createSpyFn createSpyLogger createSpyMethod createStub createStubClass createTestApplication createTestClock createTestConfigManager createTestContainer createTestContext createTestEventBus createTestHTTPRequest createTestHTTPResponse createTestMessageBus createTestQueue deepEqual deepMatches describeValue findDifference findPartialDifference jsonResponse mergeContext mergeLoggerContext noContentResponse notFoundResponse serverErrorResponse
Interfaces (43)
CapturedEvent CapturedLogEntry CapturedMessage CleanupEntry CleanupManager CleanupManagerOptions CreateEventOptions CreateMessageOptions DependencyOverride DerivedOptions Difference EventRecorder FetchApplication HTTPRequestBuilder HTTPResponseBuilder HttpTestClient HttpTestClientOptions HttpTestCookieJar HttpTestRequest HttpTestRequestSummary HttpTestResponse LogRecorder MessageRecorder MockFn RecordedEvent RecordedJob RecordedMessage Recorder SpyFn SpyMethod TestApplication TestApplicationOptions TestClock TestConfigManager TestContainer TestContainerOptions TestContext TestContextOptions TestEventBus TestHTTPRequest TestHTTPResponse TestMessageBus TestQueue
Type aliases (9)
FetchHandler HttpTestAdapterOptions HttpTestBody HttpTestExpectation HttpTestQuery HttpTestQueryValue HttpTestTarget HttpTestTargetKind NodeRequestListener
Constants (2)
DEFAULT_TEST_APPLICATION_TIME LEVELS