Testing
How the template's tests are laid out, what ./verify.sh runs, and the
guard specs that fail when a change skips a step. Read it before writing a
spec, or when verify fails and you need to know which rule you broke.
What ./verify.sh runs
Run it from the repository root, after npm ci in backend/ and
frontend/. The stages run in this order, and the first stage that fails
stops the run:
- Integration database. If you set
TEST_DATABASE_URL, verify uses it. Otherwise, if Docker answers, it starts a throwawaypostgres:16-alpinecontainer on a free127.0.0.1port, with its data in memory, runsnpm run migrateagainst it and exportsTEST_DATABASE_URL. The container is removed when verify exits. With no Docker, this stage is skipped. - Backend, in
backend/:format:check,lint,knip,build, thennpm run test. The tests are killed afterVERIFY_TEST_TIMEOUT_SECONDS(default 900), which is what a spec that leaves a handle open looks like. - Frontend, in
frontend/:format:check,lint,knip,test, the type drift check, thenbuild. - OpenAPI drift: boots the backend's module graph with stub env
values, writes
backend/openapi.runtime.jsonand diffs it against the committedopenapi.json. - Dependencies:
npm audit --omit=dev --audit-level=criticalin each half. Only a critical advisory fails it. If the npm registry does not answer, the half printsSKIPPED - the registry did not answer.
The last line is the verdict. verify: all green (integration specs ran)
means the integration specs ran. It does not cover the audit: a half
whose audit printed SKIPPED still ends green, so read the dependencies
lines too. The verify flags (VERIFY_QUIET=1,
VERIFY_ALLOW_INTEGRATION_SKIP=1 and the timeouts) are in
AGENTS.md.
Unit and integration specs
Backend specs use jest and sit beside the file they test:
| File name | What it touches | When it runs |
|---|---|---|
*.spec.ts |
Nothing outside the process. Services get stub or mock dependencies. | Always |
*.integration.spec.ts |
A real Postgres, through src/db/integration-test-db.ts |
Only when TEST_DATABASE_URL is set |
*.http.spec.ts |
Real HTTP requests against a booted app | Most need TEST_DATABASE_URL; mcp-oauth.http.spec.ts runs either way |
npm run test runs two jest passes: first every plain spec in parallel,
then the integration and HTTP specs one file at a time (--runInBand),
because they share one database.
An integration spec gates itself like this, so copy the pattern:
const describeIfDb = hasTestDb ? describe : describe.skip;
Without TEST_DATABASE_URL the second pass reports skips instead of
failures:
Test Suites: 20 skipped, 1 passed, 1 of 21 total
Tests: 155 skipped, 14 passed, 169 total
and verify ends verify: NOT green - integration specs skipped. A skipped
run has not tested any SQL.
Frontend tests use vitest. frontend/vitest.config.mts collects only
*.test.ts at the root, under lib/ and under app/. They cover lib/
helpers, Server Actions (app/**/actions.test.ts) and auth.ts. There is
no component-test setup: a *.test.tsx file is never collected, and
npm run test -- app/x.test.tsx prints No test files found, exiting with code 1.
Where a new spec goes
- A backend unit spec: beside the source, same name plus
.spec.ts(items.service.tsgetsitems.service.spec.ts). - A backend spec that needs SQL:
<name>.integration.spec.ts, usingconnectTestDb()andcloseTestDb()fromsrc/db/integration-test-db.tsand thedescribeIfDbgate above.src/items/items.service.integration.spec.tsis the one to copy. The specs delete rows, so point them at a database you can lose. - A frontend test:
<name>.test.tsbeside the file inlib/orapp/.
Running one file
From backend/:
npm run test:jest -- src/common/route-auth.spec.ts
Run single files through test:jest: it adds the
--experimental-vm-modules flag the NestJS packages need.
An integration spec needs a migrated database. With the dev Postgres
running (docker compose up -d --wait postgres), create a separate test
database in it once, then run the spec, from backend/:
docker compose exec postgres createdb -U postgres crabstack_test
export TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5433/crabstack_test
DATABASE_URL="$TEST_DATABASE_URL" npm run migrate
npm run test:jest -- src/items/items.service.integration.spec.ts
From frontend/:
npm run test -- lib/billing.test.ts
The guard specs
These specs check rules that span the codebase. Adding a module trips them by design: each one stands for a decision that would otherwise go unmade.
backend/src/common/route-auth.spec.ts
The spec walks AppModule's imports for every controller Nest
registers, whatever its file or class is called, and each needs an entry
in POLICY, keyed by class name. Most list each
route with the guards it requires (SESSION, ADMIN, INTERNAL) or a
string saying why it is public. An entry with classGuards (the two
/internal/* controllers) lists no routes: every route on it, including
one added later, must carry exactly those guards. The test reads the
guards each route actually carries and compares.
| You did | What fails |
|---|---|
Added a controller with no POLICY entry |
"states a policy for every registered controller, and only those": the diff shows the new controller name |
| Registered two controllers with the same class name | "gives every registered controller its own class name": the diff shows the name |
| Stated guards that differ from the real ones | "every route carries exactly the guards its policy states": the diff shows the extra or missing guard |
Added a route to a controller without classGuards and did not list it |
"states a policy for every route that exists", and "every route carries exactly the guards its policy states" with NO STATED POLICY - add it to POLICY in this file |
So a new controller takes one edit in this file, its POLICY entry.
Add the route auth policy
has the walkthrough.
backend/src/users/deletion-policy.spec.ts
Reads every createTable in backend/migrations/ and requires an entry
for each table in DELETION_POLICY (backend/src/users/deletion-policy.ts):
what account deletion does to it, or why it needs nothing.
| You did | What fails |
|---|---|
Added a migration that creates notes, no policy entry |
"states what happens to every table the migrations create": + "notes", |
Claimed erases: { by: 'ghostDelete' } without writing the delete in ghostDelete |
"claims an erasure only where the code performs one": "notes (claimed in users.service.ts#ghostDelete)" |
Left out columns on a table whose rows survive deletion |
"lists columns for exactly the tables whose rows survive": "notes: its rows survive, so it lists every column as erased or kept" |
backend/src/users/deletion-policy.integration.spec.ts
Checks the columns lists against the real schema, so it needs
TEST_DATABASE_URL. A column that exists but is in neither erased nor
kept fails with "notes.user_id is unlisted: add it to erased or kept";
a listed column that does not exist fails with is listed but does not exist. Recipe 1
step 6 covers which list a column belongs in.
The MCP tool specs
backend/src/mcp-admin/mcp-admin-tools.service.spec.ts pins the exact
list of registered admin MCP tools, and checks that get_server_info
mentions every one. mcp-admin-ops-tools.service.spec.ts pins only the
tools McpAdminOpsToolsService registers, with their annotations.
Registering a new ops tool and nothing else fails three tests. A new tool
needs:
- the
safeTool(...)registration, - its row in
OPS_SERVER_INFO_TOOLS(ops tools, inmcp-admin-ops-tools.service.ts) orregisterServerInfo(blog tools, inmcp-admin-tools.service.ts), - its name in the expected list in
mcp-admin-tools.service.spec.ts, for any tool, and also inmcp-admin-ops-tools.service.spec.tsfor an ops tool, plus that spec'swritesmap for an ops tool that writes.
See Admin MCP Server.
OpenAPI and type drift
These are verify stages. openapi.json and
frontend/types/api.d.ts are generated, and verify regenerates both and
diffs them against the committed files. Any change to a controller's
decorators or a zod schema changes them, down to an @ApiOperation
summary. Regenerate them; do not edit them by hand. See
Troubleshooting.
What the test run found
Each failure message on this page was produced on 2026-10-25 in a fresh
clone by making the change described next to it: a new
AdminItemsController, a migration creating notes, a new admin_ping
MCP tool, a .test.tsx file and a changed health-check summary. With
Docker turned off, the backend's second jest pass printed the skip lines
above.
Related
- Troubleshooting - each verify failure with its fix
- Adding a Module - the steps these specs check
- Home - Docs index