Tips

Integration Testing a NestJS API with Jest and Supertest

Why integration tests catch what unit tests miss in a NestJS API, and how to structure them with a real test database.

Integration Testing a NestJS API with Jest and Supertest

Unit tests with a mocked repository prove a service method calls the right methods in the right order. They do not prove a unique constraint is respected, that a guard actually rejects an unauthenticated request, or that a DTO's validation pipe catches a malformed payload before it reaches the handler. Integration tests that go through the real HTTP layer against a real (test) database catch exactly those gaps.

Boot the real Nest application, not a fake one

Test.createTestingModule({ imports: [AppModule] }) followed by app.init() boots the actual dependency graph — guards, pipes, interceptors, and all — the same way main.ts does. Point DB_DATABASE at a disposable test database via environment variables so these tests never touch development or production data.

describe('BlogPostsController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
    await app.init();
  });

  afterAll(() => app.close());

  it('rejects an unauthenticated create', () => {
    return request(app.getHttpServer())
      .post('/blog-posts')
      .send({ title: 'Test post' })
      .expect(401);
  });
});

Isolate tests with transactions, not truncation

Truncating every table between tests works but is slow once the schema has thirty tables and foreign keys to respect in the right order. Wrapping each test in a transaction that is rolled back at the end — via a beforeEach/afterEach pair around queryRunner.startTransaction() and rollbackTransaction() — keeps tests isolated and fast without ever committing test data.

  • Seed only the rows a given test needs, created through the same repositories the application uses — not raw SQL that could drift from the real schema.
  • Assert on the HTTP response shape, not on internal service calls — that is what actually changes when a contract breaks.
  • Test the negative paths explicitly: wrong role, missing field, duplicate slug, expired token. These are the cases unit tests with happy-path mocks tend to skip.
  • Keep a separate jest-e2e.json config with a longer timeout — a real database round trip is not a microsecond-scale unit test.
  • Run integration tests in CI against a freshly migrated database, never a database with is left over from a previous run.

Testing guards and pipes where they actually run

A RolesGuard unit-tested by calling canActivate() directly with a hand-built ExecutionContext proves the guard's logic in isolation, but not that it is actually wired onto the right route. An end-to-end test that hits /admin/products with a non-admin JWT and asserts a 403 proves both the guard's logic and its registration at once.

it('blocks a non-admin from the admin products list', async () => {
  const { accessToken } = await loginAs('customer');

  await request(app.getHttpServer())
    .get('/admin/products')
    .set('Authorization', `Bearer ${accessToken}`)
    .expect(403);
});

If a bug ever ships where a route was left unprotected, it is very likely there was a unit test for the guard's logic — and no end-to-end test proving the guard was actually applied to that route.

Where mocking is still the right call

Integration tests should still mock external services that cost money or are flaky by nature — a payment gateway, an email provider, an S3 upload. Inject a fake implementation behind the same interface the real provider implements, so the test proves the application's side of the contract without a live network call.

Testing file uploads and other side effects that cost money

An endpoint that uploads to S3 or sends an email needs a test that proves the request was made correctly without actually calling the paid, external service. Overriding the provider in the testing module — not mocking the whole S3Service, just the network-facing client it wraps — keeps the real business logic under test while the actual HTTP call to AWS never happens.

const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(S3Client)
  .useValue({ send: jest.fn().mockResolvedValue({ ETag: 'fake-etag' }) })
  .compile();

it('stores the uploaded key on the product', async () => {
  await request(app.getHttpServer())
    .post('/products/prod_1/media')
    .attach('file', Buffer.from('fake-image'), 'cover.jpg')
    .expect(201);

  const product = await productRepository.findOneOrFail({ where: { id: 'prod_1' } });
  expect(product.media).toHaveLength(1);
});
  • Override at the lowest-level client (the AWS SDK client, the mail transport), not at the service layer — that way the test still exercises the retry logic and error handling your own code wraps around it.
  • Assert on the side effect that matters to the business (a product_media row exists), not on the mock having been called — the mock call is an implementation detail.
  • A separate, explicitly-marked "smoke test" suite that does hit real external services in a sandbox environment is worth keeping, just not running on every commit.

Testing pagination and other easy-to-get-wrong edge cases

Pagination logic is a small surface with a disproportionate number of off-by-one bugs: the last page with a partial result set, a page number beyond the total, a page size of zero or negative, and a sort field that does not exist on the entity. Each of these deserves its own explicit test rather than trusting that "it worked for page 1" generalizes.

describe('GET /products (pagination)', () => {
  it('returns an empty items array past the last page', async () => {
    const res = await request(app.getHttpServer())
      .get('/products?page=999&pageSize=20')
      .expect(200);
    expect(res.body.items).toEqual([]);
    expect(res.body.hasNextPage).toBe(false);
  });

  it('rejects a pageSize of zero', async () => {
    await request(app.getHttpServer())
      .get('/products?page=1&pageSize=0')
      .expect(400);
  });

  it('rejects an unknown sort field instead of silently ignoring it', async () => {
    await request(app.getHttpServer())
      .get('/products?sortBy=not_a_real_field')
      .expect(400);
  });
});

That last case matters more than it looks: a query builder that silently ignores an invalid sortBy instead of rejecting it hides a frontend bug — a typo in a sort field name — behind a response that still returns 200 with unsorted (or wrongly sorted) data, which is a far harder bug to notice than a loud 400.

Deciding what belongs in the integration suite versus unit tests

A pure function with no database, no HTTP layer, and no external dependency — a price calculator, a slug generator — is well served by a fast unit test with no Test.createTestingModule overhead at all; running it through the full HTTP stack only adds time without adding confidence. The integration suite earns its cost specifically where a guard, a pipe, a database constraint, or the interaction between several services is what is actually being verified.

A healthy ratio in practice looks like many fast unit tests for business logic and a smaller, focused set of integration tests for the handful of routes where authorization, validation, or a unique constraint is the thing that could actually break in a way a unit test would never catch.

A test suite that only ever runs against a freshly migrated, empty database misses a category of bug that only shows up against realistic data volume — a query that is correct but slow enough to matter, or a pagination default that behaves differently against ten rows versus ten thousand. A periodic run of the integration suite against a seeded, larger dataset — not on every commit, but on a schedule — catches the class of bug that a fast, minimal test database structurally cannot.

Conclusion

Integration tests are slower to write and slower to run than unit tests, which is exactly why they should cover the paths that matter most: auth, validation, and the constraints the database enforces. A small suite of real end-to-end tests around the riskiest routes catches the class of bugs that mocked unit tests are structurally unable to see.

Member discussion

Share your thoughts with the ToshStack community.

Join the discussion

Become a member of ToshStack to start commenting.

Already a member? Sign in