NestJS 테스트 전략: 단위·통합·E2E
NestJS는 테스트를 1급 시민으로 취급한다. @nestjs/testing 패키지로 모듈 시스템을 그대로 활용하면서, 의존성을 자유롭게 모킹하고, 실제 HTTP 요청을 시뮬레이션할 수 있다. 테스트 피라미드에 따라 단위 테스트(Unit)로 비즈니스 로직을, 통합 테스트(Integration)로 모듈 간 상호작용을, E2E 테스트로 API 전체 흐름을 검증한다.
이 글에서는 Testing Module 구성, 서비스 단위 테스트, 컨트롤러 통합 테스트, Supertest E2E 테스트, 데이터베이스 격리, 그리고 CI 최적화까지 실무 패턴을 다룬다.
Testing Module: 테스트 전용 DI 컨테이너
Test.createTestingModule()은 NestJS의 DI 컨테이너를 테스트용으로 생성한다. 실제 모듈과 동일한 구조이되, 원하는 의존성만 모킹으로 교체할 수 있다.
import { Test, TestingModule } from '@nestjs/testing';
describe('UserService', () => {
let service: UserService;
let userRepository: jest.Mocked<EntityRepository<User>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
findAll: jest.fn(),
persistAndFlush: jest.fn(),
removeAndFlush: jest.fn(),
},
},
{
provide: EventEmitter2,
useValue: { emit: jest.fn() },
},
],
}).compile();
service = module.get(UserService);
userRepository = module.get(getRepositoryToken(User));
});
afterEach(() => jest.clearAllMocks());
});
useValue로 모킹 객체를 직접 넣는 것이 가장 명시적이다. 어떤 메서드가 모킹되었는지 한눈에 보인다.
서비스 단위 테스트: 비즈니스 로직 검증
describe('UserService.create', () => {
it('should create a user and emit event', async () => {
// Arrange
const dto: CreateUserDto = { name: 'Alice', email: 'alice@test.com' };
const savedUser = { id: 1, ...dto, createdAt: new Date() } as User;
userRepository.persistAndFlush.mockResolvedValue(undefined);
// persistAndFlush 호출 시 엔티티에 id가 할당되는 것을 시뮬레이션
userRepository.persistAndFlush.mockImplementation(async (entity: User) => {
Object.assign(entity, { id: 1, createdAt: new Date() });
});
const eventEmitter = module.get(EventEmitter2);
// Act
const result = await service.create(dto);
// Assert
expect(userRepository.persistAndFlush).toHaveBeenCalledTimes(1);
expect(eventEmitter.emit).toHaveBeenCalledWith(
'user.created',
expect.objectContaining({ userId: 1 }),
);
expect(result.name).toBe('Alice');
});
it('should throw on duplicate email', async () => {
const dto: CreateUserDto = { name: 'Bob', email: 'exists@test.com' };
userRepository.findOne.mockResolvedValue({ id: 2 } as User);
await expect(service.create(dto))
.rejects
.toThrow(ConflictException);
expect(userRepository.persistAndFlush).not.toHaveBeenCalled();
});
it('should calculate premium tier for high-value users', async () => {
const user = { id: 1, totalSpent: 150000, orderCount: 25 } as User;
userRepository.findOne.mockResolvedValue(user);
const tier = await service.calculateTier(1);
expect(tier).toBe(UserTier.PREMIUM);
});
});
컨트롤러 통합 테스트: Pipe·Guard·Interceptor 포함
컨트롤러 테스트는 서비스를 모킹하되, Pipe(Validation), Guard, Interceptor는 실제로 동작시켜야 한다. 요청/응답 변환이 올바른지 검증하는 것이 목적이다.
describe('UserController', () => {
let app: INestApplication;
let userService: jest.Mocked<UserService>;
beforeAll(async () => {
const module = await Test.createTestingModule({
controllers: [UserController],
providers: [
{
provide: UserService,
useValue: {
create: jest.fn(),
findById: jest.fn(),
findAll: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true }) // 인증 우회
.compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
await app.init();
userService = module.get(UserService);
});
afterAll(() => app.close());
describe('POST /users', () => {
it('201 - should create user', () => {
userService.create.mockResolvedValue({
id: 1, name: 'Alice', email: 'alice@test.com',
} as User);
return request(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201)
.expect((res) => {
expect(res.body.id).toBe(1);
expect(res.body.name).toBe('Alice');
});
});
it('400 - should reject invalid email', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'not-an-email' })
.expect(400)
.expect((res) => {
expect(res.body.message).toContain('email');
});
});
it('400 - should reject extra fields', () => {
return request(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'a@b.com', isAdmin: true })
.expect(400); // forbidNonWhitelisted
});
});
});
E2E 테스트: 전체 스택 검증
E2E 테스트는 실제 DB, 실제 모듈을 사용하여 API의 전체 흐름을 검증한다. 테스트 전용 DB(SQLite in-memory 또는 테스트 컨테이너)를 사용한다.
// test/app.e2e-spec.ts
describe('User API (e2e)', () => {
let app: INestApplication;
let orm: MikroORM;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MikroOrmModuleOptions)
.useValue({
dbName: ':memory:', // SQLite in-memory
driver: SqliteDriver,
allowGlobalContext: true,
})
.compile();
app = module.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
orm = module.get(MikroORM);
await orm.getSchemaGenerator().createSchema();
});
afterAll(async () => {
await orm.close();
await app.close();
});
// 각 테스트마다 DB 초기화
beforeEach(async () => {
await orm.getSchemaGenerator().clearDatabase();
});
it('should create and retrieve a user', async () => {
// Create
const createRes = await request(app.getHttpServer())
.post('/users')
.send({ name: 'Alice', email: 'alice@test.com' })
.expect(201);
const userId = createRes.body.id;
// Retrieve
const getRes = await request(app.getHttpServer())
.get(`/users/${userId}`)
.expect(200);
expect(getRes.body.name).toBe('Alice');
expect(getRes.body.email).toBe('alice@test.com');
});
it('should return 404 for non-existent user', async () => {
await request(app.getHttpServer())
.get('/users/99999')
.expect(404);
});
it('should handle concurrent creation with same email', async () => {
const dto = { name: 'Alice', email: 'same@test.com' };
const [res1, res2] = await Promise.all([
request(app.getHttpServer()).post('/users').send(dto),
request(app.getHttpServer()).post('/users').send(dto),
]);
const statuses = [res1.status, res2.status].sort();
expect(statuses).toEqual([201, 409]); // 하나 성공, 하나 충돌
});
});
모킹 패턴: useValue vs useClass vs useFactory
| 방식 | 용도 | 예시 |
|---|---|---|
useValue |
jest.fn() 객체 직접 주입 | 단위 테스트에서 가장 흔함 |
useClass |
테스트 전용 구현체 클래스 | InMemoryUserRepository |
useFactory |
동적 모킹 (다른 의존성 참조) | ConfigService 기반 분기 |
overrideProvider |
기존 모듈의 provider 교체 | E2E에서 외부 서비스 모킹 |
overrideGuard |
Guard 교체 | 인증 우회 |
// useClass: 테스트 전용 구현체
class InMemoryUserRepository {
private users: User[] = [];
private nextId = 1;
async findOne(filter: any): Promise<User | null> {
return this.users.find(u => u.id === filter.id) ?? null;
}
async persistAndFlush(user: User): Promise<void> {
user.id = this.nextId++;
this.users.push(user);
}
clear(): void { this.users = []; }
}
// useFactory: 동적 생성
{
provide: CacheService,
useFactory: (config: ConfigService) => {
if (config.get('CACHE_DRIVER') === 'redis') {
return new RedisCacheService(config);
}
return new InMemoryCacheService();
},
inject: [ConfigService],
}
// overrideProvider: E2E에서 외부 API 차단
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(PaymentGateway)
.useValue({
charge: jest.fn().mockResolvedValue({ transactionId: 'test-123' }),
refund: jest.fn().mockResolvedValue({ success: true }),
})
.compile();
테스트 데이터베이스 격리 전략
// 전략 1: 각 테스트마다 DB 초기화 (가장 안전)
beforeEach(async () => {
await orm.getSchemaGenerator().clearDatabase();
});
// 전략 2: 트랜잭션 롤백 (더 빠름)
let em: EntityManager;
beforeEach(async () => {
em = orm.em.fork();
await em.begin();
});
afterEach(async () => {
await em.rollback();
});
// 전략 3: Docker Testcontainers (실제 DB 엔진)
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container: StartedPostgreSqlContainer;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine')
.withDatabase('test')
.start();
const module = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MikroOrmModuleOptions)
.useValue({
clientUrl: container.getConnectionUri(),
driver: PostgreSqlDriver,
})
.compile();
}, 60_000); // 컨테이너 시작 시간 여유
afterAll(async () => {
await container.stop();
});
CI 최적화: 속도와 안정성
// jest.config.ts
export default {
// 프로젝트별 분리 실행
projects: [
{
displayName: 'unit',
testMatch: ['**/*.spec.ts'],
testPathIgnorePatterns: ['.e2e-spec.ts'],
},
{
displayName: 'e2e',
testMatch: ['**/*.e2e-spec.ts'],
testTimeout: 30_000,
},
],
// 워커 수 제한 (CI에서 메모리 절약)
maxWorkers: '50%',
// 캐시 활용
cacheDirectory: '/tmp/jest-cache',
};
// package.json scripts
{
"test": "jest --project unit",
"test:e2e": "jest --project e2e --runInBand",
"test:ci": "jest --ci --coverage --project unit && jest --ci --runInBand --project e2e"
}
E2E 테스트는 --runInBand로 직렬 실행한다. 병렬 실행 시 DB 포트 충돌이나 커넥션 풀 고갈이 발생할 수 있다.
운영 체크리스트
- 테스트 피라미드: Unit 70% / Integration 20% / E2E 10% 비율을 목표로 한다. E2E는 핵심 시나리오만 커버한다
- app.close() 필수:
afterAll에서 반드시 앱과 DB 연결을 정리한다. 미정리 시 Jest가 종료되지 않는다 - 타임아웃: E2E 테스트는
testTimeout을 넉넉히 설정한다. DB 스키마 생성, 컨테이너 시작 등에 시간이 걸린다 - 외부 의존성 차단: 결제 API, 이메일 서비스 등 외부 호출은 반드시 overrideProvider로 모킹한다
- Seed 데이터: 테스트 간 공유 데이터는
beforeAll에서 seed하고, 테스트별 데이터는beforeEach에서 생성한다 - 커버리지: Exception Filter와 에러 경로도 반드시 테스트한다. 해피 패스만 테스트하면 장애 시 대응이 어렵다
NestJS의 Testing Module은 DI 컨테이너를 활용한 강력한 테스트 인프라를 제공한다. 핵심은 서비스 로직은 단위 테스트로 빠르게, API 계약은 컨트롤러 통합 테스트로, 핵심 시나리오는 E2E로 검증하는 계층적 전략이다. 모킹 범위를 최소화하고 실제 동작에 가깝게 테스트할수록 프로덕션 버그를 사전에 잡을 수 있다.