Testing in NestJS: Why It Matters and How to Do It Right

Testing is one of those topics developers love to skip. "I'll add tests later" is the most common lie we tell ourselves. But here's the thing: tests aren't just about catching bugs. They're about confidence – confidence to refactor, confidence to deploy on Friday, confidence that your authentication actually works.
In this post, I'll cover the testing strategy I use for my NestJS backend: unit tests for isolated logic, E2E tests for complete flows, and why the distinction matters.
The Test Pyramid: A Mental Model
╱╲
╱ ╲
╱ E2E╲ Few, slow, expensive
╱──────╲
╱ ╲
╱Integration╲ Medium amount
╱────────────╲
╱ ╲
╱ Unit Tests ╲ Many, fast, cheap
╲────────────────╱This pyramid isn't just theory – it's a practical guide for where to invest your testing effort.
Unit tests form the base. They're fast (milliseconds), isolated (no database, no network), and cheap to write. You should have lots of them. They test individual functions and methods: "Does my password hashing work? Does my slug generator handle special characters?"
E2E (End-to-End) tests sit at the top. They're slow (seconds), expensive (need a real database), but invaluable. They test complete user flows: "Can someone actually register, log in, and create a project?" These catch integration issues that unit tests miss.
Integration tests live in the middle, testing how components work together without going through HTTP. For most NestJS applications, I find a combination of solid unit tests and focused E2E tests covers everything I need.
Why Test at All?
Let me be direct: untested code is a liability. Here's what tests actually give you:
Refactoring confidence. Want to rewrite that ugly service method? With tests, you change the implementation, run the suite, and know immediately if you broke something. Without tests, you're manually clicking through your app hoping you didn't miss an edge case.
Documentation that doesn't lie. Tests show how your code is supposed to be used. Unlike comments that rot, tests fail when they become inaccurate.
Faster debugging. When a test fails, you know exactly what broke. Compare that to "the app is broken somewhere" at 2 AM.
Deployment confidence. A green test suite means you can deploy without holding your breath. This is especially valuable for solo developers without a QA team.
Unit Tests: Fast and Focused
Unit tests isolate a single piece of logic by replacing all dependencies with mocks. In NestJS, this typically means testing a service while mocking the repositories and other services it depends on.
Here's the structure of a typical unit test:
typescript
describe('AuthService', () => {
let service: AuthService;
let usersService: jest.Mocked<UsersService>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: {
verify: jest.fn(),
findById: jest.fn(),
},
},
// ... other mocked dependencies
],
}).compile();
service = module.get(AuthService);
usersService = module.get(UsersService);
});
it('should throw UnauthorizedException on invalid credentials', async () => {
usersService.verify.mockResolvedValue(null);
await expect(service.login('test@example.com', 'wrong'))
.rejects.toThrow(UnauthorizedException);
});
});The key concept here is mocking. Instead of a real UsersService that hits the database, we provide a fake object where we control exactly what each method returns. This makes tests fast (no I/O), deterministic (no flaky database state), and focused (we're testing AuthService logic, not UsersService).
The jest.fn() creates a mock function that we can configure with mockResolvedValue() (for async functions) or mockReturnValue() (for sync functions). We can also verify it was called correctly with expect(usersService.verify).toHaveBeenCalledWith(...).
The AAA Pattern
Every test should follow Arrange-Act-Assert:
typescript
it('should return tokens on successful login', async () => {
// Arrange: Set up the test conditions
usersService.verify.mockResolvedValue(mockUser);
jwtService.signAsync.mockResolvedValue('token');
// Act: Execute the code under test
const result = await service.login('test@example.com', 'password');
// Assert: Verify the outcome
expect(result).toHaveProperty('accessToken');
expect(usersService.verify).toHaveBeenCalledWith('test@example.com', 'password');
});This pattern keeps tests readable. Anyone can glance at a test and understand: what's the setup, what are we doing, what should happen.
What to Unit Test
Focus unit tests on:
Business logic: Validation rules, calculations, transformations
Edge cases: Empty inputs, boundary values, error conditions
Branching logic: Every
ifstatement is a test case
Don't unit test:
Framework code: NestJS decorators, TypeORM queries – that's their job to test
Simple pass-through methods: If a method just calls another method, the E2E test covers it
External integrations: Use E2E tests for actual database and API interactions
E2E Tests: The Full Picture
E2E tests make real HTTP requests to your running application with a real database. They're slower but catch issues unit tests can't: Does your validation pipe reject bad input? Does your auth guard actually block unauthorized requests? Do your database relations work correctly?
The setup requires a test application factory:
typescript
export async function createTestApp(): Promise<TestContext> {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
load: [() => ({
JWT_ACCESS_SECRET: 'test-secret-at-least-32-characters',
NODE_ENV: 'test',
})],
}),
TypeOrmModule.forRoot({
type: 'postgres',
database: 'portfolio_test', // Separate test database!
synchronize: true,
dropSchema: true, // Clean slate for each run
// ... connection details
}),
// Your application modules
AuthModule,
ProjectsModule,
],
}).compile();
const app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
return { app, module };
}A few critical points here:
Separate test database. Never run tests against your development database. One wrong dropSchema: true and your data is gone. Use a dedicated portfolio_test database.
dropSchema: true ensures every test run starts fresh. No leftover data from previous runs causing flaky tests.
Same configuration as production. Use the same ValidationPipe settings, same guards, same everything. Tests should mirror reality.
Writing E2E Tests with Supertest
Supertest lets you make HTTP requests to your NestJS app and assert on responses:
typescript
describe('POST /auth/login', () => {
it('should return tokens with valid credentials', async () => {
const response = await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@test.com', password: 'Password123!' })
.expect(200);
expect(response.body).toHaveProperty('accessToken');
expect(response.body).toHaveProperty('refreshToken');
});
it('should return 401 with wrong password', async () => {
await request(app.getHttpServer())
.post('/auth/login')
.send({ email: 'admin@test.com', password: 'WrongPassword' })
.expect(401);
});
});The .expect(200) asserts the HTTP status code. You can chain assertions for headers, body structure, specific values.
Testing Protected Routes
For routes that require authentication, you need to include a valid token:
typescript
it('should create a project as admin', async () => {
const response = await request(app.getHttpServer())
.post('/projects')
.set('Authorization', `Bearer ${adminToken}`)
.send({ title: 'New Project' })
.expect(201);
expect(response.body.title).toBe('New Project');
});
it('should reject creation without auth', async () => {
await request(app.getHttpServer())
.post('/projects')
.send({ title: 'Unauthorized' })
.expect(401);
});
it('should reject creation for non-admin', async () => {
await request(app.getHttpServer())
.post('/projects')
.set('Authorization', `Bearer ${regularUserToken}`)
.send({ title: 'Forbidden' })
.expect(403);
});Always test both the happy path (it works with valid auth) and the sad paths (401 without token, 403 without permission). These tests verify your guards actually protect routes.
E2E Test Structure
I organize E2E tests by resource, following the typical CRUD flow:
typescript
describe('ProjectsController (e2e)', () => {
let app: INestApplication;
let adminToken: string;
let createdProjectId: string;
beforeAll(async () => {
// Setup: Create app, register admin, get token
});
afterAll(async () => {
// Teardown: Close app, cleanup
});
describe('POST /projects', () => {
// Create tests
});
describe('GET /projects', () => {
// Read tests
});
describe('PATCH /projects/:id', () => {
// Update tests
});
describe('DELETE /projects/:id', () => {
// Delete tests – run last!
});
});The order matters: POST creates a resource, subsequent tests use it, DELETE removes it. Using beforeAll/afterAll (not beforeEach/afterEach) keeps the database state across tests within a describe block.
Jest Configuration Tips
A few settings that make E2E testing smoother:
json
{
"testTimeout": 30000,
"maxWorkers": 1,
"verbose": true
}testTimeout: 30000 – Database setup can be slow. The default 5-second timeout causes false failures.
maxWorkers: 1 – Run tests sequentially. Parallel tests against the same database cause race conditions and flaky failures. The time saved isn't worth the debugging headaches.
verbose: true – See which tests pass/fail in real-time. Helpful for long-running E2E suites.
Running Tests
bash
# Unit tests (fast, run often)
npm run test
# Unit tests in watch mode (during development)
npm run test:watch
# E2E tests (slower, run before commits/deploys)
npm run test:e2e
# Coverage report (see what's untested)
npm run test:covI run unit tests constantly during development – they're fast enough to not break flow. E2E tests run before every commit and in CI. Coverage reports help identify blind spots, but don't chase 100% – focus on critical paths.
What to Test: A Practical Checklist
Always test:
Authentication flows (register, login, token refresh)
Authorization (protected routes reject unauthorized access)
Validation (bad input returns 400, not 500)
Core business logic (whatever makes your app unique)
Test when it matters:
Edge cases you've been bitten by before
Complex conditional logic
Data transformations
Skip testing:
Simple getters/setters
Framework functionality
Code that's obviously correct (but be honest with yourself)
Summary
┌─────────────────────────────────────────────────────────────┐
│ Testing Strategy │
├─────────────────────────────────────────────────────────────┤
│ │
│ Unit Tests (*.spec.ts) │
│ • Mock all dependencies │
│ • Test isolated logic │
│ • Fast feedback loop │
│ • Run constantly during development │
│ │
│ E2E Tests (*.e2e-spec.ts) │
│ • Real HTTP requests with Supertest │
│ • Real database (separate test DB!) │
│ • Test complete flows │
│ • Run before commits and in CI │
│ │
├─────────────────────────────────────────────────────────────┤
│ Key Principles: │
│ • Separate test database with dropSchema: true │
│ • Same configuration as production │
│ • Test happy paths AND error cases │
│ • AAA pattern: Arrange-Act-Assert │
│ • Don't chase coverage numbers – test what matters │
└─────────────────────────────────────────────────────────────┘Testing isn't about proving your code works – it's about being able to change your code with confidence. Start with E2E tests for your critical paths (auth, core features), add unit tests for complex logic, and build from there. Even a small test suite is infinitely better than none.
The best time to write tests was when you wrote the code. The second best time is now.