Testing NestJS Applications: Unit, Integration, E2E Guide

Photo by qubodup via Openverse (CC BY 2.0)
Unit tests use Test.createTestingModule with every dependency mocked, so they verify a service's business logic in complete isolation and run in milliseconds. Integration tests wire a real, disposable database into the same testing module to verify that queries and ORM mappings actually work. End-to-end tests boot the full Nest application and use supertest to send real HTTP requests, verifying the whole pipeline from route to response exactly as a client would experience it.
Register the same provider token your service is injected with, such as a custom token from a provide syntax or a getModelToken result, but supply useValue pointing at a plain object whose methods are Jest mock functions. Because the service only depends on the token, not the concrete implementation, it cannot tell the difference between the real repository and the mock, so no source code changes are needed for testing.
app.getHttpServer() paired with supertest's request function is the standard pattern for NestJS's default Express adapter, and it is what Nest's own testing documentation demonstrates for end-to-end tests. If you use the Fastify adapter instead, the equivalent approach is app.inject with a method and url, since Fastify does not expose a raw HTTP server the same way Express does.
No — that duplicates the same assertions at two different costs. Business logic, calculations, and conditional branches belong in fast unit tests, while critical user-facing flows like login, checkout, or a payment webhook belong in end-to-end tests. Most services should have thorough unit coverage and only appear in an e2e test if they sit on a genuinely critical path, keeping the overall suite shaped like a pyramid rather than a wall of slow tests.
Never point integration tests at a shared staging or development database, since concurrent runs will corrupt each other's data. Instead, give each test run its own disposable Postgres, MySQL, or SQLite instance, synchronize the schema at the start of the suite, and close the testing module in an afterAll hook so connections are released and the next run starts from a clean, predictable state.

