NestJS Testing Patterns

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.

Start with Test.createTestingModule

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.

Controllers belong in E2E

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.

Use real Postgres, not sqlite

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.

Flaky tests are bugs

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.

CI parallelization without the pain

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.

Takeaways

  • Use Test.createTestingModule for service-level unit tests. Override what isn’t real, never the framework.
  • Test controllers via Supertest with the real module wired up. overrideGuard is enough.
  • Use TestContainers for integration tests. Real Postgres or you’re testing something that isn’t your database.
  • Flakes are bugs. Fix them or delete them. Never quarantine.
  • Shard tests at the CI matrix level, not at --maxWorkers. Database contention dies first.
  • A passing test that mocks the thing that breaks in production is worse than no test at all.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.