단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
📚 교육용 풀스택 SaaS 개발기 시리즈 (10편)
NestJS + Prisma 프로젝트에서 Jest 테스트 인프라를 처음부터 세팅한 기록. 단위/통합 설정 분리, Test Data Factory 패턴, PrismaService Mock 전략, 그리고 mockResolvedValue 타입 오류까지.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Jest 설정은 단위/통합을 분리해야 한다. 단위 테스트는 병렬(50%), 통합 테스트는 순차(maxWorkers: 1). 같은 설정으로 돌리면 DB 경합이 터진다
- PrismaService Mock은
any타입이 현실적이다.jest.Mocked<PrismaService>로 하면mockResolvedValue타입 오류가 끝없이 나온다- Test Data Factory 패턴으로 테스트 데이터를 관리한다.
createStudent({ currentLevelId: 3n })한 줄이면 필요한 필드만 오버라이드된 완전한 객체가 나온다$transactionMock은 콜백을 직접 실행하는 패턴이 핵심이다.jest.fn((cb) => cb(mockPrisma))로 트랜잭션 내부 로직까지 테스트 가능test/setup.ts에서 전역 Mock 초기화를 잡아야 테스트 간 오염이 없다.clearAllMocks+restoreAllMocks조합이 안전
🤔 발단 — 테스트 없이 얼마나 갈 수 있을까
이전 편까지 DDD 3계층을 구축하고 인터페이스를 구현체로 전환했다. 코드가 돌아가긴 하는데, 확신은 없었다. Domain Service 하나 고치면 어디가 깨질지 모르는 상태. 수동으로 API 호출해서 확인하는 건 한계가 있었다.
테스트를 작성하기로 했다. 그런데 NestJS + Prisma 조합에서 Jest를 세팅하는 건 생각보다 결정할 게 많았다.
📌 핵심: 테스트 인프라는 “나중에 하지 뭐”가 아니라 가능한 빨리 잡아야 한다. 코드가 쌓일수록 Mock 구조를 맞추는 비용이 기하급수적으로 늘어난다.
🔧 설정 분리 — 단위 테스트와 통합 테스트는 다른 세계
첫 번째 결정은 Jest 설정 파일을 분리하는 것이었다. 단위 테스트와 통합 테스트는 실행 환경이 완전히 다르다.
// jest.config.js — 단위 테스트 전용
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [
'**/*.spec.ts',
'!**/*.integration.spec.ts', // 통합 테스트 제외
'!**/test/repositories/**', // Repository 통합 테스트 제외
],
testTimeout: 5000, // 5초면 충분
maxWorkers: '50%', // 병렬 실행
clearMocks: true,
restoreMocks: true,
resetMocks: true,
};
// jest.integration.config.js — 통합 테스트 전용
require('dotenv').config({ path: '.env.test' });
module.exports = {
...require('./jest.config'),
testRegex: '.*\\.integration\\.spec\\.ts$',
testMatch: null, // 단위 테스트 패턴 무효화
testTimeout: 30000, // DB 연산이라 30초
maxWorkers: 1, // 순차 실행 (DB 경합 방지)
setupFilesAfterEnv: ['<rootDir>/test/setup-integration.ts'],
collectCoverage: false, // 통합 테스트는 커버리지 불필요
displayName: { name: 'INTEGRATION', color: 'blue' },
};
⚠️ 주의:
maxWorkers가 핵심이다. 통합 테스트를 병렬로 돌리면 같은 테이블에 여러 테스트가 동시에 TRUNCATE → INSERT를 하면서 데이터가 꼬인다. 반드시maxWorkers: 1로 순차 실행해야 한다.
실행 커맨드도 분리했다.
# 단위 테스트 (빠름, ~3.5초)
pnpm test:unit
# 통합 테스트 (느림, ~21분)
pnpm test:integration
# 전체
pnpm test:all
🏗️ 전역 Setup — Mock 오염 방지의 기본기
test/setup.ts는 모든 단위 테스트 전에 실행되는 전역 설정이다. 딱 세 줄이지만 빠뜨리면 테스트 간 Mock 상태가 오염돼서 “개별로 돌리면 통과하는데 전체로 돌리면 실패” 현상이 생긴다.
// test/setup.ts
jest.setTimeout(5000);
beforeEach(() => {
jest.clearAllMocks(); // 모든 mock의 호출 기록 초기화
});
afterEach(() => {
jest.restoreAllMocks(); // spyOn으로 감싼 원본 함수 복원
});
🔍 단서:
clearMocks와restoreAllMocks는 하는 일이 다르다.clearMocks는 호출 횟수/인자 기록을 리셋하고,restoreAllMocks는jest.spyOn으로 교체된 구현체를 원본으로 되돌린다. 둘 다 해야 완전한 격리가 된다.
🧪 PrismaService Mock — any가 정답이었다
NestJS에서 Prisma를 테스트하는 건 Mock 설계가 전부다. Domain Service가 PrismaService를 주입받으니까, 테스트에서는 가짜 PrismaService를 넣어줘야 한다.
처음에는 타입 안전하게 하려고 jest.Mocked<PrismaService>를 시도했다.
// ❌ Before — 타입 안전하게 하려다 벽에 부딪힌 코드
let prisma: jest.Mocked<PrismaService>;
prisma.student.findUnique.mockResolvedValue({
id: 'student-1',
name: '테스트',
// ...
});
// ❌ Error: Property 'mockResolvedValue' does not exist on type '...'
Prisma Client의 타입 구조가 복잡해서 jest.Mocked로 감싸면 메서드 체인 중간에 타입이 깨진다. student.findUnique의 반환 타입이 조건부 타입 + 제네릭으로 되어 있어서, Jest의 Mock 래퍼가 이걸 제대로 추론하지 못한다.
// ✅ After — 프로젝트 전체에서 채택한 패턴
let prisma: any;
const mockPrismaService = {
student: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
level: {
findUnique: jest.fn(),
findMany: jest.fn().mockResolvedValue([]),
},
$transaction: jest.fn((callback) => callback(mockPrismaService)),
};
📌 핵심:
any는 보통 안티패턴이지만, Prisma Mock에서는 현실적 선택이다. 타입을 맞추려고 100줄짜리 제네릭 유틸리티를 만드는 것보다, 테스트 코드의 가독성을 택했다. 실제 비즈니스 로직의 타입 검증은 TypeScript 컴파일러가 해준다.
💰 $transaction Mock — 콜백을 직접 실행하는 트릭
Prisma의 Interactive Transaction은 콜백 함수를 받는다. 실제 코드에서는 이렇게 쓴다.
// Application Service 실제 코드
async registerStudent(dto: RegisterDto) {
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { ... } });
const student = await tx.student.create({ data: { userId: user.id, ... } });
return student;
});
}
이걸 테스트하려면 $transaction이 콜백을 실행하면서 Mock 클라이언트를 넘겨줘야 한다.
// $transaction Mock — 콜백을 즉시 실행
const mockPrismaService = {
user: {
create: jest.fn().mockResolvedValue({ id: 'user-1', role: 'MEMBER' }),
},
student: {
create: jest.fn().mockResolvedValue({ id: 'student-1', name: '테스트' }),
},
$transaction: jest.fn((callback) => callback(mockPrismaService)),
// ^^^^^^^^ 자기 자신을 tx로 넘긴다
};
$transaction이 받은 콜백을 그대로 실행하면서, 트랜잭션 클라이언트(tx)로 mockPrismaService 자체를 넘긴다. 실제 코드에서 tx.user.create()를 호출하면 mockPrismaService.user.create()가 실행되는 구조다.
⚠️ 주의: 트랜잭션 내부에서만 쓰는 모델이 있다면, 외부 Mock 객체에도 해당 모델을 추가해야 한다. 실제 Prisma에서는
tx가 별도 클라이언트지만, Mock에서는 같은 객체를 재사용하기 때문이다.
🏭 Test Data Factory — 테스트 데이터의 지옥에서 탈출
Prisma 모델은 필드가 많다. Member(유저) 하나 만들려면 id, name, birthdate, enrollmentDate, currentLevelId, levelChangedAt, poorConsecutiveDays… 10개 넘는 필드를 매번 채워야 한다. 테스트마다 이걸 반복하면 코드가 눈물겨워진다.
Factory 패턴을 도입했다.
// test/factories/base.factory.ts
import { faker } from '@faker-js/faker';
export type FactoryFunction<T> = (overrides?: Partial<T>) => T;
export function randomBigIntId(): bigint {
return BigInt(faker.number.int({ min: 1, max: 9999999 }));
}
export function generateKoreanName(): string {
const lastNames = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
const firstNames = ['민준', '서준', '예준', '도윤', '시우', '주원', ...];
return faker.helpers.arrayElement(lastNames) + faker.helpers.arrayElement(firstNames);
}
// test/factories/student.factory.ts
export function createStudent(overrides?: Partial<Student>): Student {
const defaults: Student = {
id: randomStringId(),
name: generateKoreanName(),
birthdate: faker.date.birthdate({ min: 6, max: 18, mode: 'age' }),
enrollmentDate: randomRecentDate(),
currentLevelId: null,
levelChangedAt: null,
poorConsecutiveDays: 0,
excellentConsecutiveDays: 0,
normalConsecutiveDays: 0,
createdAt: new Date(),
updatedAt: new Date(),
// ... 나머지 필드
};
return mergeDeep(defaults, overrides ?? {});
}
이제 테스트에서는 필요한 필드만 넘기면 된다.
// 기본 유저 생성
const student = createStudent();
// 특정 레벨에 배치된 유저
const student = createStudent({ currentLevelId: 3n });
// 연속 저조한 유저 (레벨 하향 시나리오 테스트용)
const student = createStudentWithConsecutiveDays(AchievementState.POOR, 5);
// 특정 지표가 약한 유저
const { student, metrics } = StudentFactory.withWeakMetric(MetricCode.METRIC_A);
📌 핵심: Factory의 가치는 “기본값”에 있다. 테스트가 관심 있는 필드만 오버라이드하고, 나머지는 합리적인 기본값으로 채운다. 테스트 코드를 읽을 때 “이 테스트가 뭘 검증하는지”가 한눈에 보인다.
🔌 NestJS Testing Module — 의존성 주입 테스트 환경
NestJS의 Test.createTestingModule을 써서 DI 컨테이너를 구성한다. 실제 모듈 설정을 그대로 가져오되, 외부 의존성만 Mock으로 교체하는 패턴이다.
describe('LevelAdjustmentApplicationService', () => {
let service: LevelAdjustmentApplicationService;
let prismaService: any;
let decisionService: jest.Mocked<LevelAdjustmentDecisionService>;
let eventEmitter: jest.Mocked<EventEmitter2>;
beforeEach(async () => {
const mockPrismaService = {
student: { findUnique: jest.fn(), update: jest.fn() },
level: { findUnique: jest.fn() },
levelAdjustmentEvent: { create: jest.fn() },
$transaction: jest.fn((cb) => cb(mockPrismaService)),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
LevelAdjustmentApplicationService,
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: LevelAdjustmentDecisionService, useValue: { evaluateAdjustment: jest.fn() } },
{ provide: EventEmitter2, useValue: { emit: jest.fn() } },
],
}).compile();
service = module.get(LevelAdjustmentApplicationService);
prismaService = mockPrismaService;
});
});
🔍 단서:
{ provide: PrismaService, useValue: mockPrismaService }패턴이 핵심이다. NestJS DI가PrismaService를 요청할 때 실제 인스턴스 대신 Mock 객체를 넣어준다. 이전 편에서 다룬 DI 토큰 개념이 여기서 빛을 발한다.
🧩 bcrypt 모듈 레벨 Mock — jest.mock의 위치가 중요하다
유저 등록 테스트에서 bcrypt가 필요했다. bcrypt는 네이티브 바인딩이라 테스트 환경에서 느리고, 비밀번호 해시 결과를 예측할 수 없으면 assertion이 어렵다.
// jest.mock은 import 바로 아래에 위치해야 한다
jest.mock('bcrypt');
import * as bcrypt from 'bcrypt';
describe('StudentOnboardingApplicationService', () => {
beforeEach(() => {
(bcrypt.hash as jest.Mock).mockResolvedValue('$2b$10$hashed');
});
it('유저를 등록하면 비밀번호가 해시된다', async () => {
await service.registerStudent(dto);
expect(bcrypt.hash).toHaveBeenCalledWith('rawPassword', 10);
});
});
⚠️ 주의:
jest.mock('bcrypt')는 반드시import문 바로 아래에 써야 한다. Jest가 내부적으로 호이스팅하긴 하지만, TypeScript + ts-jest 환경에서는 순서가 꼬이면 원본 모듈이 먼저 로드되는 경우가 있다.
📊 결과 — 257개 테스트, 3.5초
최종적으로 구축된 테스트 인프라의 규모는 이랬다.
| 구분 | 테스트 수 | 실행 시간 | 설정 파일 |
|---|---|---|---|
| Domain Services | 91개 | ~1초 | jest.config.js |
| Application Services | 99개 | ~1.5초 | jest.config.js |
| Controllers | 27개 | ~0.5초 | jest.config.js |
| Factories | 25개 | ~0.3초 | jest.config.js |
| 기타 | 15개 | ~0.2초 | jest.config.js |
| 단위 소계 | 257개 | ~3.5초 | |
| Repository (통합) | 101개 | ~21분 | jest.integration.config.js |
단위 테스트 257개가 3.5초에 돌아간다. 코드 한 줄 고치고 테스트 돌리는 데 거부감이 없는 속도다. 통합 테스트 101개는 21분이 걸리지만, 이건 실제 PostgreSQL을 쓰니까 어쩔 수 없다. PR 올리기 전에 한 번 돌리는 용도다.
📋 정리 — 핵심 요약
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| Jest 설정 | ❌ 단위/통합 같은 config | ✅ 설정 파일 분리 (병렬 vs 순차) |
| PrismaService Mock 타입 | ❌ jest.Mocked<PrismaService> | ✅ any + 필요한 모델만 Mock |
$transaction 테스트 | ❌ 트랜잭션 로직 건너뛰기 | ✅ jest.fn((cb) => cb(mock)) |
| 테스트 데이터 | ❌ 매번 전체 필드 직접 입력 | ✅ Factory 패턴 + Partial<T> 오버라이드 |
| Mock 초기화 | ❌ 테스트마다 수동 리셋 | ✅ test/setup.ts 전역 설정 |
| 외부 모듈 Mock | ❌ 실제 bcrypt 실행 | ✅ jest.mock() 모듈 레벨 교체 |
테스트 인프라는 한번 잘 잡아두면 그 위에 쌓이는 모든 테스트가 편해진다. 반대로 처음에 대충 시작하면 100개쯤 됐을 때 리팩토링 지옥이 온다. 이 시점에 시간을 투자한 건 좋은 결정이었다.
다음 편에서는 이 테스트 인프라 위에서 실제로 E2E 테스트를 구축하고, Cloud SQL과 호환성 문제로 삽질한 이야기를 다룬다.
📚 교육용 풀스택 SaaS 개발기 시리즈 (10편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지