Photo by qubodup via Openverse (CC BY 2.0)
Every NestJS codebase eventually accumulates three different kinds of tests, and most teams write them by instinct rather than by design. The result is either an app with hundreds of slow, brittle end-to-end tests that take ten minutes to run, or an app with a thousand unit tests that all pass while the actual HTTP endpoint is broken. NestJS's testing module, built on top of Jest and Nest's own dependency injection container, gives you exactly the tools to separate these concerns properly: mocked providers for fast unit tests, a real but disposable database for integration tests, and a fully booted application for end-to-end tests. This guide walks through all three, with working code for each, and a clear framework for deciding which layer a given test actually belongs in.
Unit tests verify a single class in isolation — a service's business logic, with every collaborator replaced by a mock. They answer one question: given this input and these dependency responses, does the method under test produce the right output and call its dependencies correctly. Integration tests verify a slice of the system where at least one real infrastructure dependency is wired in, most commonly a real database instance scoped to the test run. They answer whether your queries, migrations, and ORM mappings are actually correct, something a mocked repository can never tell you. End-to-end tests verify the full request lifecycle — HTTP layer, guards, pipes, interceptors, controller, service, and (usually) a real or realistic database — by making an actual HTTP call against a booted Nest application. They answer whether a real client hitting a real route gets the response your product needs. Each layer costs more to run and maintain than the one before it, which is exactly why the ratio between them matters as much as writing them in the first place.
Nest's testing module, imported from the at-nestjs slash testing package, exposes Test.createTestingModule, which builds a real Nest dependency injection container from a module metadata object, exactly like the at-Module decorator does in production code. The difference is that in a unit test you hand it a bare providers array instead of a full at-Module class, and you substitute every provider your service under test depends on with a mock. Calling compile on the returned builder resolves the container, and module.get retrieves the fully wired service instance, with its mocked dependencies already injected. Because nothing here touches a real database, network call, or filesystem, these tests run in milliseconds and can safely run in parallel by the hundreds.
Most NestJS services depend on a repository or data-access provider rather than a concrete ORM class directly, which is exactly what makes them mockable. If your service is injected via a custom token, defined with the provide syntax rather than a class reference, your test module just needs to register that same token with a useValue pointing at a plain object whose methods are Jest mock functions. The service under test never knows the difference between the real repository and the mock, because both satisfy the same injection token. This is the same useValue provider pattern Nest documents for swapping in test doubles for Mongoose models, Sequelize models, or any other injected dependency — the mechanism is identical regardless of what you are mocking.
// invoices.service.spec.ts — unit test with a mocked repository provider
import { Test, TestingModule } from "@nestjs/testing"
import { InvoicesService } from "./invoices.service"
import { INVOICE_REPOSITORY } from "./invoices.constants"
describe("InvoicesService", () => {
let service: InvoicesService
const mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
InvoicesService,
{
provide: INVOICE_REPOSITORY,
useValue: mockRepository,
},
],
}).compile()
service = module.get<InvoicesService>(InvoicesService)
})
afterEach(() => {
jest.clearAllMocks()
})
it("throws when an invoice is already marked paid", async () => {
mockRepository.findOne.mockResolvedValue({ id: 1, status: "PAID" })
await expect(service.markAsPaid(1)).rejects.toThrow(
"Invoice is already paid"
)
expect(mockRepository.save).not.toHaveBeenCalled()
})
it("marks a pending invoice as paid and persists it", async () => {
mockRepository.findOne.mockResolvedValue({ id: 1, status: "PENDING" })
mockRepository.save.mockImplementation((entity) => Promise.resolve(entity))
const result = await service.markAsPaid(1)
expect(result.status).toBe("PAID")
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ status: "PAID" })
)
})
})
Keep unit test mocks as plain objects with Jest mock functions, not hand-rolled fake classes with real logic inside them. The moment a mock starts reimplementing business rules to make an assertion pass, you have built a second, untested copy of the code you are supposed to be testing — and it will quietly drift out of sync with the real implementation.
Unit tests cannot catch a wrong column name, an incorrect join, a missing index that changes result ordering, or a subtle mismatch between your entity definition and the actual schema, because the mocked repository always returns exactly what you told it to. Integration tests close that gap by wiring a real, disposable database into the same Test.createTestingModule flow, instead of mocking the repository away. The trade-off is speed and setup complexity: these tests need a running Postgres, MySQL, or SQLite instance, and they need a strategy for keeping test runs isolated from each other so one test's leftover rows do not corrupt the next test's assertions.
The most common approach is to point TypeOrmModule.forRoot, or the equivalent Prisma or Mongoose setup, at a database instance that only exists for the duration of the test suite — a dedicated test Postgres container, a throwaway schema, or an in-memory SQLite database for lighter-weight cases. Because Test.createTestingModule accepts a full imports array, not just a providers array, you can import the exact same TypeOrmModule or MongooseModule configuration your production AppModule uses, just pointed at different connection settings. Running schema synchronization and dropping the schema between test files keeps runs isolated, and closing the module in an afterAll hook releases the connection so Jest can exit cleanly instead of hanging on an open database handle.
// invoices.repository.integration-spec.ts — real query, test-scoped database
import { Test, TestingModule } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { Invoice } from "./invoice.entity"
import { InvoicesRepository } from "./invoices.repository"
describe("InvoicesRepository (integration)", () => {
let module: TestingModule
let repository: InvoicesRepository
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
host: process.env.TEST_DB_HOST,
port: 5433,
database: "invoices_test",
entities: [Invoice],
synchronize: true,
dropSchema: true,
}),
TypeOrmModule.forFeature([Invoice]),
],
providers: [InvoicesRepository],
}).compile()
repository = module.get<InvoicesRepository>(InvoicesRepository)
})
afterAll(async () => {
await module.close()
})
it("finds only overdue invoices for a given tenant", async () => {
await repository.save([
{ tenantId: "t1", status: "OVERDUE", dueDate: new Date("2026-01-01") },
{ tenantId: "t1", status: "PAID", dueDate: new Date("2026-01-01") },
{ tenantId: "t2", status: "OVERDUE", dueDate: new Date("2026-01-01") },
])
const results = await repository.findOverdueForTenant("t1")
expect(results).toHaveLength(1)
expect(results[0].status).toBe("OVERDUE")
})
})
Never point an integration test suite at a shared staging or development database. Concurrent test runs will race on the same rows, migrations run by one branch will break assertions in another, and a bug in a test's cleanup step can silently corrupt data another engineer is actively debugging against. Give every test run its own database or schema, and tear it down deterministically in afterAll — treat the test database as fully disposable infrastructure, not a shared resource.
End-to-end tests are the only layer that exercises the entire request pipeline exactly as a real client would trigger it — global pipes validating the incoming body, guards checking authorization, interceptors transforming the response, and the controller-to-service wiring all firing in the order Nest actually applies them in production. Nest's own documentation recommends supertest specifically because request(app.getHttpServer()) gives you a live reference to the application's actual HTTP listener, so the requests your test sends are indistinguishable, from the framework's point of view, from requests arriving over the network.
The setup mirrors a unit test's Test.createTestingModule call, but instead of stopping at compile, you call createNestApplication on the resolved module reference and then await app.init(), which boots the full Nest runtime the same way main.ts does. From there, app.getHttpServer() hands you the underlying HTTP server instance, and supertest's request function wraps it so you can chain get, post, and other HTTP verbs directly against real route paths. Because this test now depends on the whole module graph, it is the right place to stub only the true external boundaries, an email provider, a payment gateway, a third-party API, while letting the rest of the real application code run exactly as it will in production.
// invoices.e2e-spec.ts — full HTTP flow via supertest
import * as request from "supertest"
import { Test, TestingModule } from "@nestjs/testing"
import { INestApplication } from "@nestjs/common"
import { AppModule } from "../src/app.module"
describe("Invoices (e2e)", () => {
let app: INestApplication
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile()
app = moduleRef.createNestApplication()
await app.init()
})
afterAll(async () => {
await app.close()
})
it("POST /invoices/:id/pay marks an invoice paid and returns 200", async () => {
const server = app.getHttpServer()
const created = await request(server)
.post("/invoices")
.send({ tenantId: "t1", amount: 1500000 })
.expect(201)
await request(server)
.post(`/invoices/${created.body.id}/pay`)
.expect(200)
.expect((res) => {
if (res.body.status !== "PAID") {
throw new Error("expected invoice status to be PAID")
}
})
})
it("POST /invoices/:id/pay returns 409 for an already-paid invoice", async () => {
const server = app.getHttpServer()
const created = await request(server)
.post("/invoices")
.send({ tenantId: "t1", amount: 500000 })
.expect(201)
await request(server).post(`/invoices/${created.body.id}/pay`).expect(200)
return request(server)
.post(`/invoices/${created.body.id}/pay`)
.expect(409)
})
})
The most common mistake is not writing too few tests, it is writing the same assertion three times at three layers because nobody decided which layer owns which kind of correctness. Reach for a unit test when you are verifying business logic, calculations, conditional branches, and edge cases in a service method, because that is where you get the fastest feedback loop and the cheapest coverage of every input combination. Reach for an integration test when you are verifying that a specific query returns the right rows, that a migration produces the schema you expect, or that an ORM mapping actually round-trips correctly, because no amount of mocking can catch a real SQL mistake. Reserve end-to-end tests for the critical user-facing flows, a login, a checkout, a payment webhook, where you need confidence that the entire pipeline works together, and accept that you will only ever have a handful of these compared to hundreds of unit tests. A healthy NestJS test suite looks like a pyramid, not a wall, and the shape of that pyramid is a design decision, not an accident.