AI Unified Process service and API layers - NestJS services, REST controllers, and their tests
94%
Does it follow best practices?
Impact
100%
1.11x2 of 3 eval scenarios. Add 1 more for a full score.
Low
Low-risk findings worth noting
The one description of how the tests of a NestJS application run: read by /nest-service-test, /nest-rest-test, the
persistence test skill, and the E2E test skill. An existing setup of the project comes first: check each section,
reuse what exists, add what is missing, and say which. Examples show a NodeNext project; drop the .js suffixes where
the layout detection found none.
This is the highest-value check, and it is invisible until it bites.
NestJS dependency injection resolves constructor parameters by reading design:paramtypes metadata, which needs
emitDecoratorMetadata in tsconfig.json and a transformer that honours it. Without one, every provider fails to
resolve, and the error names a parameter index rather than the cause, so it reads like a broken module, not a broken
build config. The Vite version under Vitest decides:
| Vite | Default transformer | What the project needs |
|---|---|---|
| Below 8 | esbuild | unplugin-swc: esbuild emits no decorator metadata |
| 8 and up | Oxc | Nothing: Oxc reads the setting from tsconfig.json. Add no SWC for it |
Confirm the answer rather than trusting the table: the first spec that boots the application proves it, and only
that failure justifies a new dependency. Verified on Vite 8.3 with Vitest 4.1, where the suite behaved the same with
SWC, with SWC and oxc: false, and without either. An existing SWC setup stays.
// vitest.config.e2e.ts: the tests that run against the database
import swc from 'unplugin-swc'; // below Vite 8 only
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [swc.vite({ module: { type: 'es6' } })], // below Vite 8 only
test: {
globals: true,
environment: 'node',
include: ['test/**/*-spec.ts'],
globalSetup: ['test/utils/global-setup.ts'],
fileParallelism: false,
hookTimeout: 120_000,
},
});The Nest generator may already have written this file: extend it. Its test/app.e2e-spec.ts runs under the wider
include too and is left alone.
Adding unplugin-swc, @swc/core, testcontainers, or supertest is a new dependency: it follows the decision
rights of /use-case-implement, and the report names it.
include covers every spec against the database: UC-XXX-<slug>.e2e-spec.ts (API),
UC-XXX-<slug>.service-spec.ts (service), and UC-XXX-<slug>.repository.e2e-spec.ts (persistence). A project whose
existing include reads test/**/*.e2e-spec.ts picks up the repository specs already; widen it for the service specs. Pure unit specs stay in src/**/*.spec.ts under the project's vitest.config.ts, which needs the
same transformer as soon as one of them imports a decorated class.
One container for the whole run, started in global setup and published to the test files:
// test/utils/global-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import type { TestProject } from 'vitest/node';
export default async function setup(project: TestProject): Promise<() => Promise<void>> {
const container = await new PostgreSqlContainer('postgres:17-alpine').start();
project.provide('DATABASE_URL', container.getConnectionUri());
return async () => {
await container.stop();
};
}
declare module 'vitest' {
interface ProvidedContext {
DATABASE_URL: string;
}
}Per test file, drop and recreate the schema, then migrate. openTestDb does that and hands back a Drizzle connection;
the persistence tests use it alone, without an application:
// test/utils/test-db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import pg from 'pg';
import type { DrizzleDb } from '../../src/database/drizzle.provider.js';
import * as schema from '../../src/database/schema.js';
export const MIGRATIONS = { migrationsFolder: './drizzle/migrations' }; // the `out` of drizzle.config.ts
export type TestDb = { db: DrizzleDb; close: () => Promise<void> };
export async function resetSchema(connectionString: string): Promise<void> {
const client = new pg.Client({ connectionString });
await client.connect();
try {
await client.query(
'DROP SCHEMA IF EXISTS public CASCADE; DROP SCHEMA IF EXISTS drizzle CASCADE; CREATE SCHEMA public;',
);
} finally {
await client.end();
}
}
export async function openTestDb(connectionString: string): Promise<TestDb> {
await resetSchema(connectionString);
const pool = new pg.Pool({ connectionString });
const db = drizzle(pool, { schema });
await migrate(db, MIGRATIONS);
return { db, close: () => pool.end() };
}The service and API tests boot the application on the same reset and apply the test data:
// test/utils/create-app.ts
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { inject } from 'vitest';
import { AppModule } from '../../src/app.module.js';
import { configureApp } from '../../src/configure-app.js';
import { DRIZZLE, type DrizzleDb } from '../../src/database/drizzle.provider.js';
import { applySeeds } from './apply-seeds.js';
import { MIGRATIONS, resetSchema } from './test-db.js';
export async function createTestApp({ seed = true } = {}): Promise<INestApplication> {
const url = inject('DATABASE_URL');
process.env.DATABASE_URL = url;
await resetSchema(url);
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleRef.createNestApplication();
configureApp(app);
await app.init();
const db = app.get<DrizzleDb>(DRIZZLE);
await migrate(db, MIGRATIONS);
if (seed) {
await applySeeds(db);
}
return app;
}Test.createTestingModule builds only what the modules declare.
What main.ts adds on top (setGlobalPrefix, useGlobalPipes, useGlobalFilters) is missing here: call the
project's configureApp(app) from /nest-rest before app.init(), or repeat those lines in createTestApp and report it, because a test without the
ValidationPipe passes against an application that does not exist. The prefix decides the paths the tests call.migrate call: the data follows app.init() either way.DATABASE_URL earlier: set it in a setupFiles entry from inject.This design has two constraints worth stating plainly, because otherwise they surface as intermittent failures:
openTestDb per test file. The reset is global, so a second one while the first is alive
wipes its data. Open in beforeAll, close in afterAll.A container per test file would avoid both and is the obvious-looking alternative; don't. Container startup dominates the suite's runtime, and a dozen test files become minutes of waiting. Without a Docker daemon the suite cannot start: say so in the report rather than claiming the tests pass.
The test skills own the test data; the migration layer writes none. An existing test data convention of the project always comes first. Without one, the rows a use case's preconditions and rejected flows need live in one file per use case, the same file for every test skill of that use case; extend it when it exists:
// test/data/uc001-register-guest.ts
import type { DrizzleDb } from '../../src/database/drizzle.provider.js';
import { guests } from '../../src/database/schema.js';
export async function seed(db: DrizzleDb): Promise<void> {
await db
.insert(guests)
.values({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', birthDate: '1990-01-15' })
.onConflictDoNothing();
}// test/utils/apply-seeds.ts
import { readdir } from 'node:fs/promises';
import type { DrizzleDb } from '../../src/database/drizzle.provider.js';
export async function applySeeds(db: DrizzleDb): Promise<void> {
const folder = new URL('../data/', import.meta.url); // __dirname in a CommonJS project
const files = (await readdir(folder)).filter((file) => /^uc.*\.[jt]s$/.test(file) && !file.endsWith('.d.ts'));
for (const file of files.sort()) {
const { seed } = await import(new URL(file, folder).href);
await seed(db);
}
}test/data/uc<id>-<slug>.ts exports seed(db) and is applied after migrate(). It is never a migration:
Drizzle migrations are generated, and a hand-written one desynchronises the journal.onConflictDoNothing on a unique key) and leaves generated ids to the database, because
every seed runs together and an E2E server may restart on the same database. Take the values from the specification's
examples and the entity model.afterEach, by id or by the value the test used; never delete
all data. A use case that offers no delete gets none added for a test: delete through the DRIZZLE handle of the
application under test.UC-XXX-<slug>-empty.e2e-spec.ts
or -empty.service-spec.ts, whose createTestApp({ seed: false }) starts without the rows. The tests run in process, so such a flow is reachable
here; E2E leaves it to this layer.Browser tests run against a started application that must load the same test data. src/ never imports test/, so a
small entry point does what createTestApp does and then listens:
// test/e2e-server.ts
import { NestFactory } from '@nestjs/core';
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { AppModule } from '../src/app.module.js';
import { configureApp } from '../src/configure-app.js';
import { DRIZZLE, type DrizzleDb } from '../src/database/drizzle.provider.js';
import { applySeeds } from './utils/apply-seeds.js';
// The server owns its database: a throwaway container, unless DATABASE_URL names one.
process.env.DATABASE_URL ??= (await new PostgreSqlContainer('postgres:17-alpine').start()).getConnectionUri();
const app = await NestFactory.create(AppModule);
configureApp(app);
const db = app.get<DrizzleDb>(DRIZZLE);
await migrate(db, { migrationsFolder: './drizzle/migrations' });
await applySeeds(db);
await app.listen(process.env.PORT ?? 3001);// tsconfig.e2e.json
{
"extends": "./tsconfig.json",
"compilerOptions": { "rootDir": ".", "outDir": "dist-e2e" },
"include": ["src/**/*", "test/e2e-server.ts", "test/data/**/*", "test/utils/apply-seeds.ts"]
}Compile it with tsc -p tsconfig.e2e.json and start node dist-e2e/test/e2e-server.js from the API app's root, because
the migrations folder is relative. Testcontainers removes the container when the process ends, so each start begins
with a clean database. Do not run it with tsx: esbuild emits no decorator metadata, so dependency injection fails. A test
skill never edits the Commands table of docs/architecture/stack.md: name the two commands in the report, for the team
to record under End-to-end tests. Add dist-e2e to .gitignore. The top-level await needs ESM; in a CommonJS project
wrap the body in an async function.
Code that reads the clock or generates randomness inline (new Date(), Math.random(), crypto.randomUUID() in a
service method) cannot be asserted exactly. Pin it in the test rather than loosening the assertion to
expect.any(String), which stops testing the thing that matters:
vi.useFakeTimers({ toFake: ['Date'] }); // only Date: fake timers would stall the database driver
vi.setSystemTime(new Date('2026-03-01T12:00:00.000Z'));
// …exercise the service…
vi.useRealTimers();Where the project's own conventions call for an injectable clock and the code under test does not use one, do not refactor the source as part of writing tests. Pin the value, get the test green, and report the inconsistency.