npm.io
4.0.0 • Published yesterday

@lokalise/universal-testing-utils

Licence
Apache-2.0
Version
4.0.0
Deps
0
Size
78 kB
Vulns
0
Weekly
0
Stars
11

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

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-stream receive 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 onlyresponseStatus 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-allresponseStatus 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 in responsesByStatusCode. responseStatus is 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 by ExactStatusCodePairs are excluded via Exclude so 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 responseBody and events
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 ApiContractMockttpHelper instead, which works with the new defineApiContract-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 responseBody and events
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"

Keywords