Design a test strategy for Node.js/TypeScript back-end services and write the tests — runner choice (Vitest, Jest, node:test), unit vs integration vs end-to-end balance, in-process HTTP tests with supertest or Fastify inject, real databases with Testcontainers, outbound HTTP mocking with MSW or nock, fake timers, database isolation, contract tests with Pact, factories, coverage and flaky async tests in CI.
When agents use itUse this whenever the user asks how to test a Node API, route, controller, service, repository, queue worker or NestJS module; sets up vitest.config.ts, jest.config.ts or test scripts in package.json; asks about mocking modules, fetch, Prisma or time; hits "open handles", "Jest did not exit", timeouts or order-dependent failures; or asks "what should I test". Also apply it when reviewing Node.js test code or generating tests for back-end code the user just wrote, even if they don't say "test".
Install
Copilot (VS Code, Visual Studio, Copilot CLI and github.com) reads skills from the repository — commit them so the whole team gets them.
npx skills add AGCO-Global/org-skills --skill nodejs-testing -a github-copilot
# or with the org installer (adds .github/skills/nodejs-testing):
npx -y github:AGCO-Global/org-skills add skill nodejs-testing
Uses the open skills CLI. Works with Claude Code, Codex, Cursor, Copilot, Gemini CLI, OpenCode, Windsurf and 60+ others — it asks which agent to install into.
Installs the backend-nodejs-skills plugin, which bundles all Backend / Node.js skills and keeps them updated.
/plugin marketplace add AGCO-Global/org-skills
/plugin install backend-nodejs-skills@org-skills
# or just this skill, in this repository:
npx skills add AGCO-Global/org-skills --skill nodejs-testing -a claude-code
Use Download .zip above, then upload it under Customize → Skills → Upload skill. Team and Enterprise admins can sync this repo as a plugin marketplace instead.
Installs into .agents/skills/, which Codex reads.
npx skills add AGCO-Global/org-skills --skill nodejs-testing -a codex
Installs into Cursor's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-testing -a cursor
Installs into Gemini CLI's skills folder.
npx skills add AGCO-Global/org-skills --skill nodejs-testing -a gemini-cli
Any tool with rules, instructions or custom prompts: use Copy SKILL.md above and paste it in. It is plain Markdown.
Commands use your normal git sign-in to GitHub, so they work while the repository is private. Node.js 20+ required.
Skill contents
Node.js Testing
Back-end tests earn their keep by catching wiring, data and contract bugs, which mocks hide. Favour fast in-process tests that hit real HTTP routing and a real database, and mock only what crosses the network boundary you don't own.
1. Pick the runner
Runner
Default when
Notes
Vitest
New TS/ESM projects
Native ESM and TS, Jest-compatible API, fast watch; pool: 'forks' for native deps
Jest
Existing Jest suites, NestJS default scaffolds
ESM support still needs extra config; use ts-jest or @swc/jest
node:test
Libraries or services wanting zero test deps
Built-in runner, mock.fn, mock.timers, coverage via --experimental-test-coverage; smaller ecosystem
Don't migrate a healthy Jest suite for speed alone; do pick Vitest for new ESM services.
2. The pyramid for a service
Layer
Share
Tests what
Dependencies
Static
free
Types (tsc --noEmit), lint (no-floating-promises)
none
Unit
~30%
Domain rules, mappers, validation schemas, pure use cases with fake ports
in-memory fakes
Integration (in-process API)
~55%
Route → validation → service → real DB; error mapping; auth guards
Real Postgres/Redis via Testcontainers, outbound HTTP mocked
Contract
~5%
Consumer/provider API agreements
Pact broker or checked-in pacts
End-to-end
~10%
A few critical journeys against a deployed build
Full environment
The integration layer is where most confidence comes from: it exercises schemas, SQL and error handling together.
3. In-process HTTP tests
Export a buildApp(deps) from app.ts that does not call listen. Tests build the app with test config.
// Fastify: no socket, very fast
const app = await buildApp({ config: testConfig, db });
const res = await app.inject({ method: 'POST', url: '/orders', payload: { sku: 'A1', qty: 2 },
headers: { authorization: `Bearer ${await tokenFor(user)}` } });
expect(res.statusCode).toBe(201);
expect(res.json()).toMatchObject({ sku: 'A1', qty: 2, status: 'pending' });
await app.close();
Express/Hono/NestJS: supertest(app) (Nest: Test.createTestingModule(...).compile(), then app.getHttpServer()); Hono also offers app.request().
Assert status, body shape and side effects (row in DB, message enqueued), plus the error cases: 400 on invalid input, 401/403, 404, 409.
Override dependencies through the composition root (Nest: .overrideProvider(PaymentGateway).useValue(fake)), not by patching modules.
4. Real dependencies vs mocks
Dependency
Use
Why
Your database
Testcontainers (@testcontainers/postgresql) or a CI service container
SQL, constraints, migrations and transactions are where bugs live
Redis, message broker
Testcontainers
Real semantics (TTL, acks)
Third-party HTTP APIs
MSW (msw/node), nock (recent versions intercept fetch), or undici MockAgent
Deterministic, no network
Clock, randomness, ids
Injected ports or fake timers
Determinism
Your own modules
Don't mock; inject fakes via ports
Module mocks couple tests to file layout
// global-setup.ts: one container per test run, migrations applied once
const pg = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = pg.getConnectionUri();
await runMigrations(process.env.DATABASE_URL);
Set MSW (or nock) to fail on unhandled requests (server.listen({ onUnhandledRequest: 'error' }), nock.disableNetConnect()), so a forgotten stub fails loudly instead of calling production.
5. Database isolation
Transaction per test, rolled back in afterEach: fastest; requires the app to accept an injected transaction/connection. Not suitable when code under test opens its own transactions without savepoint support.
Truncate tables between tests (TRUNCATE ... RESTART IDENTITY CASCADE): simple, works with any code.
Schema or database per worker (test_${process.env.VITEST_POOL_ID}): enables parallel files without interference.
Never rely on test order or on data left by another test; each test creates what it needs through factories (buildOrder({ status: 'paid' }) with sensible defaults, e.g. @faker-js/faker with a fixed seed or fishery).
6. Mocks, time and async
Module mocks (vi.mock, jest.mock) only for true externals without an injection point (SDK singletons, node:fs in a CLI). They are hoisted: import mocked modules after declaring them, and reset with vi.restoreAllMocks() / restoreMocks: true.
Time: vi.useFakeTimers() + vi.setSystemTime(...), vi.advanceTimersByTimeAsync(...) for promise-based timers; node:test has mock.timers.enable(). Always restore real timers in afterEach; don't fake timers in tests that talk to a real DB driver.
Always await the thing under test; use await expect(p).rejects.toThrow(NotFoundError). A missing await makes tests pass while the assertion never runs.
Queue workers: call the job processor function directly for unit tests; for integration run the worker against a real Redis and await a completion event, never sleep.
7. Contract tests
When separate teams own consumer and provider, use Pact (@pact-foundation/pact): consumers generate pacts from their tests, providers verify them in CI against provider states, and a broker's can-i-deploy gates releases. For a single team with a shared monorepo, generated clients from OpenAPI plus schema validation in integration tests are usually enough.
8. Coverage, flakiness and CI
Coverage with the v8 provider (default in Vitest) or istanbul when you need exact branch mapping. Gate on changed-code coverage and critical modules; 80% on domain/application code is a sensible target, not a vanity global number.
CI: tsc --noEmit and lint → unit → integration (Docker available, sharded with --shard) → contract verify → e2e on deploy. Pin TZ=UTC, seed randomness, run with --sequence.shuffle occasionally to expose order coupling.
Flaky symptom
Likely cause
Fix
"Jest did not exit" / open handles
Server, pool, Redis or timer left open
await app.close(), close pools in afterAll, .unref() intervals; --detectOpenHandles to find it
Passes alone, fails in suite
Shared DB rows or module state
Per-test isolation, factories, reset mocks
Random timeouts
Missing await, real network, container start in beforeEach
Await everything, block network, start containers once globally
Fails only in CI
TZ, locale, CPU slower, parallel DB access
TZ=UTC, schema per worker, no wall-clock assertions
Deliverable format
When asked to write tests for a Node module or endpoint:
## Behaviours list of cases: happy path, validation, auth, not found, conflict, dependency failure
## Setup reused helpers (buildApp, factories, containers, MSW handlers); new ones added
## Tests one `it` per behaviour, named as a sentence, full compiling TS
## Run exact command and expected result; any Docker/env prerequisites
Anti-patterns to reject
Mocking the ORM/repository to test a handler, so no SQL is ever exercised.
SQLite or in-memory fakes standing in for Postgres/MySQL in integration tests.
Tests that listen on fixed ports or call real third-party APIs.
setTimeout/sleep to wait for async effects; unawaited assertions.
Shared seed data mutated across tests; order-dependent suites.
Snapshotting entire JSON responses including ids and timestamps.
Coverage targets gamed with assertion-free tests.
---
name: nodejs-testing
description: Design a test strategy for Node.js/TypeScript back-end services and write the tests — runner choice (Vitest, Jest, node:test), unit vs integration vs end-to-end balance, in-process HTTP tests with supertest or Fastify inject, real databases with Testcontainers, outbound HTTP mocking with MSW or nock, fake timers, database isolation, contract tests with Pact, factories, coverage and flaky async tests in CI. Use this whenever the user asks how to test a Node API, route, controller, service, repository, queue worker or NestJS module; sets up vitest.config.ts, jest.config.ts or test scripts in package.json; asks about mocking modules, fetch, Prisma or time; hits "open handles", "Jest did not exit", timeouts or order-dependent failures; or asks "what should I test". Also apply it when reviewing Node.js test code or generating tests for back-end code the user just wrote, even if they don't say "test".
metadata:
technology: Node.js
type: testing
---
# Node.js Testing
Back-end tests earn their keep by catching wiring, data and contract bugs, which mocks hide. Favour fast in-process tests that hit real HTTP routing and a real database, and mock only what crosses the network boundary you don't own.
## 1. Pick the runner
| Runner | Default when | Notes |
|---|---|---|
| **Vitest** | New TS/ESM projects | Native ESM and TS, Jest-compatible API, fast watch; `pool: 'forks'` for native deps |
| **Jest** | Existing Jest suites, NestJS default scaffolds | ESM support still needs extra config; use `ts-jest` or `@swc/jest` |
| **node:test** | Libraries or services wanting zero test deps | Built-in runner, `mock.fn`, `mock.timers`, coverage via `--experimental-test-coverage`; smaller ecosystem |
Don't migrate a healthy Jest suite for speed alone; do pick Vitest for new ESM services.
## 2. The pyramid for a service
| Layer | Share | Tests what | Dependencies |
|---|---|---|---|
| Static | free | Types (`tsc --noEmit`), lint (`no-floating-promises`) | none |
| Unit | ~30% | Domain rules, mappers, validation schemas, pure use cases with fake ports | in-memory fakes |
| Integration (in-process API) | ~55% | Route → validation → service → real DB; error mapping; auth guards | Real Postgres/Redis via Testcontainers, outbound HTTP mocked |
| Contract | ~5% | Consumer/provider API agreements | Pact broker or checked-in pacts |
| End-to-end | ~10% | A few critical journeys against a deployed build | Full environment |
The integration layer is where most confidence comes from: it exercises schemas, SQL and error handling together.
## 3. In-process HTTP tests
Export a `buildApp(deps)` from `app.ts` that does not call `listen`. Tests build the app with test config.
```ts
// Fastify: no socket, very fast
const app = await buildApp({ config: testConfig, db });
const res = await app.inject({ method: 'POST', url: '/orders', payload: { sku: 'A1', qty: 2 },
headers: { authorization: `Bearer ${await tokenFor(user)}` } });
expect(res.statusCode).toBe(201);
expect(res.json()).toMatchObject({ sku: 'A1', qty: 2, status: 'pending' });
await app.close();
```
- Express/Hono/NestJS: `supertest(app)` (Nest: `Test.createTestingModule(...).compile()`, then `app.getHttpServer()`); Hono also offers `app.request()`.
- Assert status, body shape and side effects (row in DB, message enqueued), plus the error cases: 400 on invalid input, 401/403, 404, 409.
- Override dependencies through the composition root (Nest: `.overrideProvider(PaymentGateway).useValue(fake)`), not by patching modules.
## 4. Real dependencies vs mocks
| Dependency | Use | Why |
|---|---|---|
| Your database | **Testcontainers** (`@testcontainers/postgresql`) or a CI service container | SQL, constraints, migrations and transactions are where bugs live |
| Redis, message broker | Testcontainers | Real semantics (TTL, acks) |
| Third-party HTTP APIs | **MSW** (`msw/node`), **nock** (recent versions intercept `fetch`), or undici `MockAgent` | Deterministic, no network |
| Clock, randomness, ids | Injected ports or fake timers | Determinism |
| Your own modules | Don't mock; inject fakes via ports | Module mocks couple tests to file layout |
```ts
// global-setup.ts: one container per test run, migrations applied once
const pg = await new PostgreSqlContainer('postgres:16-alpine').start();
process.env.DATABASE_URL = pg.getConnectionUri();
await runMigrations(process.env.DATABASE_URL);
```
Set MSW (or nock) to fail on unhandled requests (`server.listen({ onUnhandledRequest: 'error' })`, `nock.disableNetConnect()`), so a forgotten stub fails loudly instead of calling production.
## 5. Database isolation
- **Transaction per test, rolled back** in `afterEach`: fastest; requires the app to accept an injected transaction/connection. Not suitable when code under test opens its own transactions without savepoint support.
- **Truncate tables** between tests (`TRUNCATE ... RESTART IDENTITY CASCADE`): simple, works with any code.
- **Schema or database per worker** (`test_${process.env.VITEST_POOL_ID}`): enables parallel files without interference.
- Never rely on test order or on data left by another test; each test creates what it needs through **factories** (`buildOrder({ status: 'paid' })` with sensible defaults, e.g. `@faker-js/faker` with a fixed seed or `fishery`).
## 6. Mocks, time and async
- Module mocks (`vi.mock`, `jest.mock`) only for true externals without an injection point (SDK singletons, `node:fs` in a CLI). They are hoisted: import mocked modules after declaring them, and reset with `vi.restoreAllMocks()` / `restoreMocks: true`.
- Time: `vi.useFakeTimers()` + `vi.setSystemTime(...)`, `vi.advanceTimersByTimeAsync(...)` for promise-based timers; `node:test` has `mock.timers.enable()`. Always restore real timers in `afterEach`; don't fake timers in tests that talk to a real DB driver.
- Always `await` the thing under test; use `await expect(p).rejects.toThrow(NotFoundError)`. A missing `await` makes tests pass while the assertion never runs.
- Queue workers: call the job processor function directly for unit tests; for integration run the worker against a real Redis and await a completion event, never `sleep`.
## 7. Contract tests
When separate teams own consumer and provider, use **Pact** (`@pact-foundation/pact`): consumers generate pacts from their tests, providers verify them in CI against provider states, and a broker's `can-i-deploy` gates releases. For a single team with a shared monorepo, generated clients from OpenAPI plus schema validation in integration tests are usually enough.
## 8. Coverage, flakiness and CI
- Coverage with the **v8** provider (default in Vitest) or istanbul when you need exact branch mapping. Gate on changed-code coverage and critical modules; 80% on domain/application code is a sensible target, not a vanity global number.
- CI: `tsc --noEmit` and lint → unit → integration (Docker available, sharded with `--shard`) → contract verify → e2e on deploy. Pin `TZ=UTC`, seed randomness, run with `--sequence.shuffle` occasionally to expose order coupling.
| Flaky symptom | Likely cause | Fix |
|---|---|---|
| "Jest did not exit" / open handles | Server, pool, Redis or timer left open | `await app.close()`, close pools in `afterAll`, `.unref()` intervals; `--detectOpenHandles` to find it |
| Passes alone, fails in suite | Shared DB rows or module state | Per-test isolation, factories, reset mocks |
| Random timeouts | Missing `await`, real network, container start in `beforeEach` | Await everything, block network, start containers once globally |
| Fails only in CI | TZ, locale, CPU slower, parallel DB access | `TZ=UTC`, schema per worker, no wall-clock assertions |
## Deliverable format
When asked to write tests for a Node module or endpoint:
```
## Behaviours list of cases: happy path, validation, auth, not found, conflict, dependency failure
## Setup reused helpers (buildApp, factories, containers, MSW handlers); new ones added
## Tests one `it` per behaviour, named as a sentence, full compiling TS
## Run exact command and expected result; any Docker/env prerequisites
```
## Anti-patterns to reject
- Mocking the ORM/repository to test a handler, so no SQL is ever exercised.
- SQLite or in-memory fakes standing in for Postgres/MySQL in integration tests.
- Tests that `listen` on fixed ports or call real third-party APIs.
- `setTimeout`/`sleep` to wait for async effects; unawaited assertions.
- Shared seed data mutated across tests; order-dependent suites.
- Snapshotting entire JSON responses including ids and timestamps.
- Coverage targets gamed with assertion-free tests.