liaisev5.3.1
/Testing your code

Guide

Testing your code

This page shows how to test code that calls liaise. Read it when you write unit tests for anything that uses your client.

liaise/testing gives you a fetch stub that matches routes. Your tests then run the real pipeline, from URL building, headers and body to parsing and your own middleware. A stubbed API method that returns a canned Result drifts away from what liaise actually does. The stub needs no test runner, so it works in Vitest, Jest or anything else.

import { mockFetch, jsonResponse } from 'liaise/testing'

const mock = mockFetch({
  'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
  'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
})

mock.install()   // replaces globalThis.fetch
const { data } = await api.getUser({ id: '42' })   // your code, calling the real client
mock.restore()   // puts the original globalThis.fetch back

api is your client, created with baseUrl: '/api'. Nothing in it changes for the test, because the stub replaces only fetch. The route captures id from the URL, so data is { id: '42', name: 'Ada' }, and mock.restore() puts the real fetch back.

Routes

  • Routes are keyed as "METHOD /path". Each :token segment is captured and passed to a route function as { params, request }.

  • A route value is a Response, a route function, or an array of either. jsonResponse builds a Response with a JSON body, or you can build your own.

  • An array is a sequence. Each matching call takes the next entry, and the last entry repeats once the others are used up. That suits “fail twice, then succeed”:

    const mock = mockFetch({
      'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
    })
  • mock.calls records every request as { method, url, headers, body, init }. mock.callCount('GET /api/users/:id') and mock.lastCall(...) take the same "METHOD /path" keys as the routes.

Without replacing the global fetch

mock.fetch is the stub itself. Pass it to the client as fetch, and the global fetch is never touched, so there is nothing to restore:

import { createApi, defineRequest } from 'liaise'
import { mockFetch, jsonResponse } from 'liaise/testing'

type User = { id: string; name: string }

const mock = mockFetch({ 'GET /api/users/:id': jsonResponse({ id: '42', name: 'Ada' }) })
const api = createApi({
  baseUrl: '/api',
  requests: { getUser: defineRequest<User>()({ method: 'GET', path: '/users/:id' }) },
  fetch: mock.fetch,   // this client sends with the stub
})

const { data } = await api.getUser({ id: '42' })

Unmatched routes

A request that matches no route makes the stub throw, so a typo in a path can’t pass quietly. liaise catches every fetch rejection, so your code sees an ordinary Result with kind: 'network' and the Error in error.body. Its message names the method, the URL and every route you defined. Assert on the result, because the call itself never rejects.

const r = await api.getUser({ id: '42' })   // routes only define 'GET /api/user/:id'
expect(r.error?.kind).toBe('network')
expect(String(r.error?.body)).toMatch(/no route matched GET \/api\/users\/42/)

api is a client created with baseUrl: '/api'.

An empty array for a route behaves the same way, with a descriptive Error in error.body.

Stubbing a Result directly

To stub at the Result level instead of the fetch level, successResult(data) and errorResult(status, body) build a well-formed Result. This uses Vitest’s vi.spyOn, and any runner’s equivalent works the same way:

import { successResult, errorResult } from 'liaise/testing'

vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))

Stubbing this way skips the real pipeline, so prefer mockFetch when you can.

More of the stub’s behaviour, such as how it handles a signal, is under liaise/testing.

Next: That’s the guide. The recipes put these pieces together for common tasks, starting with Add an auth header and refresh the token on a 401.

esc