Add Backend Tests
Use this skill when writing integration tests for backend API features.
Step 0 — Read shared test infrastructure
Before writing any tests, read these files (they are shared across all features and always exist):
- •
apps/backend/tests/assertions.ts— shared assertion helpers (assertPage,assertStrictEqualProblemDocument) - •
apps/backend/tests/errors.ts— error fixtures (createValidationError,validationError, etc.) - •
apps/backend/tests/setup.ts— test setup (DB connection teardown)
The code templates below are the canonical patterns for DSL and test files — follow them exactly.
Test infrastructure overview
- •Test runner:
node:testmodule (describe,test,assert) — NOT Jest/Vitest/Mocha - •HTTP client:
testClient(app)fromhono/testing— NOT supertest or raw fetch - •Run command:
npm test -w @node-monorepo/backend - •File structure:
apps/backend/tests/features/<entities>/
Step 1 — Create the DSL file (<entity>-dsl.ts)
File: apps/backend/tests/features/<entities>/<entity>-dsl.ts
The DSL file contains four sections:
1A. Factory functions
Create named factory functions (like walk(), cook()) that produce valid input objects using @faker-js/faker:
import { faker } from '@faker-js/faker';
import type { Add<Entity> } from '#/features/<entities>/schemas.js';
export const walk = (overrides?: Partial<Add<Entity>>): Add<Entity> => {
return {
name: `walk ${faker.string.uuid()}`,
...overrides,
};
};
export const cook = (overrides?: Partial<Add<Entity>>): Add<Entity> => {
return {
name: `cook ${faker.string.uuid()}`,
...overrides,
};
};
1B. Action functions (overloaded)
Each action function has two overloads: success returns the entity, error returns ProblemDocument.
import { testClient } from 'hono/testing';
import { app } from '#/app.js';
import type { ProblemDocument } from 'http-problem-details';
import { StatusCodes } from 'http-status-codes';
import assert from 'node:assert';
import { assertStrictEqualProblemDocument } from '../../assertions.js';
import type { Page } from '#/pagination.js';
import type { Add<Entity>, Edit<Entity>, <Entity>, List<Entities> } from '#/features/<entities>/schemas.js';
export async function add<Entity>(input: Add<Entity>): Promise<<Entity>>;
export async function add<Entity>(
input: Add<Entity>,
expectedProblemDocument: ProblemDocument
): Promise<ProblemDocument>;
export async function add<Entity>(
input: Add<Entity>,
expectedProblemDocument?: ProblemDocument
): Promise<<Entity> | ProblemDocument> {
const client = testClient(app);
const response = await client.api.<entities>.$post({ json: input });
if (response.status === StatusCodes.CREATED) {
assert.ok(!expectedProblemDocument, 'Expected a problem document but received CREATED status');
const item = await response.json();
assert.ok(item);
return item;
} else {
const problemDocument = await response.json();
assert.ok(problemDocument);
assert.ok(expectedProblemDocument, `Expected CREATED status but received ${response.status}`);
assertStrictEqualProblemDocument(problemDocument, expectedProblemDocument);
return problemDocument;
}
}
Repeat the same overloaded pattern for edit<Entity>, get<Entity>, and list<Entities>.
1C. Fluent assertion builder
export const assert<Entity> = (item: <Entity>) => {
return {
hasName(expected: string) {
assert.strictEqual(item.name, expected, `Expected name to be ${expected}, got ${item.name}`);
return this;
},
// Add a method for each field...
isTheSameOf(expected: <Entity>) {
return this.hasName(expected.name); // chain all field checks
},
};
};
Step 2 — Create test files
One test file per endpoint:
add-<entity>.test.ts
import { test, describe } from 'node:test';
import { add<Entity>, assert<Entity>, walk } from './<entity>-dsl.js';
import { emptyText, bigText, createValidationError, validationError } from '../../errors.js';
describe('Add <Entity> Endpoint', () => {
test('should create a new <entity> with valid data', async () => {
const input = walk();
const item = await add<Entity>(input);
assert<Entity>(item).hasName(input.name);
});
describe('Property validations', () => {
const testCases = [
{
name: 'should reject empty name',
input: walk({ name: emptyText }),
expectedError: createValidationError([validationError.tooSmall('name', 1)]),
},
{
name: 'should reject name longer than 1024 characters',
input: walk({ name: bigText(1025) }),
expectedError: createValidationError([validationError.tooBig('name', 1024)]),
},
{
name: 'should reject missing name',
input: walk({ name: undefined }),
expectedError: createValidationError([validationError.requiredString('name')]),
},
];
for (const { name, input, expectedError } of testCases) {
test(name, async () => {
await add<Entity>(input, expectedError);
});
}
});
});
get-<entity>.test.ts
import { test, describe } from 'node:test';
import { add<Entity>, assert<Entity>, get<Entity>, walk } from './<entity>-dsl.js';
import { createNotFoundError, createValidationError, validationError } from '../../errors.js';
describe('Get <Entity> Endpoint', () => {
test('should get an existing <entity> by ID', async () => {
const created = await add<Entity>(walk());
const retrieved = await get<Entity>(created.<entityId>);
assert<Entity>(retrieved).isTheSameOf(created);
});
test('should return 404 for non-existent <entity>', async () => {
const id = '01940b6d-1234-7890-abcd-ef1234567890';
await get<Entity>(id, createNotFoundError(`<Entity> ${id} not found`));
});
test('should reject invalid UUID format', async () => {
await get<Entity>('invalid-uuid', createValidationError([validationError.invalidUuid('<entityId>')]));
});
});
edit-<entity>.test.ts
import { test, describe } from 'node:test';
import { add<Entity>, edit<Entity>, walk, cook, assert<Entity> } from './<entity>-dsl.js';
import { emptyText, bigText, createValidationError, validationError, createNotFoundError } from '../../errors.js';
describe('Edit <Entity> Endpoint', () => {
test('should edit an existing <entity> with valid data', async () => {
const item = await add<Entity>(walk());
const input = cook();
const updated = await edit<Entity>(item.<entityId>, { ...input /* + other fields */ });
assert<Entity>(updated).hasName(input.name);
});
describe('Property validations', async () => {
const testCases = [
// Similar to add, but may need to construct from existing entity
];
for (const { name, input, expectedError } of testCases) {
test(name, async () => {
const item = await add<Entity>(walk());
await edit<Entity>(item.<entityId>, input(item), expectedError);
});
}
});
test('should return 404 for non-existent <entity>', async () => {
const id = '01940b6d-1234-7890-abcd-ef1234567890';
await edit<Entity>(id, { ...cook() }, createNotFoundError(`<Entity> ${id} not found`));
});
});
list-<entities>.test.ts
import { test, describe } from 'node:test';
import { add<Entity>, assert<Entity>, list<Entities>, walk } from './<entity>-dsl.js';
import { assertPage } from '../../assertions.js';
describe('List <Entities> Endpoint', () => {
test('should filter <entities> by name', async () => {
const item = await add<Entity>(walk());
const page = await list<Entities>({ name: item.name, pageSize: 10, pageNumber: 1 });
assertPage(page).hasItemsCount(1);
assert<Entity>(page.items[0]).isTheSameOf(item);
});
test('should return empty items when no match', async () => {
const page = await list<Entities>({ name: 'nonexistent-xyz', pageSize: 10, pageNumber: 1 });
assertPage(page).hasEmptyResult();
});
});
Shared helpers reference
assertions.ts
- •
assertPage(page)— fluent builder:.hasItemsCount(n),.hasTotalCount(n),.hasTotalPages(n),.hasItemsCountAtLeast(n),.hasEmptyResult() - •
assertStrictEqualProblemDocument(actual, expected)— compares onlystatus,detail, anderrors(NOT full object)
errors.ts
- •
emptyText— empty string constant - •
bigText(length)— generates string of given length - •
createValidationError(errors)— creates ProblemDocument with BAD_REQUEST - •
createNotFoundError(detail)— creates ProblemDocument with NOT_FOUND - •
validationError.tooSmall(path, min),.tooBig(path, max),.requiredString(path),.invalidUrl(path),.invalidUuid(path),.notPositive(path),.requiredNumber(path)
Critical rules
- •
node:test— never Jest, Vitest, or Mocha - •
testClient(app)fromhono/testing— never supertest or raw fetch - •All imports use
.jsextension (NodeNext resolution) - •
#/alias for src imports in tests - •Overloaded action functions — success returns entity, error returns ProblemDocument
- •Data-driven validation tests with
testCasesarray in nesteddescribe - •
assertStrictEqualProblemDocumentcompares onlystatus,detail,errors