테스트 — Jest로 NestJS 유닛·통합 테스트 작성하기
"테스트 코드를 짜야 한다"는 것은 알겠는데, NestJS에서 DI 컨테이너를 쓰면서 어떻게 의존성을 모킹하나요? Spring의
@MockBean처럼 NestJS도overrideProvider라는 깔끔한 방법을 제공합니다.
NestJS 테스트의 핵심 — TestingModule
NestJS의 테스트는 실제 DI 컨테이너를 부트스트랩 하되, 원하는 의존성을 모킹으로 교체하는 방식입니다.
import { Test, TestingModule } from '@nestjs/testing';
describe('UserService', () => {
let service: UserService;
let repository: Repository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: { // Repository 모킹
find: jest.fn(),
findOneBy: jest.fn(),
save: jest.fn(),
create: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
repository = module.get<Repository<User>>(getRepositoryToken(User));
});
});
Spring의 @ExtendWith(MockitoExtension.class) + @InjectMocks와 비슷하지만, NestJS는 실제 모듈 구조를 그대로 사용 하면서 특정 프로바이더만 교체합니다.
유닛 테스트 — 서비스 레이어
서비스의 비즈니스 로직만 검증합니다. DB, 외부 API 같은 의존성은 모두 모킹합니다.
describe('UserService', () => {
// ... beforeEach 생략 (위와 동일)
describe('findOne', () => {
it('존재하는 유저를 반환한다', async () => {
const user = { id: 1, name: '홍길동', email: 'hong@test.com' };
jest.spyOn(repository, 'findOneBy').mockResolvedValue(user as User);
const result = await service.findOne(1);
expect(result).toEqual(user);
expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 });
});
it('존재하지 않으면 NotFoundException을 던진다', async () => {
jest.spyOn(repository, 'findOneBy').mockResolvedValue(null);
await expect(service.findOne(999))
.rejects.toThrow(NotFoundException);
});
});
describe('create', () => {
it('유저를 생성하고 저장한다', async () => {
const dto = { name: '김개발', email: 'kim@test.com' };
const savedUser = { id: 1, ...dto };
jest.spyOn(repository, 'create').mockReturnValue(savedUser as User);
jest.spyOn(repository, 'save').mockResolvedValue(savedUser as User);
const result = await service.create(dto);
expect(result.id).toBe(1);
expect(repository.create).toHaveBeenCalledWith(dto);
expect(repository.save).toHaveBeenCalled();
});
});
});
컨트롤러 테스트
컨트롤러는 라우팅이 올바른 서비스 메서드를 호출하는지 검증합니다.
describe('UserController', () => {
let controller: UserController;
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [UserController],
providers: [
{
provide: UserService,
useValue: {
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue({ id: 1, name: '테스트' }),
create: jest.fn().mockResolvedValue({ id: 1 }),
},
},
],
}).compile();
controller = module.get<UserController>(UserController);
service = module.get<UserService>(UserService);
});
it('findAll은 서비스의 findAll을 호출한다', async () => {
await controller.findAll();
expect(service.findAll).toHaveBeenCalled();
});
});
overrideProvider — 특정 의존성만 교체
실제 모듈 구조를 유지하면서 하나의 프로바이더만 교체하고 싶을 때 사용합니다.
const module = await Test.createTestingModule({
imports: [UserModule], // 실제 모듈 가져옴
})
.overrideProvider(getRepositoryToken(User))
.useValue(mockRepository) // Repository만 모킹으로 교체
.overrideProvider(AuthService)
.useValue({ validate: jest.fn() }) // AuthService도 모킹
.compile();
Spring Boot의 @MockBean과 같은 역할입니다. 모듈 전체를 다시 구성하지 않아도 되어 테스트 코드가 간결해집니다.
E2E 테스트 — HTTP 요청 전체 흐름
E2E 테스트는 실제 HTTP 요청을 보내서 Middleware → Guard → Pipe → Controller → Service 전체 흐름을 검증합니다.
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
describe('UserController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(getRepositoryToken(User))
.useValue(mockRepository)
.compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
await app.init();
});
afterAll(async () => {
await app.close();
});
it('GET /users — 200과 유저 목록 반환', () => {
return request(app.getHttpServer())
.get('/users')
.expect(200)
.expect(res => {
expect(Array.isArray(res.body)).toBe(true);
});
});
it('POST /users — 잘못된 이메일로 400 반환', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: '홍길동', email: 'not-an-email' })
.expect(400);
});
it('POST /users — 유효한 데이터로 201 반환', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: '홍길동', email: 'hong@test.com' })
.expect(201);
});
});
E2E 테스트에서 useGlobalPipes를 다시 설정해야 합니다. main.ts에서 설정한 전역 파이프는 E2E 테스트의 앱 인스턴스에는 적용되지 않습니다. 이걸 빠뜨리면 ValidationPipe가 동작하지 않아서 검증 테스트가 통과해 버리는 문제가 생깁니다.
주의할 점
E2E 테스트에서 전역 설정 누락
가장 흔한 실수입니다. main.ts의 전역 Pipe, Guard, Interceptor를 E2E 테스트 설정에서 ** 동일하게 적용 **해야 합니다. 그렇지 않으면 실제 운영과 다른 환경에서 테스트하는 것이 됩니다.
// main.ts와 동일하게 설정
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());
모킹이 너무 많으면 의미 없는 테스트가 된다
모든 의존성을 모킹하면 "모킹한 대로 동작하는지" 테스트하는 것이 되어 실제 버그를 잡지 못합니다. 유닛 테스트에서는 ** 직접 의존성만** 모킹하고, 통합 테스트에서는 실제 DB를 사용하는 것이 균형 잡힌 전략입니다.
유닛 테스트: Service만 테스트, Repository는 모킹
통합 테스트: Service + Repository + 실제 DB (테스트용 DB)
E2E 테스트: HTTP 요청 → 응답 전체 흐름
정리
| 포인트 | 내용 |
|---|---|
| TestingModule | 실제 DI 컨테이너 부트스트랩 + 선택적 모킹 |
| overrideProvider | 특정 프로바이더만 교체 (Spring의 @MockBean 대응) |
| 유닛 테스트 | 서비스 로직 검증, 의존성 모킹 |
| E2E 테스트 | supertest로 HTTP 전체 흐름 검증 |
| 핵심 함정 | E2E에서 전역 Pipe/Guard 설정 누락, 과도한 모킹 |