AgentSkillsCN

add-backend-tests

使用 node:test、Hono testClient 以及项目的自定义 DSL 模式编写 API 集成测试。可通过“为项目功能添加测试”、“为 add-company 端点编写测试”、“测试 API”等短语来触发。

SKILL.md
--- frontmatter
name: add-backend-tests
description: |
  Write API integration tests using node:test, Hono testClient, and the project's
  custom DSL pattern. Triggers: "add tests for the projects feature," "write tests
  for add-company endpoint," "test the API."

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:test module (describe, test, assert) — NOT Jest/Vitest/Mocha
  • HTTP client: testClient(app) from hono/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:

ts
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.

ts
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

ts
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

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

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

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

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 only status, detail, and errors (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) from hono/testing — never supertest or raw fetch
  • All imports use .js extension (NodeNext resolution)
  • #/ alias for src imports in tests
  • Overloaded action functions — success returns entity, error returns ProblemDocument
  • Data-driven validation tests with testCases array in nested describe
  • assertStrictEqualProblemDocument compares only status, detail, errors