Integration and E2E Testing
- nestjs
- testing
- integration-testing
- e2e-testing
- postgresql
- docker
- backend-development
- node-js
In Unit Testing Services, we covered testing a service's logic in isolation, mocking every dependency so a single test only ever verifies one thing. That approach is fast and precise, but it leaves a gap: it never proves that your controller, your validation pipe, your guards, and your actual database wiring work together the way you assumed. This article closes that gap. It's part of the NestJS Production Guide series, and it's the layer of testing most teams either skip entirely or get expensively wrong.
Unit tests answer "does this function do what I think it does." Integration and end-to-end tests answer a different question: "does a real request, hitting the real HTTP layer, with a real database behind it, produce the response a client actually gets." Both questions matter, and neither one substitutes for the other.
I'll walk through NestJS's TestingModule for module-level integration tests, spinning up a real Postgres instance for realistic data-layer behavior, testing the full HTTP stack with Supertest, and the tradeoffs that determine how much of this you actually need before shipping.
Why integration tests catch what unit tests can't
A unit test for an OrdersService method typically mocks the repository, so it verifies your business logic against a fake that behaves exactly as you told it to. That's the point of a unit test, and it's also the blind spot. Say your actual TypeORM query has a subtle bug: a wrong join direction, a missing WHERE clause after adding a new column, a unique constraint you forgot to declare. A mocked repository will never notice. The mock only returns what you programmed it to return.
Integration tests remove that layer of pretending. Instead of mocking the repository, you point the module at a real database and let the actual query run. This is meaningfully slower than a unit test, since it involves real I/O, but it's the only way to catch problems that live in the gap between your code and the database driver: migration mismatches, transaction boundaries that don't roll back the way you expect, or a NestJS pipe silently coercing a query parameter into the wrong type before it reaches your handler.
The mental model I use: unit tests protect business logic, integration tests protect the seams between your code and the systems it depends on, and E2E tests protect the contract your API makes with whatever calls it. Each layer catches a different class of regression, and skipping a layer means that class of bug reaches production undetected until someone reports it.
Setting up NestJS's TestingModule for real dependencies
NestJS's Test.createTestingModule is the same tool you'd use for a unit test, but for integration tests you swap out the in-memory mocks for real connections. The key difference is what you provide: instead of a mock repository, you import the actual TypeOrmModule pointed at a test database.
// orders/orders.integration.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OrdersModule } from './orders.module';
import { Order } from './order.entity';
describe('OrdersModule (integration)', () => {
let app: INestApplication;
let dataSource: DataSource;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.TEST_DB_HOST ?? 'localhost',
port: Number(process.env.TEST_DB_PORT ?? 5433),
username: 'test',
password: 'test',
database: 'orders_test',
entities: [Order],
synchronize: true,
}),
OrdersModule,
],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
dataSource = moduleRef.get(DataSource);
await app.init();
});
afterEach(async () => {
// Truncate rather than drop, so schema stays intact between tests.
await dataSource.query('TRUNCATE TABLE "order" RESTART IDENTITY CASCADE');
});
afterAll(async () => {
await app.close();
});
it('persists an order and returns it with a generated id', async () => {
const service = app.get('OrdersService');
const order = await service.create({ productId: 'sku-123', quantity: 2 });
expect(order.id).toBeDefined();
const stored = await dataSource
.getRepository(Order)
.findOneBy({ id: order.id });
expect(stored?.quantity).toBe(2);
});
});
Two details here matter more than they look. First, synchronize: true is fine for a disposable test database but is a genuine hazard in any environment that might get confused with a real one, so this configuration should only ever point at a container that exists purely for tests. Second, truncating between tests instead of recreating the schema every time keeps the suite fast, since schema creation is the expensive part.
Running Postgres in CI without faking it
The temptation here is to use an in-memory or SQLite substitute to avoid managing a real Postgres instance in CI. Resist it. SQLite and Postgres diverge on enough things (case-sensitivity, JSON column behavior, upsert syntax, how constraints are enforced) that a green suite against SQLite doesn't tell you your Postgres-facing code actually works. If you're going to write integration tests specifically to catch data-layer bugs, the database in the test needs to be the database in production.
The practical answer is a disposable Postgres container, both locally and in CI:
# docker-compose.test.yml
services:
postgres-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: orders_test
ports:
- "5433:5432"
tmpfs:
- /var/lib/postgresql/data
Mapping the data directory to tmpfs keeps it fast and guarantees a clean slate on every restart, since nothing persists to disk. In GitHub Actions, the same idea works as a service container attached to the test job:
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: orders_test
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run test:integration
env:
TEST_DB_HOST: localhost
TEST_DB_PORT: 5433
The --health-cmd pg_isready option matters because CI runners aren't always instant. A test run that starts querying before Postgres finishes accepting connections fails for a reason that has nothing to do with your code, and that's exactly the kind of flaky failure that erodes trust in a test suite.
Testing the full HTTP stack with Supertest
Integration tests at the module level skip the HTTP transport, calling services directly through Nest's dependency injection. E2E tests go one step further and exercise the actual HTTP layer, including guards, interceptors, pipes, and serialization, by sending real requests into a running application instance.
// orders/orders.e2e-spec.ts
import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Orders (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('rejects an order with a negative quantity', async () => {
const response = await request(app.getHttpServer())
.post('/orders')
.send({ productId: 'sku-123', quantity: -1 })
.expect(400);
expect(response.body.message).toContain('quantity must not be less than 1');
});
it('creates an order and returns 201 with the resource', async () => {
const response = await request(app.getHttpServer())
.post('/orders')
.send({ productId: 'sku-123', quantity: 2 })
.expect(201);
expect(response.body).toMatchObject({ productId: 'sku-123', quantity: 2 });
expect(response.body.id).toBeDefined();
});
});
This is the layer that verifies your validation pipe rejects bad input as configured, since a unit test on the DTO class alone won't tell you whether the pipe is wired into the request pipeline correctly. It also verifies status codes and response shape, exactly the contract external clients depend on.
Tradeoffs and how much of this you actually need
Not every project needs the full pyramid built out on day one. A small SaaS with a handful of endpoints and one or two engineers gets more value, per hour spent, from solid unit tests around business logic plus a thin slice of E2E tests on the flows that would actually hurt if they broke silently, like checkout or authentication. A comprehensive integration suite against every module, before the product has real users, is effort spent on a problem you don't have yet.
The calculus changes once a team is large enough that people regularly touch code they didn't write, or once a regression in a specific flow has actually happened and cost something. At that point, integration tests around the data layer and E2E tests around critical paths pay for themselves, catching the "worked locally, broke in staging" bugs unit tests can't see by design.
Writing these tests is rarely the expensive part. Maintaining the infrastructure is: a Postgres container that starts reliably in CI, seed data that stays in sync with your schema, and cleanup logic that runs even when a test fails mid-assertion. Underestimating that maintenance cost is the most common reason integration suites get abandoned within a year of being written.
Common pitfalls that make these suites unreliable
The most common failure is test pollution: one test's leftover data changing the outcome of the next one. Truncating tables in afterEach, or wrapping each test in a transaction that rolls back afterward, solves this. Skipping cleanup means your suite passes or fails depending on execution order, which erodes trust faster than having no integration tests at all.
The second is forgetting to isolate configuration between test runs, so a test accidentally connects to a database that isn't the disposable test one. Pull the test database connection string from an environment variable that's explicitly different from your development and production values, and fail loudly if it isn't set, rather than silently falling back to a default that might point somewhere real.
The third is running these tests so slowly that nobody runs them locally before pushing. Failures then only surface in CI, well after the developer has moved on. Keeping the Postgres container fast, with tmpfs, minimal seed data, and truncation over full schema recreation, is what keeps the suite something people actually run instead of routing around.
Key takeaways
- Unit tests protect business logic; integration tests protect the seams with your database and external dependencies; E2E tests protect the actual HTTP contract. Each catches a different class of bug.
- Use a real Postgres container for integration tests, in Docker Compose locally and as a CI service container, rather than substituting SQLite or an in-memory database.
- Supertest against a fully bootstrapped `INestApplication` verifies that guards, pipes, and interceptors actually behave as configured, at the HTTP layer, not only in isolated unit tests.
- Truncate between tests (or use transactional rollback) to avoid pollution between test cases; this is the most common source of flaky integration suites.
- Don't build out a full integration and E2E suite before you have the team size or the incident history that justifies the maintenance cost; start with the critical paths.
- The ongoing cost of these suites is infrastructure maintenance, not the initial test-writing effort. Budget for that.
Frequently asked questions
What is the difference between an integration test and an E2E test in NestJS?
An integration test typically uses `Test.createTestingModule` to wire real dependencies (like a database) into a module and calls services directly through dependency injection. An E2E test goes further, bootstrapping the full application and sending real HTTP requests through Supertest, which also exercises guards, pipes, and interceptors that an integration test at the module level would skip.
Should I use SQLite instead of Postgres for faster tests?
It's tempting because SQLite needs no separate process, but Postgres and SQLite diverge on enough behavior (JSON columns, case sensitivity, constraint enforcement, upsert syntax) that a passing SQLite suite doesn't guarantee your Postgres-facing code is correct. If the whole point of the integration test is catching data-layer bugs, test against the same database engine you run in production.
How do I avoid tests interfering with each other when they share a database?
Either truncate the affected tables in `afterEach`, or wrap each test in a transaction and roll it back at the end instead of committing. Both approaches work; transactional rollback is generally faster for large suites, but truncation is simpler to reason about and easier to debug when something goes wrong.
Do I need integration and E2E tests for every module?
No. Prioritize flows where a silent regression would actually cost something, like checkout, authentication, or anything involving money or irreversible state changes. A low-risk module with good unit test coverage is often not worth the added CI time and maintenance burden.
Why does my E2E test fail in CI but pass locally?
The most common cause is a timing issue: the test suite starts querying Postgres before the container has finished accepting connections. Add a health check (`pg_isready`) to your CI service container configuration and make sure your app's connection retry logic (or the test setup) waits for it rather than assuming the database is immediately available.
Can I reuse the same TestingModule setup across multiple test files?
Extract a shared factory function for the module configuration, but avoid sharing a live `INestApplication` instance across files. Jest typically runs files in separate workers, and a shared instance can leak database state between suites in ways that are hard to trace back.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.