Skip to content

TestingMocking

Mocking

MockBuilder API for controlling endpoint responses in tests -- static data, dynamic handlers, errors, limits, and delays.

3 min readdirect mode

MockBuilder API

When you call mock(group, method), you get a MockBuilder with a fluent chainable API:

types
// 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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
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:

ts
// 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

tests/retry.test.ts
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:

types
declare function findRequests(
  history: HistoryEntry[],
  group: string,
  method?: string,
): HistoryEntry[]
tests/analytics.test.ts
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:

types
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

tests/audit.test.ts
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