NestJS 테스트 전략
NestJS는 테스트 유틸리티를 프레임워크 레벨에서 제공하는 몇 안 되는 백엔드 프레임워크입니다. @nestjs/testing 패키지의 Test.createTestingModule()을 활용하면 DI 컨테이너를 그대로 살린 채 의존성을 교체할 수 있어, 단위 테스트와 통합 테스트 모두 깔끔하게 작성할 수 있습니다.
단위 테스트: Service 계층
Service의 비즈니스 로직을 외부 의존성 없이 테스트합니다:
import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './user.entity';
describe('UserService', () => {
let service: UserService;
let mockRepo: Record<string, jest.Mock>;
beforeEach(async () => {
mockRepo = {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: mockRepo,
},
],
}).compile();
service = module.get<UserService>(UserService);
});
describe('findById', () => {
it('유저를 반환한다', async () => {
const user = { id: 1, name: '테오', email: 'theo@test.com' };
mockRepo.findOne.mockResolvedValue(user);
const result = await service.findById(1);
expect(result).toEqual(user);
expect(mockRepo.findOne).toHaveBeenCalledWith({
where: { id: 1 },
});
});
it('유저가 없으면 NotFoundException', async () => {
mockRepo.findOne.mockResolvedValue(null);
await expect(service.findById(999))
.rejects.toThrow(NotFoundException);
});
});
});
getRepositoryToken()으로 TypeORM Repository의 DI 토큰을 가져와 mock으로 교체합니다. 이 패턴은 TypeORM QueryBuilder 심화에서 다룬 Repository 패턴과 자연스럽게 연결됩니다.
Mock 팩토리 패턴
테스트마다 mock을 반복 작성하는 것은 비효율적입니다. Mock 팩토리를 만들어 재사용합니다:
// test/mock/repository.mock.ts
export const createMockRepository = <T>() => ({
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn(),
getOne: jest.fn(),
})),
});
// test/mock/service.mock.ts
export const createMockUserService = () => ({
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
});
// 사용
const module = await Test.createTestingModule({
providers: [
OrderService,
{
provide: getRepositoryToken(Order),
useValue: createMockRepository(),
},
{
provide: UserService,
useValue: createMockUserService(),
},
],
}).compile();
Controller 단위 테스트
Controller는 Service를 mock하고 HTTP 요청/응답 매핑만 검증합니다:
describe('UserController', () => {
let controller: UserController;
let mockService: Record<string, jest.Mock>;
beforeEach(async () => {
mockService = {
findById: jest.fn(),
create: jest.fn(),
update: jest.fn(),
};
const module = await Test.createTestingModule({
controllers: [UserController],
providers: [
{ provide: UserService, useValue: mockService },
],
}).compile();
controller = module.get(UserController);
});
it('POST /users → 201 + 생성된 유저', async () => {
const dto = { name: '테오', email: 'theo@test.com' };
const created = { id: 1, ...dto };
mockService.create.mockResolvedValue(created);
const result = await controller.create(dto);
expect(result).toEqual(created);
expect(mockService.create).toHaveBeenCalledWith(dto);
});
});
E2E 테스트: Supertest 통합
실제 HTTP 요청을 보내 전체 파이프라인(Middleware → Guard → Interceptor → Pipe → Controller)을 검증합니다:
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
describe('Users E2E', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(getRepositoryToken(User))
.useValue(createMockRepository())
.compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}));
await app.init();
});
afterAll(() => app.close());
describe('POST /users', () => {
it('유효한 요청 → 201', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: '테오', email: 'theo@test.com' })
.expect(201)
.expect((res) => {
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('테오');
});
});
it('이메일 누락 → 400', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: '테오' })
.expect(400)
.expect((res) => {
expect(res.body.message).toContain('email');
});
});
});
});
overrideProvider()로 특정 의존성만 교체할 수 있어, 실제 모듈 구성을 유지하면서 외부 의존성(DB, Redis 등)만 mock 처리합니다.
Guard·Interceptor 테스트
NestJS Guard와 Interceptor도 독립적으로 테스트할 수 있습니다:
// Guard 테스트
describe('RolesGuard', () => {
let guard: RolesGuard;
let reflector: Reflector;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [RolesGuard, Reflector],
}).compile();
guard = module.get(RolesGuard);
reflector = module.get(Reflector);
});
it('ADMIN 역할이면 접근 허용', () => {
const context = createMockExecutionContext({
user: { role: 'ADMIN' },
handler: () => {},
});
jest.spyOn(reflector, 'get').mockReturnValue(['ADMIN']);
expect(guard.canActivate(context)).toBe(true);
});
});
// ExecutionContext mock 헬퍼
function createMockExecutionContext(opts: {
user?: any;
handler?: Function;
}): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user: opts.user }),
}),
getHandler: () => opts.handler,
getClass: () => ({}),
} as ExecutionContext;
}
테스트 DB: SQLite In-Memory
E2E 테스트에서 실제 DB를 사용하면 느리고 불안정합니다. SQLite in-memory를 사용하면 테스트마다 깨끗한 DB를 얻습니다:
// test/test-db.module.ts
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
entities: [User, Order, Product],
synchronize: true, // 테스트에서만!
dropSchema: true, // 매번 초기화
}),
],
})
export class TestDbModule {}
// E2E 테스트에서 사용
const module = await Test.createTestingModule({
imports: [
TestDbModule,
TypeOrmModule.forFeature([User]),
UserModule,
],
})
.overrideModule(TypeOrmModule) // 실제 DB 모듈 교체 불필요
.compile();
커버리지와 테스트 구조
권장하는 테스트 디렉토리 구조:
src/
├── user/
│ ├── user.service.ts
│ ├── user.service.spec.ts # 단위 테스트 (같은 폴더)
│ ├── user.controller.ts
│ └── user.controller.spec.ts
test/
├── mock/
│ ├── repository.mock.ts # Mock 팩토리
│ └── service.mock.ts
├── fixture/
│ └── user.fixture.ts # 테스트 데이터
├── user.e2e-spec.ts # E2E 테스트
└── jest-e2e.json
// test/fixture/user.fixture.ts
export const userFixture = {
valid: { name: '테오', email: 'theo@test.com' },
noEmail: { name: '테오' },
admin: { id: 1, name: 'Admin', role: 'ADMIN' },
};
// jest.config.ts — 커버리지 설정
module.exports = {
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts',
'!src/**/*.module.ts',
'!src/main.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
마무리
NestJS의 테스트 유틸리티는 DI 컨테이너와 완벽히 통합되어 있어, mock 주입이 자연스럽고 실제 모듈 구조를 유지하면서 테스트할 수 있습니다. 단위 테스트에서는 Test.createTestingModule()로 의존성을 교체하고, E2E에서는 supertest로 전체 파이프라인을 검증합니다. Mock 팩토리와 Fixture를 체계적으로 관리하면 테스트 코드의 유지보수성이 크게 향상됩니다.