universal-testing-utils
Reusable testing utilities that are potentially relevant for both backend and frontend
Compatibility matrix
| Helper | Contract API |
|---|---|
ApiContractMockttpHelper |
defineApiContract |
MswHelper |
buildRestContract / buildSseContract |
MockttpHelper (deprecated) |
buildRestContract / buildSseContract |
Table of contents
- ApiContractMockttpHelper
- msw integration with API contracts
- mockttp integration with API contracts (deprecated)
formatSseResponse
ApiContractMockttpHelper
Mock HTTP responses in mockttp-based tests using contracts defined with defineApiContract from @lokalise/api-contracts.
Setup
import { getLocal } from 'mockttp'
import { ApiContractMockttpHelper } from '@lokalise/universal-testing-utils'
const mockServer = getLocal()
const helper = new ApiContractMockttpHelper(mockServer)
beforeEach(() => mockServer.start())
afterEach(() => mockServer.stop())
mockResponse
Registers a mock rule for the given contract. responseStatus is the concrete numeric HTTP status code the mock will send (e.g. 201, 404). It also controls which schema is used: the helper looks up the contract entry with exact → range → 'default' precedence, so a contract with only a '2xx' key accepts any responseStatus in 200–299.
await helper.mockResponse(contract, params)
params is a discriminated union on responseStatus. The required body fields are inferred from the contract's response type for that status code:
| Response type | Required field |
|---|---|
ZodType (JSON) |
responseJson: z.input<T> |
sseResponse(schemas) |
events: { event: string; data: unknown }[] |
textResponse(contentType) |
responseText: string |
blobResponse(contentType) |
responseBlob: string |
ContractNoBody / noBodyResponse() |
(none) |
anyOfResponses([sse, json]) |
responseJson + events |
Path params are required when the contract declares requestPathParamsSchema, and optional otherwise.
JSON response
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/users',
responsesByStatusCode: { 200: z.object({ id: z.string() }) },
})
await helper.mockResponse(contract, {
responseStatus: 200,
responseJson: { id: '1' },
})
The response body is validated and stripped through the contract's Zod schema before being sent.
JSON response with path params
const contract = defineApiContract({
method: 'get',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: { 200: z.object({ id: z.string() }) },
})
await helper.mockResponse(contract, {
pathParams: { userId: '42' },
responseStatus: 200,
responseJson: { id: '42' },
})
No-body response
const contract = defineApiContract({
method: 'delete',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: { 204: ContractNoBody },
})
await helper.mockResponse(contract, {
pathParams: { userId: '1' },
responseStatus: 204,
})
SSE response
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/events/stream',
responsesByStatusCode: {
200: sseResponse({ 'item.updated': z.object({ id: z.string() }), completed: z.object({ totalCount: z.number() }) }),
},
})
await helper.mockResponse(contract, {
responseStatus: 200,
events: [
{ event: 'item.updated', data: { id: '1' } },
{ event: 'completed', data: { totalCount: 1 } },
],
})
Dual-mode response (SSE + JSON)
Contracts using anyOfResponses([sseResponse(...), jsonSchema]) serve either SSE or JSON depending on the request's Accept header. Both events and responseJson are required so the mock can respond to either mode.
const contract = defineApiContract({
method: 'post',
requestBodySchema: z.object({ name: z.string() }),
pathResolver: () => '/jobs',
responsesByStatusCode: {
200: anyOfResponses([sseResponse({ completed: z.object({ totalCount: z.number() }) }), z.object({ id: z.string() })]),
},
})
await helper.mockResponse(contract, {
responseStatus: 200,
responseJson: { id: '1' },
events: [{ event: 'completed', data: { totalCount: 1 } }],
})
- Requests with
Accept: text/event-streamreceive the SSE stream. - All other requests receive the JSON body.
Range and wildcard status keys
Contracts may use range keys ('1xx'–'5xx') or 'default' in responsesByStatusCode instead of exact codes. Pass any concrete numeric code covered by that range as responseStatus; the helper resolves the contract entry using the same exact → range → 'default' precedence as the runtime client.
Range key only — responseStatus accepts any code in 200–299:
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/items',
responsesByStatusCode: { '2xx': z.object({ id: z.string() }) },
})
await helper.mockResponse(contract, {
responseStatus: 201, // any 2xx code is valid
responseJson: { id: '1' },
})
'default' catch-all — responseStatus accepts any HttpStatusCode:
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/items',
responsesByStatusCode: { default: z.object({ id: z.string() }) },
})
await helper.mockResponse(contract, {
responseStatus: 200,
responseJson: { id: '1' },
})
Exact key takes priority — when both 200 and '2xx' are defined, responseStatus: 200 uses the exact entry and responseStatus: 201 falls back to the range entry:
const contract = defineApiContract({
method: 'get',
pathResolver: () => '/items',
responsesByStatusCode: {
200: z.object({ id: z.string() }),
'2xx': z.object({ id: z.string(), created: z.literal(true) }),
},
})
await helper.mockResponse(contract, { responseStatus: 200, responseJson: { id: '1' } })
await helper.mockResponse(contract, { responseStatus: 201, responseJson: { id: '2', created: true } })
How StatusCodeBodyPair works (type-level)
MockResponseParams<TContract> is a discriminated union on responseStatus. It has two branches:
ExactStatusCodePairs— one member per exact numeric key inresponsesByStatusCode.responseStatusis that literal number and the body fields come from the entry at that key.RangeStatusCodePairs— one member per wildcard key ('1xx'–'5xx','default').ExpandStatusRangeKey<K>expands the key to its numeric union (e.g.'2xx'→200|201|…|299), then exact codes already covered byExactStatusCodePairsare excluded viaExcludeso the discriminated union stays unambiguous.
Type safety
MockResponseParams<TContract> is exported for cases where you need to type the params object separately:
import type { MockResponseParams } from '@lokalise/universal-testing-utils'
function mockUser(params: MockResponseParams<typeof getUserContract>) {
return helper.mockResponse(getUserContract, params)
}
msw integration with API contracts
MswHelper provides a unified mockValidResponse method that works with all contract types — REST, SSE, and dual-mode. The contract type determines which params are required:
- REST contracts — requires
responseBody - SSE contracts — requires
events - Dual-mode contracts — requires both
responseBodyandevents
Basic usage
import { buildRestContract } from '@lokalise/api-contracts'
import { sendByContract } from '@lokalise/frontend-http-client'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import wretch, { type Wretch } from 'wretch'
import { z } from 'zod/v4'
import { MswHelper } from '@lokalise/universal-testing-utils'
const RESPONSE_BODY_SCHEMA = z.object({ id: z.string() })
const PATH_PARAMS_SCHEMA = z.object({ userId: z.string() })
const postContractWithPathParams = buildRestContract({
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
requestBodySchema: z.object({ name: z.string() }),
requestPathParamsSchema: PATH_PARAMS_SCHEMA,
method: 'post',
description: 'some description',
responseSchemasByStatusCode: { 200: RESPONSE_BODY_SCHEMA },
pathResolver: (pathParams) => `/users/${pathParams.userId}`,
})
const BASE_URL = 'http://localhost:8080'
describe('MswHelper', () => {
const server = setupServer()
const mswHelper = new MswHelper(BASE_URL)
const wretchClient = wretch(BASE_URL)
beforeAll(() => { server.listen({ onUnhandledRequest: 'error' }) })
afterEach(() => { server.resetHandlers() })
afterAll(() => { server.close() })
it('mocks POST request with path params', async () => {
mswHelper.mockValidResponse(postContractWithPathParams, server, {
pathParams: { userId: '3' },
responseBody: { id: '2' },
})
const response = await sendByContract(wretchClient, postContractWithPathParams, {
pathParams: { userId: '3' },
body: { name: 'frf' },
})
expect(response).toEqual({ id: '2' })
})
})
msw SSE contracts
mockValidResponse works with SSE contracts built using buildSseContract. Pass events instead of responseBody. Event names and data shapes are fully type-safe.
import { buildSseContract } from '@lokalise/api-contracts'
import { z } from 'zod/v4'
const sseContract = buildSseContract({
method: 'get',
pathResolver: () => '/events/stream',
serverSentEventSchemas: {
'item.updated': z.object({ items: z.array(z.object({ id: z.string() })) }),
completed: z.object({ totalCount: z.number() }),
},
})
// events is required, responseBody is not accepted
mswHelper.mockValidResponse(sseContract, server, {
events: [
{ event: 'item.updated', data: { items: [{ id: '1' }] } },
{ event: 'completed', data: { totalCount: 1 } },
],
})
// With path params
mswHelper.mockValidResponse(sseContractWithPathParams, server, {
pathParams: { userId: '42' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
// With query params
mswHelper.mockValidResponse(sseContractWithQueryParams, server, {
queryParams: { yearFrom: 2020 },
events: [{ event: 'completed', data: { totalCount: 5 } }],
})
msw dual-mode contracts
Dual-mode contracts (built with both successResponseBodySchema and serverSentEventSchemas) require both responseBody and events. A single handler is registered that routes on the Accept header:
Accept: text/event-stream→ returns SSE response- Otherwise → returns JSON response
const dualModeContract = buildSseContract({
method: 'post',
pathResolver: () => '/events/dual',
requestBodySchema: z.object({ name: z.string() }),
successResponseBodySchema: z.object({ id: z.string() }),
serverSentEventSchemas: {
'item.updated': z.object({ items: z.array(z.object({ id: z.string() })) }),
},
})
// Both responseBody and events are required
mswHelper.mockValidResponse(dualModeContract, server, {
responseBody: { id: '1' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
msw mockAnyResponse
Mocks API responses with any response body, bypassing contract schema validation. Useful for testing error responses or edge cases. Works with REST and dual-mode contracts.
// REST — any response shape, no schema validation
mswHelper.mockAnyResponse(postContract, server, {
responseBody: { error: 'Internal Server Error' },
responseCode: 500,
})
// Dual-mode — unvalidated responseBody + typed events, routes on Accept header
mswHelper.mockAnyResponse(dualModeContract, server, {
responseBody: { error: 'Something went wrong' },
responseCode: 500,
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
mockValidResponseWithAnyPath
Wildcards all path params so the mock matches any path param values. Works with all contract types — the same overloads as mockValidResponse apply (REST requires responseBody, SSE requires events, dual-mode requires both), but pathParams is never needed.
// REST
mswHelper.mockValidResponseWithAnyPath(postContractWithPathParams, server, {
responseBody: { id: '2' },
})
// SSE — matches any userId
mswHelper.mockValidResponseWithAnyPath(sseContractWithPathParams, server, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
// Dual-mode — matches any userId
mswHelper.mockValidResponseWithAnyPath(dualModeContractWithPathParams, server, {
responseBody: { id: '1' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
mockValidResponseWithImplementation
Custom handler for complex logic. The handleRequest callback receives the full MSW request info and returns the response body. Works with REST and dual-mode contracts.
// REST contract
mswHelper.mockValidResponseWithImplementation(postContractWithPathParams, server, {
pathParams: { userId: ':userId' },
handleRequest: async (requestInfo) => ({
id: `id-${requestInfo.params.userId}`,
}),
})
// Dual-mode contract — handleRequest for JSON, events for SSE
mswHelper.mockValidResponseWithImplementation(dualModeContract, server, {
handleRequest: async (requestInfo) => {
const body = await requestInfo.request.json()
return { id: `impl-${body.name}` }
},
events: [{ event: 'completed', data: { totalCount: 1 } }],
})
Per-call status codes with MswHelper.response()
By default, all calls return the same status code (params.responseCode or 200). To vary the status code per call, wrap the return value with MswHelper.response(body, { status }):
let callCount = 0
mswHelper.mockValidResponseWithImplementation(contract, server, {
handleRequest: () => {
callCount++
if (callCount === 1) {
return MswHelper.response({ error: 'Server error' }, { status: 500 })
}
return { id: 'success' } // plain return still works
},
})
This is fully non-breaking — returning the body directly (without MswHelper.response()) continues to work as before.
Status code priority: MswHelper.response({ status }) > params.responseCode > 200.
mockSseStream
Returns an SseEventController that lets you emit SSE events on demand during tests, instead of returning all events immediately. Works with SSE and dual-mode contracts.
// SSE contract — emit events on demand
const controller = mswHelper.mockSseStream(sseContract, server)
const response = await fetch('/events/stream')
controller.emit({ event: 'item.updated', data: { items: [{ id: '1' }] } })
controller.emit({ event: 'completed', data: { totalCount: 1 } })
controller.close()
// With path params
const controller = mswHelper.mockSseStream(sseContractWithPathParams, server, {
pathParams: { userId: '42' },
})
// Dual-mode contract — SSE side streams on demand, JSON side uses responseBody
const controller = mswHelper.mockSseStream(dualModeContract, server, {
responseBody: { id: '1' },
})
// JSON requests get immediate response
const jsonRes = await fetch('/events/dual', { headers: { accept: 'application/json' } })
// SSE requests get streaming response
const sseRes = await fetch('/events/dual', { headers: { accept: 'text/event-stream' } })
controller.emit({ event: 'completed', data: { totalCount: 42 } })
controller.close()
The controller is fully type-safe — event names and data shapes are inferred from the contract's serverSentEventSchemas.
mockttp integration with API contracts (deprecated)
Deprecated. Use
ApiContractMockttpHelperinstead, which works with the newdefineApiContract-based contracts.
MockttpHelper provides the same unified mockValidResponse API. The contract type determines params:
- REST contracts — requires
responseBody - SSE contracts — requires
events - Dual-mode contracts — requires both
responseBodyandevents
Basic usage
import { buildRestContract } from '@lokalise/api-contracts'
import { getLocal } from 'mockttp'
import wretch, { type Wretch } from 'wretch'
import { z } from 'zod/v4'
import { MockttpHelper } from '@lokalise/universal-testing-utils'
const mockServer = getLocal()
const mockttpHelper = new MockttpHelper(mockServer)
// REST contract
await mockttpHelper.mockValidResponse(postContract, {
responseBody: { id: '1' },
})
// With path params
await mockttpHelper.mockValidResponse(contractWithPathParams, {
pathParams: { userId: '3' },
responseBody: { id: '2' },
})
Query params support
Both mockValidResponse and mockAnyResponse support queryParams. When provided, the mock server will only match requests that include the specified query parameters.
await mockttpHelper.mockValidResponse(getContractWithQueryParams, {
queryParams: { yearFrom: 2020 },
responseBody: { id: '1' },
})
mockttp SSE contracts
await mockttpHelper.mockValidResponse(sseContract, {
events: [
{ event: 'item.updated', data: { items: [{ id: '1' }] } },
{ event: 'completed', data: { totalCount: 1 } },
],
})
// With path params
await mockttpHelper.mockValidResponse(sseContractWithPathParams, {
pathParams: { userId: '42' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
mockttp dual-mode contracts
Same as msw — a single handler routes on the Accept header:
await mockttpHelper.mockValidResponse(dualModeContract, {
responseBody: { id: '1' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
mockttp mockAnyResponse
Mocks API responses with any response body, bypassing contract schema validation. Works with REST and dual-mode contracts.
// REST — any response shape
await mockttpHelper.mockAnyResponse(postContract, {
responseBody: { error: 'Internal Server Error' },
responseCode: 500,
})
// Dual-mode — unvalidated responseBody + typed events, routes on Accept header
await mockttpHelper.mockAnyResponse(dualModeContract, {
responseBody: { error: 'Something went wrong' },
responseCode: 500,
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
formatSseResponse
A standalone helper exported for manual SSE response formatting:
import { formatSseResponse } from '@lokalise/universal-testing-utils'
const body = formatSseResponse([
{ event: 'item.updated', data: { items: [{ id: '1' }] } },
{ event: 'completed', data: { totalCount: 1 } },
])
// "event: item.updated\ndata: {\"items\":[{\"id\":\"1\"}]}\n\nevent: completed\ndata: {\"totalCount\":1}\n"