@lokalise/universal-testing-utils
v3.2.0
Published
Reusable testing utilities that are potentially relevant for both backend and frontend
Readme
universal-testing-utils
Reusable testing utilities that are potentially relevant for both backend and frontend
msw integration with API contracts
Basic usage
import { buildRestContract } from '@lokalise/api-contracts'
import { sendByGetRoute, sendByPayloadRoute } from '@lokalise/frontend-http-client'
import { setupServer } from 'msw/node'
import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'
import wretch, { type Wretch } from 'wretch'
import { z } from 'zod/v4'
import { MswHelper } from '@lokalise/universal-testing-utils'
const REQUEST_BODY_SCHEMA = z.object({
name: z.string(),
})
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: REQUEST_BODY_SCHEMA,
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)
beforeEach(() => {
server.listen({ onUnhandledRequest: 'error' })
})
afterEach(() => {
server.resetHandlers()
})
afterAll(() => {
server.close()
})
describe('mockValidPayloadResponse', () => {
it('mocks POST request with path params', async () => {
mswHelper.mockValidResponse(postContractWithPathParams, server, {
pathParams: { userId: '3' },
responseBody: { id: '2' },
})
const response = await sendByPayloadRoute(wretchClient, postContractWithPathParams, {
pathParams: {
userId: '3',
},
body: { name: 'frf' },
})
expect(response).toMatchInlineSnapshot(`
{
"id": "2",
}
`)
})
})
describe('mockValidResponseWithAnyPath', () => {
it('mocks POST request with path params', async () => {
// you don't need specify any path params, they automatically are set to *
mswHelper.mockValidResponseWithAnyPath(postContractWithPathParams, server, {
responseBody: { id: '2' },
})
const response = await sendByPayloadRoute(wretchClient, postContractWithPathParams, {
pathParams: {
userId: '9',
},
body: { name: 'frf' },
})
expect(response).toMatchInlineSnapshot(`
{
"id": "2",
}
`)
})
})
// use this approach when you need to implement custom logic within mocked endpoint,
// e. g. call your own mock
describe("mockValidResponseWithImplementation", () => {
it("mocks POST request with custom implementation", async () => {
const apiMock = vi.fn();
mswHelper.mockValidResponseWithImplementation(postContractWithPathParams, server, {
// setting this to :userId makes the params accessible by name within the callback
pathParams: { userId: ':userId' },
handleRequest: async (requestInfo) => {
apiMock(await requestInfo.request.json())
return {
id: `id-${requestInfo.params.userId}`,
}
},
})
const response = await sendByPayloadRoute(
wretchClient,
postContractWithPathParams,
{
pathParams: {
userId: "9",
},
body: { name: "test-name" },
},
);
expect(apiMock).toHaveBeenCalledWith({
name: "test-name",
});
expect(response).toMatchInlineSnapshot(`
{
"id": "9",
}
`);
});
})
describe('mockAnyResponse', () => {
it('mocks POST request without path params', async () => {
mswHelper.mockAnyResponse(postContract, server, {
// you can specify any response, regardless of what contract expects
responseBody: { wrongId: '1' },
})
const response = await wretchClient.post({ name: 'frf' }, mapRouteToPath(postContract))
expect(await response.json()).toMatchInlineSnapshot(`
{
"wrongId": "1",
}
`)
})
})
})mockttp integration with API contracts
API contract-based mock servers for testing. Resolves path to be mocked based on the contract and passed path params, and automatically infers type for the response based on the contract schema
Basic usage
import { buildRestContract } from '@lokalise/api-contracts'
import { sendByGetRoute, sendByPayloadRoute } from '@lokalise/frontend-http-client'
import { getLocal } from 'mockttp'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import wretch, { type Wretch } from 'wretch'
import { z } from 'zod/v4'
import { MockttpHelper } from '@lokalise/universal-testing-utils'
const REQUEST_BODY_SCHEMA = z.object({
name: z.string(),
})
const RESPONSE_BODY_SCHEMA = z.object({
id: z.string(),
})
const PATH_PARAMS_SCHEMA = z.object({
userId: z.string(),
})
const QUERY_PARAMS_SCHEMA = z.object({
yearFrom: z.coerce.number(),
})
const postContract = buildRestContract({
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
requestBodySchema: REQUEST_BODY_SCHEMA,
method: 'post',
description: 'some description',
responseSchemasByStatusCode: {
200: RESPONSE_BODY_SCHEMA,
},
pathResolver: () => '/',
})
const contractWithPathParams = buildRestContract({
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
requestBodySchema: REQUEST_BODY_SCHEMA,
requestPathParamsSchema: PATH_PARAMS_SCHEMA,
method: 'post',
description: 'some description',
responseSchemasByStatusCode: {
200: RESPONSE_BODY_SCHEMA,
},
pathResolver: (pathParams) => `/users/${pathParams.userId}`,
})
const getContractWithQueryParams = buildRestContract({
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
requestQuerySchema: QUERY_PARAMS_SCHEMA,
description: 'some description',
responseSchemasByStatusCode: {
200: RESPONSE_BODY_SCHEMA,
},
pathResolver: () => '/items',
})
const getContractWithPathAndQueryParams = buildRestContract({
successResponseBodySchema: RESPONSE_BODY_SCHEMA,
requestPathParamsSchema: PATH_PARAMS_SCHEMA,
requestQuerySchema: QUERY_PARAMS_SCHEMA,
description: 'some description',
responseSchemasByStatusCode: {
200: RESPONSE_BODY_SCHEMA,
},
pathResolver: (pathParams) => `/users/${pathParams.userId}`,
})
describe('mockttpUtils', () => {
const mockServer = getLocal()
const mockttpHelper = new MockttpHelper(mockServer)
let wretchClient: Wretch
beforeEach(async () => {
await mockServer.start()
wretchClient = wretch(mockServer.url)
})
afterEach(() => mockServer.stop())
describe('mockValidResponse', () => {
it('mocks POST request without path params', async () => {
await mockttpHelper.mockValidResponse(postContract, {
responseBody: {id: '1'},
})
const response = await sendByPayloadRoute(wretchClient, postContract, {
body: {name: 'frf'},
})
expect(response).toMatchInlineSnapshot(`
{
"id": "1",
}
`)
})
it('mocks GET request with query params', async () => {
await mockttpHelper.mockValidResponse(getContractWithQueryParams, {
queryParams: { yearFrom: 2020 },
responseBody: { id: '1' },
})
const response = await sendByGetRoute(wretchClient, getContractWithQueryParams, {
queryParams: { yearFrom: 2020 },
})
expect(response).toMatchInlineSnapshot(`
{
"id": "1",
}
`)
})
it('mocks GET request with path params and query params', async () => {
await mockttpHelper.mockValidResponse(getContractWithPathAndQueryParams, {
pathParams: { userId: '3' },
queryParams: { yearFrom: 2020 },
responseBody: { id: '2' },
})
const response = await sendByGetRoute(wretchClient, getContractWithPathAndQueryParams, {
pathParams: { userId: '3' },
queryParams: { yearFrom: 2020 },
})
expect(response).toMatchInlineSnapshot(`
{
"id": "2",
}
`)
})
})
describe('mockAnyResponse', () => {
it('mocks error response with non-matching schema', async () => {
// mockAnyResponse allows any response body, bypassing schema validation
// Useful for testing error responses or edge cases
await mockttpHelper.mockAnyResponse(postContract, {
responseBody: { error: 'Internal Server Error', code: 'ERR_500' },
responseCode: 500
})
const response = await wretchClient
.post({ name: 'test' }, '/')
.json()
expect(response).toMatchInlineSnapshot(`
{
"error": "Internal Server Error",
"code": "ERR_500"
}
`)
})
})
})Query params support
Both mockValidResponse and mockAnyResponse support queryParams. When provided, the mock server will only match requests that include the specified query parameters. The queryParams type is inferred from the contract's requestQuerySchema, so you pass the same values as you would to frontend-http-client (strings, numbers, etc.).
mockAnyResponse
The mockAnyResponse method allows you to mock API responses with any response body, bypassing contract schema validation. This is particularly useful for:
- Testing error responses (4xx, 5xx status codes)
- Testing edge cases where the response doesn't match the expected schema
- Simulating malformed responses to test error handling
Unlike mockValidResponse which enforces schema validation, mockAnyResponse accepts any response body structure, making it ideal for testing how your application handles unexpected API responses.
SSE mock support
Both MswHelper and MockttpHelper support mocking SSE (Server-Sent Events) endpoints via mockSseResponse. This method works with SSEContractDefinition and DualModeContractDefinition contracts built using buildSseContract from @lokalise/api-contracts.
Event names and data shapes are fully type-safe — typing event: 'item.updated' narrows the data field to the matching schema's input type.
mockttp SSE example
import { buildSseContract } from '@lokalise/api-contracts'
import { getLocal } from 'mockttp'
import { z } from 'zod/v4'
import { MockttpHelper } from '@lokalise/universal-testing-utils'
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() }),
},
})
const sseContractWithPathParams = buildSseContract({
method: 'get',
requestPathParamsSchema: z.object({ userId: z.string() }),
pathResolver: (params) => `/users/${params.userId}/events`,
serverSentEventSchemas: {
'item.updated': z.object({ items: z.array(z.object({ id: z.string() })) }),
completed: z.object({ totalCount: z.number() }),
},
})
const mockServer = getLocal()
const mockttpHelper = new MockttpHelper(mockServer)
// No path params — pathParams is not required
await mockttpHelper.mockSseResponse(sseContract, {
events: [
{ event: 'item.updated', data: { items: [{ id: '1' }] } }, // fully typed
{ event: 'completed', data: { totalCount: 1 } },
],
})
// With path params — pathParams is required and typed
await mockttpHelper.mockSseResponse(sseContractWithPathParams, {
pathParams: { userId: '42' },
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})
// Custom response code
await mockttpHelper.mockSseResponse(sseContract, {
responseCode: 201,
events: [{ event: 'completed', data: { totalCount: 0 } }],
})MSW SSE example
import { setupServer } from 'msw/node'
import { MswHelper } from '@lokalise/universal-testing-utils'
const server = setupServer()
const mswHelper = new MswHelper('http://localhost:8080')
// Same contract definitions as above
mswHelper.mockSseResponse(sseContract, server, {
events: [
{ event: 'item.updated', data: { items: [{ id: '1' }] } },
{ event: 'completed', data: { totalCount: 1 } },
],
})Dual-mode contracts
mockSseResponse also works with dual-mode contracts (built with successResponseBodySchema), which support both JSON and SSE responses:
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() })) }),
},
})
// Mock the SSE response mode
await mockttpHelper.mockSseResponse(dualModeContract, {
events: [{ event: 'item.updated', data: { items: [{ id: '1' }] } }],
})formatSseResponse
A standalone helper is also 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"