How I write NestJS tests that catch real bugs. Test.createTestingModule with overrides, Supertest E2E, TestContainers for real Postgres, and what to do about flaky tests in CI.
A federation tournament wrapped up on a Saturday night at the combat-sports platform I CTO’d in London. The new champion should have shown up at the top of the global rankings within minutes. Eight hours later the page still showed the old number one, an athlete tweeted a screenshot of our broken rankings tagging the federation, and I spent Sunday morning explaining to the team why our tests had let a silent Elasticsearch projection failure ship to production.
The indexer had unit tests. Good ones. They mocked the ES client, asserted that the right bulk() payload was emitted, ran in 40ms a piece. They passed. The bug lived in a layer those tests never touched, which is the circuit breaker around the bulk-write client. Once it opened, it stayed open until restart. The unit tests had no way to see that, because they’d mocked the very thing that broke.
That’s the lens I bring to every NestJS test now. Tests are cheap to write and expensive to trust. The shape that survives in production is a mix.
The NestJS testing utility is the right tool for isolated unit tests. Inject what’s real, override what isn’t, assert on behavior. I keep these tests fast and boring.
import { Test, TestingModule } from '@nestjs/testing';
import { Logger } from '@nestjs/common';
import { OrdersService } from './orders.service';
import { OrderRepository } from './order.repository';
import { PaymentClient } from './payment.client';
describe('OrdersService', () => {
let service: OrdersService;
let repository: jest.Mocked<OrderRepository>;
let payments: jest.Mocked<PaymentClient>;
beforeEach(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: OrderRepository, useValue: { save: jest.fn(), findById: jest.fn() } },
{ provide: PaymentClient, useValue: { charge: jest.fn() } },
],
})
.setLogger(new Logger())
.compile();
service = moduleRef.get(OrdersService);
repository = moduleRef.get(OrderRepository);
payments = moduleRef.get(PaymentClient);
});
it('rejects a duplicate idempotency key without charging', async () => {
repository.findById.mockResolvedValue({ id: 'o_1', status: 'placed' } as any);
await service.place({ orderId: 'o_1', items: [], idempotencyKey: 'k_1' });
expect(payments.charge).not.toHaveBeenCalled();
expect(repository.save).not.toHaveBeenCalled();
});
});
A note on overrides. useValue is fine for plain stubs. For anything that needs behavior, I reach for useFactory so the mock can take its own dependencies. And I never mock the framework. Logger, EventEmitter2, ConfigService, they all come along for the ride. Mocking them is how you end up with green tests that pass through to bugs.
I used to write controller unit tests. I stopped. The interesting part of a NestJS controller is everything around it, guards, interceptors, pipes, validation, exception filters. Mocking those out is what tricked us with the rankings indexer. Test the controller via HTTP, with Supertest, with the real module wired up.
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { AuthGuard } from '../src/auth/auth.guard';
describe('POST /orders', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('rejects an invalid payload with 400', async () => {
await request(app.getHttpServer())
.post('/orders')
.send({ items: 'not-an-array' })
.expect(400)
.expect((res) => {
expect(res.body.message).toContain('items must be an array');
});
});
it('places a valid order and returns 201', async () => {
const res = await request(app.getHttpServer())
.post('/orders')
.set('Idempotency-Key', 'k_42')
.send({ items: [{ productId: 'p_1', quantity: 2 }] })
.expect(201);
expect(res.body).toMatchObject({ id: expect.any(String), status: 'placed' });
});
});
overrideGuard is the unlock. I override auth, leave everything else real, and I get a test that genuinely exercises pipe validation, the controller, the service, the repository. The only thing I swap is the part that needs a real user. If the test runs in under a second, it isn’t testing enough.
This one I’ll die on. Do not run integration tests against sqlite to “speed things up”. The query planner differs, the type coercion differs, partial indexes don’t behave the same, and the bugs that bite you in production are the ones that need the real engine to reproduce.
TestContainers spins up a real Postgres per test run. Slower? A little. Worth it.
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Test } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { TypeOrmModule } from '@nestjs/typeorm';
import { OrdersModule } from '../src/orders/orders.module';
import { OrderRepository } from '../src/orders/order.repository';
describe('OrderRepository (integration)', () => {
let container: StartedPostgreSqlContainer;
let dataSource: DataSource;
let repository: OrderRepository;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16').start();
const moduleRef = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: container.getHost(),
port: container.getMappedPort(5432),
username: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
autoLoadEntities: true,
synchronize: true,
}),
OrdersModule,
],
}).compile();
dataSource = moduleRef.get(DataSource);
repository = moduleRef.get(OrderRepository);
}, 60_000);
afterAll(async () => {
await dataSource.destroy();
await container.stop();
});
beforeEach(async () => {
await dataSource.query('TRUNCATE "orders" RESTART IDENTITY CASCADE');
});
it('enforces the unique idempotency key constraint at the db', async () => {
await repository.save({ id: 'o_1', idempotencyKey: 'k_1', total: 100 });
await expect(
repository.save({ id: 'o_2', idempotencyKey: 'k_1', total: 200 }),
).rejects.toThrow(/duplicate key/);
});
});
That last assertion is the one that mattered for us on the branded-mobile-app pipeline at the creator economy platform I worked at. The Apple SubscriptionRenewal notification was getting retried after our handler returned 200 OK too late. No idempotency key check on the renewal handler. Every retry created a new creator_subscriptions row. A bunch of end customers across dozens of branded apps got charged twice in a single month, and Apple did not refund anything just because we hid the duplicate row in the UI. The structural fix was a database-level unique constraint on (apple_original_transaction_id, notification_uuid). A real Postgres integration test would have caught the absence of that constraint in a single run. A sqlite test would not have. A mocked test definitely would not have.
The rule on my teams is simple. A flake is a bug. We don’t quarantine it, we don’t .skip it with a Jira link, we fix it or we delete it. Flakes lie. Once you tolerate one, you tolerate all of them, and the team stops trusting CI.
Most NestJS flakes come from one of three sources. Shared state between tests, real timers, or assumptions about async ordering. The fix for the first is TRUNCATE between tests, never truncate the world at the suite level. The fix for the second is jest.useFakeTimers() and explicit advancement. The fix for the third is await everything, and don’t trust that setImmediate will run before the assertion.
Jest’s --maxWorkers flag is great, until two workers race on the same Postgres database. I run TestContainers per worker, and I use a per-worker schema inside one shared container when I want to keep memory low.
# .github/workflows/test.yml (excerpt)
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm jest --shard=${{ matrix.shard }}/4 --runInBand
env:
TEST_CONTAINERS_RYUK_DISABLED: true
--runInBand inside a shard, sharded across the matrix. That gets parallelism at the runner level without the database contention you get from --maxWorkers on a single host. Cuts a 14-minute suite down to about 4.
Test.createTestingModule for service-level unit tests. Override what isn’t real, never the framework.overrideGuard is enough.--maxWorkers. Database contention dies first.Thanks for reading. If you’ve got thoughts, send them my way.