MockBuilder API
When you call mock(group, method), you get a MockBuilder with a fluent chainable API:
// R is the endpoint's response, V the variables it is called with
type MockBuilder<R, V> = {
/** Return static data (status 200) */
resolves(data: R): MockBuilder<R, V>
/** Return data computed from the variables of the call */
returns(fn: (variables: V) => R | Promise<R>): MockBuilder<R, V>
/** Throw HTTPError with the given status and optional data */
rejects(status: number, data?: unknown): MockBuilder<R, V>
/** Limit this mock to a single invocation */
once(): MockBuilder<R, V>
/** Limit this mock to N invocations */
times(n: number): MockBuilder<R, V>
/** Add artificial delay (ms) before responding */
delay(ms: number): MockBuilder<R, V>
}resolves
Return static data with a 200 status. This is the most common mock:
mock("users", "getAll").resolves([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
])
const users = await client.users.getAll()
// users === [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]returns
Provide a function that receives the request variables and returns data dynamically. The variables are typed as the endpoint declares them:
mock("users", "getById").returns(({ id }) => {
return { id, name: `User ${id}`, email: `user${id}@test.com` }
})
const user1 = await client.users.getById({ id: 1 })
// user1 === { id: 1, name: "User 1", email: "user1@test.com" }
const user2 = await client.users.getById({ id: 2 })
// user2 === { id: 2, name: "User 2", email: "user2@test.com" }The function can also be async:
mock("users", "getById").returns(async ({ id }) => {
// Simulate async work
return { id, name: "Loaded", email: `user${id}@test.com` }
})rejects
Throw an HTTPError with a given status code and optional response body:
mock("users", "getById").rejects(404, { message: "Not found" })
// Throws HTTPError with status 404 and data { message: "Not found" }
await client.users.getById({ id: 999 })If you omit the data argument, a default error body is used:
mock("users", "delete").rejects(403)
// Throws HTTPError with status 403 and data { success: false, message: "HTTP 403" }
await client.users.delete({ id: 1 })once
Limit a mock to a single invocation. After the mock is consumed, the next call will use a previously registered mock (LIFO) or throw a "no mock" error:
mock("users", "getAll").resolves([{ id: 1, name: "Alice" }]) // permanent fallback
mock("users", "getAll").resolves([]).once() // consumed first
const first = await client.users.getAll()
// first === [] (the .once() mock)
const second = await client.users.getAll()
// second === [{ id: 1, name: "Alice" }] (falls back to the permanent mock)times
Limit a mock to N invocations:
mock("users", "getAll")
.rejects(500, { error: "Server error" })
.times(2)
mock("users", "getAll").resolves([]) // fallback
// First two calls throw HTTPError 500
// Third call returns []delay
Add an artificial delay before the mock responds. Useful for testing loading states and timeouts:
mock("users", "getAll")
.resolves([{ id: 1, name: "Alice" }])
.delay(1000) // 1 second delay
// Takes ~1 second to resolve
const users = await client.users.getAll()Chaining
All methods return the same MockBuilder, so you can chain freely:
mock("users", "getAll")
.resolves([{ id: 1, name: "Alice" }])
.delay(100)
.once()LIFO Stack Behavior
Mocks for the same endpoint form a LIFO (last-in, first-out) stack. The most recently added mock is checked first. This lets you override specific scenarios without removing earlier mocks:
// Stack (bottom to top):
mock("users", "getAll").resolves([]) // 1. permanent base
mock("users", "getAll").rejects(500).once() // 2. consumed first
// Call 1: uses mock #2 (top of stack, .once()) → HTTPError 500
// Call 2: uses mock #1 (permanent) → []
// Call 3: uses mock #1 (permanent) → []Once a .once() or .times(n) mock is exhausted, it is popped off the stack, and the next mock below takes over.
Testing optimistic → error → retry pattern
it("retries after server error", async () => {
// Permanent success mock (base)
mock("users", "getAll").resolves([{ id: 1, name: "Alice" }])
// Temporary failure (consumed first)
mock("users", "getAll")
.rejects(500, { error: "Temporary failure" })
.once()
// First call fails
await expect(
client.users.getAll(),
).rejects.toThrow()
// Second call succeeds (failure mock was consumed)
const result = await client.users.getAll()
expect(result).toEqual([{ id: 1, name: "Alice" }])
})findRequests Helper
Use findRequests to filter the history array by group and optionally by method:
declare function findRequests(
history: HistoryEntry[],
group: string,
method?: string,
): HistoryEntry[]import { createTestClient, findRequests } from "@noverax/courier/testing"
const { client, mock, history } = createTestClient<typeof api>()
mock("users", "getAll").resolves([])
mock("posts", "getAll").resolves([])
await client.users.getAll()
await client.posts.getAll()
await client.users.getAll()
// All user calls
const userCalls = findRequests(history, "users")
expect(userCalls).toHaveLength(2)
// Only getAll calls
const getAllCalls = findRequests(history, "users", "getAll")
expect(getAllCalls).toHaveLength(2)History Entry Structure
Each entry records full request and response details:
type HistoryEntry = {
group: string // "users"
method: string // "getAll"
variables?: unknown // { name: "Alice" }
result?: {
status: number // 200
data: unknown // the resolved mock data
}
error?: Error // HTTPError or "no mock" Error
timestamp: number // Date.now() at call time
}Asserting on history entries
import { createTestClient, findRequests } from "@noverax/courier/testing"
import type { api } from "../lib/api"
const { client, mock, history } = createTestClient<typeof api>()
it("sends correct variables to create endpoint", async () => {
mock("users", "create").resolves({ id: 1, name: "Alice" })
await client.users.create({ name: "Alice", email: "a@b.com" })
const [entry] = findRequests(history, "users", "create")
expect(entry.variables).toEqual({ name: "Alice", email: "a@b.com" })
expect(entry.result?.status).toBe(200)
expect(entry.error).toBeUndefined()
})
it("records errors in history", async () => {
mock("users", "delete").rejects(403)
try {
await client.users.delete({ id: 1 })
} catch {
// expected
}
const [entry] = findRequests(history, "users", "delete")
expect(entry.error).toBeDefined()
expect(entry.result?.status).toBe(403)
})Next Steps
- Test Client -- creating and using the test client
- Error Handling -- HTTPError and isHTTPError for assertions