nest_14_testing - Testing
Table of Contents
- unit testing a service
- controller testing with supertest
- end-to-end testing with test database
- testing security boundaries
- CI integration
- prerequisites
If you’re not testing your NestJS app , you’re deploying hope as a strategy
The framework gives you Test.createTestingModule() - a testing utilities module that provides isolated DI containers for unit and integration tests
Use it or accept that your “it works on my machine” deployment strategy will fail on someone else’s machine at exactly the wrong moment
what’s in here
- Test.createTestingModule and testing utilities
- Unit testing services with mocked dependencies
- Controller testing with supertest
- End-to-end testing with NestJS + supertest
- Database testing with test containers or in-memory DB
- Test coverage and CI integration
unit testing a service
npm install --save-dev @nestjs/testing
import { Test, TestingModule } from '@nestjs/testing'
import { UsersService } from './users.service'
import { getRepositoryToken } from '@nestjs/typeorm'
import { User } from './entities/user.entity'
describe('UsersService', () => {
let service: UsersService
let mockRepository: any
beforeEach(async () => {
mockRepository = {
findOne: jest.fn(),
find: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
delete: jest.fn()
}
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: mockRepository
}
]
}).compile()
service = module.get<UsersService>(UsersService)
})
it('should find user by email', async () => {
const mockUser = { id: '1', email: 'test@example.com' }
mockRepository.findOne.mockResolvedValue(mockUser)
const result = await service.findByEmail('test@example.com')
expect(result).toEqual(mockUser)
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { email: 'test@example.com' },
relations: ['orders']
})
})
it('should throw when user not found', async () => {
mockRepository.findOne.mockResolvedValue(null)
await expect(
service.findByEmail('nonexistent@example.com')
).rejects.toThrow('user not found')
})
})
Test.createTestingModule() creates an isolated NestJS DI container
Mock the repository with useValue - you don’t need a real database
getRepositoryToken(User) generates the injection token TypeORM uses , so your mock is injected instead of the real repository
Test happy path AND error path - “user not found” is as important as “user found”
controller testing with supertest
npm install --save-dev @nestjs/testing supertest
npm install --save-dev @types/supertest
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'
describe('UsersController (e2e)', () => {
let app: INestApplication
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
}).compile()
app = moduleFixture.createNestApplication()
await app.init()
})
afterAll(async () => {
await app.close()
})
it('GET /api/v1/users should return 401 without auth', () => {
return request(app.getHttpServer())
.get('/api/v1/users')
.expect(401)
})
it('GET /api/v1/users should return users with valid auth', () => {
return request(app.getHttpServer())
.get('/api/v1/users')
.set('Authorization', 'Bearer test-token')
.expect(200)
.expect(res => {
expect(Array.isArray(res.body)).toBe(true)
expect(res.body.length).toBeGreaterThan(0)
})
})
})
request(app.getHttpServer()) sends real HTTP requests to your Nest app
Test auth enforcement - verify that protected routes actually block unauthenticated requests
Test both success and failure responses - your error response structure should be consistent
The afterAll cleanup prevents port conflicts between test runs
end-to-end testing with test database
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'
import { TestDataSource } from './test-datasource'
describe('Auth (e2e)', () => {
let app: INestApplication
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
// override TypeORM config with test database
.overrideProvider('DataSource')
.useValue(TestDataSource)
.compile()
app = moduleFixture.createNestApplication()
// apply the same global pipes as production
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true
})
)
await app.init()
})
afterAll(async () => {
await app.close()
})
const testUser = {
email: 'test@example.com',
password: 'StrongP@ss123'
}
it('POST /api/v1/auth/register should create user', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send(testUser)
.expect(201)
.expect(res => {
expect(res.body.accessToken).toBeDefined()
expect(res.body.user.email).toBe(testUser.email)
})
})
it('POST /api/v1/auth/register should reject duplicate', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send(testUser)
.expect(409)
})
it('POST /api/v1/auth/login should return tokens', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: testUser.password })
.expect(200)
.expect(res => {
expect(res.body.accessToken).toBeDefined()
expect(res.body.refreshToken).toBeDefined()
})
})
it('POST /api/v1/auth/login should reject wrong password', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: testUser.email, password: 'wrong' })
.expect(401)
})
})
Use a test database (SQLite in-memory or a dedicated test PostgreSQL) - never run tests against production
.overrideProvider() lets you swap real providers with test doubles
Apply the same global pipes as production - your validation behavior should be identical
testing security boundaries
describe('Security - Authorization', () => {
it('should reject user accessing admin endpoint', () => {
return request(app.getHttpServer())
.get('/api/v1/admin/users')
.set('Authorization', `Bearer ${userToken}`)
.expect(403)
})
it('should allow admin accessing admin endpoint', () => {
return request(app.getHttpServer())
.get('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.expect(200)
})
it('should strip role from registration payload', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ ...testUser, role: 'admin' })
.expect(201)
.expect(res => {
expect(res.body.user.role).not.toBe('admin') // should be 'user' default
})
})
it('should reject XSS in name field', () => {
return request(app.getHttpServer())
.post('/api/v1/users')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: '<script>alert("xss")</script>' })
.expect(400) // validation pipe catches it
})
})
Test your security boundaries explicitly - not as an afterthought Verify role-based access works (user blocked , admin allowed) Verify mass assignment protection (extra fields ignored/rejected) Test injection vectors - validate that pipes catch them
CI integration
# .github/workflows/test.yml
npm ci
npm run build
npm run test # unit tests
npm run test:e2e # e2e tests
npm run test:cov # coverage report
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:ci": "jest --coverage --maxWorkers=2"
}
}
Run tests in CI - failing tests block deployment
Coverage thresholds enforce quality: "coverageThresholds": { "global": { "branches": 80, "functions": 80, "lines": 80 } }
maxWorkers: 2 prevents CI runners from running out of memory
prerequisites
nest_13_database - Database Integration
next -> nest_15_deploy - Deployment
Posted · Updated