nest_14_testing - Testing¶
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