BackendDevGuide0006
테스트 심화와 TDD 완전 정복 A-Z
비전공자도 OK! 버그 없는 코드를 만드는 테스트의 모든 것
💡 왜 테스트를 배워야 하나요?
📚 학습 목차 — 12가지 핵심 주제
🔬 01. 테스트란? 왜 필요한가
테스트 코드는 "내가 작성한 코드가 예상대로 동작하는지 자동으로 검증하는 또 다른 코드"입니다. 음식점 비유: 음식을 손님에게 내기 전에 주방에서 맛을 보는 것!
🍽️ 테스트가 없는 세상 vs 있는 세상
😱 테스트 없는 개발
- 새 기능 추가 → 기존 기능 망가짐
- 배포 후 버그 발견 → 늦은 밤 긴급 수정
- 리팩토링이 두려워서 묵은 코드 방치
- 팀원 코드 수정 시 불안함
- "이게 왜 되지?" 매직 코드 출현
😎 테스트 있는 개발
- 새 기능 추가 → 자동으로 회귀 테스트
- 배포 전 자동으로 모든 케이스 검증
- 자신감 있게 리팩토링 가능
- 팀원 코드도 믿고 수정 가능
- 코드 의도가 테스트로 문서화됨
| 테스트 종류 | 범위 | 속도 | 목적 | 비율(권장) |
|---|---|---|---|---|
| Unit Test | 함수/클래스 단독 | ⚡ 매우 빠름 (ms) | 로직 검증 | 70% |
| Integration Test | 여러 모듈 연결 | 🔶 보통 (초) | 모듈 간 협력 검증 | 20% |
| E2E Test | 전체 앱 흐름 | 🐢 느림 (분) | 사용자 시나리오 검증 | 10% |
* 테스트 피라미드: Unit 많이, E2E 적게
⚙️ 02. Jest 기초 완전 정복
Jest는 Facebook이 만든 JavaScript/TypeScript 테스트 프레임워크입니다. NestJS 기본 내장!
💻 Jest 설치 및 기본 구조
# NestJS는 Jest 기본 내장 (별도 설치 불필요)
# 순수 Node.js/Express 프로젝트라면:
npm install --save-dev jest @types/jest ts-jest
# jest.config.js
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\.spec\.ts$', // .spec.ts 파일을 테스트 파일로 인식
transform: { '^.+\.(t|j)s$': 'ts-jest' },
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
coverageThreshold: {
global: { branches: 80, functions: 80, lines: 80 }
}
};
# 테스트 실행 명령어
npm test # 모든 테스트 실행
npm test -- --watch # 변경 감지 모드
npm test -- --coverage # 커버리지 포함
npm test -- users.spec # 특정 파일만
npm run test:e2e # E2E 테스트
💻 Jest 기본 문법 완전 가이드
// ===== 테스트 구조 =====
describe('UsersService', () => { // 테스트 그룹
describe('create()', () => { // 중첩 그룹
it('사용자를 성공적으로 생성한다', () => { // 개별 테스트
// Arrange (준비)
const input = { name: '홍길동', email: 'hong@e.com' };
// Act (실행)
const result = createUser(input);
// Assert (검증)
expect(result.name).toBe('홍길동');
});
test('이메일 중복 시 에러를 던진다', () => { // it과 동일
expect(() => createUser({ email: 'dup@e.com' }))
.toThrow('이미 사용 중인 이메일');
});
});
});
// ===== 생명주기 훅 =====
beforeAll(async () => { // 모든 테스트 전에 한 번
await connectDB();
});
afterAll(async () => { // 모든 테스트 후에 한 번
await disconnectDB();
});
beforeEach(() => { // 각 테스트 전
jest.clearAllMocks(); // Mock 초기화 (매우 중요!)
});
afterEach(() => { // 각 테스트 후
// 상태 정리
});
// ===== 테스트 건너뛰기/집중 =====
it.skip('나중에 작성할 테스트', () => {...}); // 건너뜀
it.only('이것만 실행', () => {...}); // 이것만 실행
xit('비활성화', () => {...}); // 건너뜀
🎯 03. Matcher 완전 정복
Matcher는 expect()와 함께 쓰는 검증 함수입니다. 다양한 상황을 정확하게 검증할 수 있습니다.
💻 모든 Matcher 완전 가이드
// ===== 1. 동등 비교 =====
expect(1 + 1).toBe(2); // 완전 동일 (===, 원시타입)
expect({ a: 1 }).toEqual({ a: 1 }); // 깊은 비교 (객체/배열에 사용)
expect(user).not.toBe(null); // not으로 부정
expect(value).toStrictEqual(obj); // 타입까지 엄격 비교
// ===== 2. Truthiness (참/거짓) =====
expect(true).toBeTruthy(); // truthy 값 (true, 1, "abc", {})
expect(false).toBeFalsy(); // falsy 값 (false, 0, "", null)
expect(null).toBeNull(); // null 인지
expect(undefined).toBeUndefined(); // undefined 인지
expect(1).toBeDefined(); // undefined가 아닌지
// ===== 3. 숫자 비교 =====
expect(5).toBeGreaterThan(3); // 3 초과
expect(5).toBeGreaterThanOrEqual(5); // 5 이상
expect(3).toBeLessThan(5); // 5 미만
expect(0.1 + 0.2).toBeCloseTo(0.3); // 부동소수점 비교 (toEqual 대신)
// ===== 4. 문자열 =====
expect('Hello World').toContain('World'); // 포함 여부
expect('hello').toMatch(/^hel/); // 정규식 매칭
expect('abc').toHaveLength(3); // 길이
// ===== 5. 배열/객체 =====
expect([1, 2, 3]).toContain(2); // 배열에 포함
expect([1, 2, 3]).toHaveLength(3); // 배열 길이
expect(user).toHaveProperty('email'); // 속성 존재
expect(user).toHaveProperty('role', 'ADMIN'); // 속성 값까지 확인
expect(users).toEqual(
expect.arrayContaining([ // 일부 요소 포함
expect.objectContaining({ id: 1 }) // 일부 속성 포함
])
);
// ===== 6. 비동기 테스트 =====
// Promise 방식
it('비동기 테스트', async () => {
const user = await usersService.findOne(1);
expect(user.id).toBe(1);
});
// Promise reject (에러) 테스트
it('없는 사용자 조회 시 에러', async () => {
await expect(usersService.findOne(999))
.rejects.toThrow('찾을 수 없습니다');
// 또는
await expect(usersService.findOne(999))
.rejects.toBeInstanceOf(NotFoundException);
});
// ===== 7. 스냅샷 테스트 =====
it('응답 구조가 변경되지 않음', async () => {
const user = await usersService.findOne(1);
expect(user).toMatchSnapshot(); // 첫 실행에 스냅샷 저장, 이후 비교
});
🎭 04. Mock / Spy / Stub — 가짜 객체 완전 정복
테스트에서 외부 의존성(DB, API, 이메일 서비스)을 가짜로 대체하는 기법입니다. 빠르고 독립적인 테스트의 핵심!
🔍 Mock vs Stub vs Spy 차이
| 종류 | 역할 | 호출 기록 | 반환값 설정 | 사용 예 |
|---|---|---|---|---|
| Mock | 전체 객체/함수 대체 | ✅ 기록 | ✅ 가능 | DB 서비스 전체 대체 |
| Stub | 고정된 값만 반환 | ❌ 미기록 | ✅ 가능 | 특정 API 응답 고정 |
| Spy | 실제 함수를 감시 | ✅ 기록 | ⚠️ 선택적 | 함수 호출 여부 확인 |
💻 Jest Mock 완전 가이드
// ===== 1. jest.fn() — 함수 Mock =====
const mockFn = jest.fn();
// 반환값 설정
mockFn.mockReturnValue('hello'); // 항상 'hello' 반환
mockFn.mockReturnValueOnce('first'); // 첫 번째 호출만
mockFn.mockResolvedValue({ id: 1 }); // Promise.resolve()
mockFn.mockResolvedValueOnce({ id: 1 }); // 첫 번째 호출만 resolve
mockFn.mockRejectedValue(new Error('에러')); // Promise.reject()
mockFn.mockImplementation((x) => x * 2); // 구현 자체를 대체
// 호출 검증
expect(mockFn).toHaveBeenCalled(); // 호출됐는지
expect(mockFn).toHaveBeenCalledTimes(2); // 2번 호출됐는지
expect(mockFn).toHaveBeenCalledWith('arg1'); // 특정 인수로 호출됐는지
expect(mockFn).toHaveBeenLastCalledWith(1); // 마지막 호출 인수
// ===== 2. jest.spyOn() — 실제 메서드 감시 =====
const emailService = new EmailService();
const sendSpy = jest.spyOn(emailService, 'sendMail');
sendSpy.mockResolvedValue(true); // 실제 이메일 발송 방지
// 테스트 실행 후
expect(sendSpy).toHaveBeenCalledWith(
expect.objectContaining({ to: 'hong@e.com' })
);
sendSpy.mockRestore(); // 원래 함수로 복구
// ===== 3. 모듈 전체 Mock =====
jest.mock('../utils/email'); // 모듈 전체를 자동 Mock
// 특정 모듈의 일부만 Mock
jest.mock('bcryptjs', () => ({
hash: jest.fn().mockResolvedValue('hashedPassword'),
compare: jest.fn().mockResolvedValue(true),
}));
// ===== 4. 타이머 Mock (시간 제어) =====
jest.useFakeTimers();
jest.advanceTimersByTime(1000); // 1초 경과시키기
jest.runAllTimers(); // 모든 타이머 즉시 실행
jest.useRealTimers(); // 실제 타이머로 복구
🏗️ 05. NestJS Unit Test — 서비스/컨트롤러 테스트
NestJS의 의존성 주입 시스템을 활용해 서비스와 컨트롤러를 격리하여 테스트하는 방법입니다.
💻 UsersService Unit Test 완전 구현
// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException, ConflictException } from '@nestjs/common';
describe('UsersService', () => {
let service: UsersService;
// Prisma Mock 객체 — 실제 DB 연결 없이 테스트
const mockPrisma = {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
}
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{ provide: PrismaService, useValue: mockPrisma }
],
}).compile();
service = module.get<UsersService>(UsersService);
jest.clearAllMocks(); // 각 테스트마다 Mock 초기화
});
// ===== findAll 테스트 =====
describe('findAll()', () => {
it('사용자 목록과 총 개수를 반환한다', async () => {
// Arrange
const mockUsers = [
{ id: 1, name: '홍길동', email: 'hong@e.com', createdAt: new Date() },
{ id: 2, name: '김영희', email: 'kim@e.com', createdAt: new Date() },
];
mockPrisma.user.findMany.mockResolvedValue(mockUsers);
mockPrisma.user.count.mockResolvedValue(2);
// Act
const result = await service.findAll(1, 10);
// Assert
expect(result.data).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.page).toBe(1);
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 0, take: 10 })
);
});
});
// ===== findOne 테스트 =====
describe('findOne()', () => {
it('ID로 사용자를 반환한다', async () => {
const mockUser = { id: 1, name: '홍길동', email: 'hong@e.com' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.findOne(1);
expect(result).toEqual(mockUser);
expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({
where: { id: 1 }, select: expect.any(Object)
});
});
it('존재하지 않는 ID 조회 시 NotFoundException을 던진다', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
await expect(service.findOne(999))
.rejects.toThrow(NotFoundException);
});
});
// ===== create 테스트 =====
describe('create()', () => {
it('새 사용자를 생성하고 반환한다', async () => {
const dto = { name: '홍길동', email: 'hong@e.com', password: 'Test123!' };
mockPrisma.user.findUnique.mockResolvedValue(null); // 중복 없음
mockPrisma.user.create.mockResolvedValue({ id: 1, name: dto.name, email: dto.email });
const result = await service.create(dto);
expect(result.email).toBe(dto.email);
expect(result).not.toHaveProperty('password'); // 보안: password 응답에서 제외
});
it('이메일 중복 시 ConflictException을 던진다', async () => {
mockPrisma.user.findUnique.mockResolvedValue({ id: 1, email: 'dup@e.com' });
await expect(service.create({ name: '홍', email: 'dup@e.com', password: 'pw' }))
.rejects.toThrow(ConflictException);
});
});
});
🌐 06. E2E Test — 실제 HTTP 요청 통합 테스트
E2E(End-to-End) 테스트는 실제 HTTP 요청을 보내서 전체 API 흐름을 검증합니다. 사용자가 실제로 API를 사용하는 것처럼 테스트!
💻 NestJS E2E 테스트 완전 구현
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Users API (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let accessToken: string;
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
imports: [AppModule], // 실제 앱 모듈 사용
}).compile();
app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.setGlobalPrefix('api/v1');
await app.init();
prisma = moduleRef.get<PrismaService>(PrismaService);
// 테스트 DB 초기화
await prisma.user.deleteMany();
});
afterAll(async () => {
await prisma.user.deleteMany(); // 테스트 후 정리
await app.close();
});
// ===== 회원가입 테스트 =====
describe('POST /api/v1/auth/register', () => {
it('201 — 회원가입 성공', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ name: '테스터', email: 'test@e2e.com', password: 'Test123!' })
.expect(201)
.expect((res) => {
expect(res.body.data.email).toBe('test@e2e.com');
expect(res.body.data).not.toHaveProperty('password');
});
});
it('409 — 이메일 중복', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ name: '중복', email: 'test@e2e.com', password: 'Test123!' })
.expect(409);
});
it('400 — 입력값 검증 실패', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ name: 'a', email: 'invalid', password: '1234' })
.expect(400)
.expect((res) => {
expect(res.body.message).toBeInstanceOf(Array);
expect(res.body.message.length).toBeGreaterThan(0);
});
});
});
// ===== 로그인 + 인증 테스트 =====
describe('POST /api/v1/auth/login', () => {
it('200 — 로그인 성공, 토큰 발급', async () => {
const res = await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@e2e.com', password: 'Test123!' })
.expect(200);
expect(res.body.data.accessToken).toBeDefined();
accessToken = res.body.data.accessToken; // 이후 테스트에서 사용
});
it('401 — 잘못된 비밀번호', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: 'test@e2e.com', password: 'wrongpassword' })
.expect(401);
});
});
// ===== 인증이 필요한 라우트 테스트 =====
describe('GET /api/v1/auth/profile', () => {
it('200 — 토큰으로 프로필 조회', () => {
return request(app.getHttpServer())
.get('/api/v1/auth/profile')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
});
it('401 — 토큰 없이 접근', () => {
return request(app.getHttpServer())
.get('/api/v1/auth/profile')
.expect(401);
});
});
});
🔴 07. TDD란? — Red-Green-Refactor 사이클
TDD(Test-Driven Development) = 테스트를 먼저 작성하고 코드를 나중에 작성하는 개발 방법론! 처음엔 어색하지만 익히면 강력합니다.
🔄 TDD 3단계 사이클
RED
실패하는 테스트 먼저 작성
아직 구현이 없으므로 당연히 실패!
GREEN
테스트를 통과하는 최소한의 코드 작성
완벽하지 않아도 OK!
REFACTOR
테스트를 통과하면서 코드 개선
중복 제거, 가독성 향상
🍽️ 음식점 비유로 이해하는 TDD
❌ 일반 개발 방식
- 요리(코드) 만들기
- 손님에게 서빙
- 맛이 없다고 불평 (버그 발견)
- 긴급 수정
✅ TDD 방식
- 레시피(테스트) 작성
- 레시피대로 요리
- 자동으로 맛 검증
- 안심하고 서빙!
| 항목 | 일반 개발 | TDD 개발 |
|---|---|---|
| 버그 발견 시점 | 출시 후 (비용 ↑↑) | 개발 중 (비용 ↓↓) |
| 리팩토링 자신감 | ❌ 두려움 | ✅ 자신감 |
| 초기 개발 속도 | ✅ 빠름 | ⚠️ 약간 느림 |
| 장기 유지보수 | ❌ 어려움 | ✅ 매우 쉬움 |
| 코드 설계 | ⚠️ 설계 후 고치기 어려움 | ✅ 자연스럽게 좋은 설계 |
🛠️ 08. TDD 실전 — 장바구니 기능을 TDD로 구현
실제로 TDD 사이클을 돌며 기능을 구현해봅니다. 처음엔 테스트가 실패하고, 코드를 작성하면 통과됩니다!
💻 1단계: 🔴 RED — 실패하는 테스트 먼저 작성
// cart.service.spec.ts — 구현 전에 테스트 먼저 작성!
describe('CartService', () => {
let service: CartService;
beforeEach(() => {
service = new CartService();
});
describe('addItem()', () => {
it('빈 장바구니에 상품을 추가한다', () => {
service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
expect(service.getItems()).toHaveLength(1);
});
it('같은 상품 추가 시 수량이 증가한다', () => {
service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
service.addItem({ id: 1, name: '사과', price: 1000, quantity: 3 });
expect(service.getItems()[0].quantity).toBe(5);
expect(service.getItems()).toHaveLength(1); // 중복 안 생김
});
});
describe('getTotalPrice()', () => {
it('총 금액을 올바르게 계산한다', () => {
service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
service.addItem({ id: 2, name: '바나나', price: 500, quantity: 3 });
expect(service.getTotalPrice()).toBe(3500); // 2000 + 1500
});
it('빈 장바구니의 총 금액은 0이다', () => {
expect(service.getTotalPrice()).toBe(0);
});
});
describe('removeItem()', () => {
it('상품을 장바구니에서 제거한다', () => {
service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
service.removeItem(1);
expect(service.getItems()).toHaveLength(0);
});
});
});
// ↑ 아직 CartService가 없으므로 실행하면 모두 실패 (🔴 RED)
💻 2단계: 🟢 GREEN — 테스트를 통과하는 최소 구현
// cart.service.ts — 테스트를 통과하도록 구현
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
export class CartService {
private items: CartItem[] = [];
addItem(item: CartItem): void {
const existing = this.items.find(i => i.id === item.id);
if (existing) {
existing.quantity += item.quantity;
} else {
this.items.push({ ...item });
}
}
getItems(): CartItem[] {
return [...this.items];
}
getTotalPrice(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
removeItem(id: number): void {
this.items = this.items.filter(i => i.id !== id);
}
}
// npm test 실행 → 모든 테스트 통과! (🟢 GREEN)
💻 3단계: 🔵 REFACTOR — 테스트 유지하며 코드 개선
// 리팩토링 후 (기능은 동일, 코드만 개선)
export class CartService {
private items = new Map<number, CartItem>(); // 배열 → Map으로 성능 개선
addItem(item: CartItem): void {
const existing = this.items.get(item.id);
if (existing) {
this.items.set(item.id, { ...existing, quantity: existing.quantity + item.quantity });
} else {
this.items.set(item.id, { ...item });
}
}
getItems(): CartItem[] {
return Array.from(this.items.values());
}
getTotalPrice(): number {
return this.getItems().reduce((sum, { price, quantity }) => sum + price * quantity, 0);
}
removeItem(id: number): void {
this.items.delete(id); // filter 대신 delete로 성능 개선
}
}
// npm test 다시 실행 → 여전히 모두 통과! 리팩토링 성공 🔵
📊 09. 테스트 커버리지 — 측정과 목표 설정
커버리지는 "내 테스트가 코드의 몇 %를 검증하는가"입니다. 단, 100%가 목표가 아닙니다 — 의미 있는 테스트가 중요!
💻 커버리지 측정 및 설정
# 커버리지 리포트 생성
npm test -- --coverage
# 결과 예시:
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 87.5 | 80.0 | 90.0 | 87.5 |
users.service.ts | 95.0 | 88.0 | 100.0 | 95.0 |
auth.service.ts | 80.0 | 72.0 | 80.0 | 80.0 |
--------------------|---------|----------|---------|---------|
# 4가지 커버리지 지표
# - Statements: 실행된 코드 라인 비율
# - Branches: if/switch 분기 처리 비율
# - Functions: 호출된 함수 비율
# - Lines: 실행된 줄 비율
// jest.config.js — 커버리지 최소 기준 설정
module.exports = {
coverageThreshold: {
global: {
branches: 80, // 전체 80% 이하면 테스트 실패
functions: 80,
lines: 80,
statements: 80
},
'./src/services/': { // 서비스 폴더는 더 엄격하게
lines: 90
}
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts', // 테스트 파일 제외
'!src/main.ts', // 진입점 제외
'!src/**/*.module.ts', // 모듈 파일 제외
'!src/**/*.dto.ts', // DTO 파일 제외
],
coverageReporters: ['text', 'lcov', 'html'], // HTML 리포트도 생성
};
📏 커버리지 목표 가이드
| 커버리지 | 평가 | 상황 |
|---|---|---|
| 0~50% | ❌ 위험 | 테스트가 거의 없는 상태. 즉시 개선 필요 |
| 50~70% | ⚠️ 부족 | 핵심 기능은 테스트되지만 엣지 케이스 부족 |
| 70~85% | ✅ 양호 | 대부분 스타트업/중소기업 목표 |
| 85~95% | 🚀 우수 | 대기업/핀테크/의료 서비스 목표 |
| 95~100% | 💎 최고 | 항공/금융 등 고신뢰성 시스템. 비용↑ |
🗄️ 10. DB 테스트 — Prisma Mock + 인메모리 DB
실제 DB 없이 빠르게 테스트하는 두 가지 방법을 배웁니다.
💻 Prisma Mock 전략 완전 가이드
// npm install --save-dev jest-mock-extended
// test/prisma-mock.ts — 재사용 가능한 Prisma Mock
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
import { PrismaService } from '../src/prisma/prisma.service';
export const prismaMock = mockDeep<PrismaService>();
export type MockPrisma = DeepMockProxy<PrismaService>;
// jest.setup.ts
beforeEach(() => {
mockReset(prismaMock);
});
// 사용 예시
describe('PostsService', () => {
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
PostsService,
{ provide: PrismaService, useValue: prismaMock }
],
}).compile();
service = module.get(PostsService);
});
it('게시글 목록을 조회한다', async () => {
// 타입 안전한 Mock 설정 (TypeScript 자동완성 지원!)
prismaMock.post.findMany.mockResolvedValue([
{ id: 1, title: '테스트 글', content: '내용', published: true,
authorId: 1, createdAt: new Date() }
]);
prismaMock.post.count.mockResolvedValue(1);
const result = await service.findAll(1, 10);
expect(result.data).toHaveLength(1);
expect(prismaMock.post.findMany).toHaveBeenCalledTimes(1);
});
});
🚀 11. CI/CD 파이프라인에 테스트 통합
테스트를 자동화 파이프라인에 포함시켜 코드 병합 전에 자동으로 실행되도록 합니다. GitHub Actions로 구현합니다!
🔄 CI/CD + 테스트 자동화 흐름
💻 GitHub Actions 워크플로우
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
# E2E 테스트용 MySQL 컨테이너
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: testpassword
MYSQL_DATABASE: testdb
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- name: Node.js 설정
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 의존성 설치
run: npm ci
- name: TypeScript 빌드 확인
run: npm run build
- name: Unit Test 실행
run: npm test -- --coverage --forceExit
- name: 커버리지 80% 미달 시 실패 처리
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
echo "커버리지: $COVERAGE%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "❌ 커버리지 80% 미달!"
exit 1
fi
- name: Prisma 마이그레이션 실행
run: npx prisma migrate deploy
env:
DATABASE_URL: mysql://root:testpassword@localhost:3306/testdb
- name: E2E Test 실행
run: npm run test:e2e -- --forceExit
env:
DATABASE_URL: mysql://root:testpassword@localhost:3306/testdb
JWT_SECRET: test-secret-key
- name: 커버리지 리포트 업로드 (Codecov)
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
deploy:
needs: test # test 성공 후에만 실행
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: 프로덕션 배포
run: echo "🚀 테스트 통과! 자동 배포 시작..."
🎤 12. 면접 Q&A + 실무 체크리스트 + 로드맵
테스트 관련 면접 단골 질문과 현업에서 바로 쓸 수 있는 체크리스트입니다.
Q1. TDD란 무엇인가요? 장단점을 설명해주세요.
TDD(Test-Driven Development)는 테스트 코드를 먼저 작성하고 그 테스트를 통과하는 코드를 나중에 작성하는 개발 방법론입니다. Red(실패 테스트) → Green(통과 코드) → Refactor(개선) 사이클을 반복합니다. 장점: 버그 조기 발견, 자신감 있는 리팩토링, 자연스러운 좋은 설계. 단점: 초기 개발 속도 다소 저하, 학습 곡선 존재.
Q2. Unit Test와 E2E Test의 차이는?
Unit Test: 함수/클래스 하나만 격리해서 테스트합니다. 빠르고(ms) 독립적이며 외부 의존성을 Mock으로 대체합니다. 로직 검증에 적합합니다. E2E Test: 실제 HTTP 요청을 보내 전체 앱 흐름을 테스트합니다. 느리지만(초~분) 실제 사용자 경험을 검증합니다. 테스트 피라미드: Unit 70%, Integration 20%, E2E 10%를 권장합니다.
Q3. Mock과 Stub, Spy의 차이는?
Mock: 외부 의존성을 완전히 가짜로 대체하고 호출 여부를 검증합니다 (jest.fn()). Stub: 특정 응답만 반환하는 가짜 구현으로 호출 여부는 검증하지 않습니다. Spy: 실제 함수를 그대로 쓰면서 호출 기록만 감시합니다 (jest.spyOn()). 현업에서는 이 세 가지를 통칭해서 "Mock"이라고 부르는 경우가 많습니다.
Q4. 테스트 커버리지 100%가 목표인가요?
아닙니다. 100% 커버리지보다 의미 있는 테스트가 더 중요합니다. 커버리지가 높아도 엣지 케이스나 비즈니스 로직을 제대로 검증하지 않으면 의미가 없습니다. 일반적인 현업 목표는 80~90%이며, 중요한 비즈니스 로직은 100%를 목표로 합니다. getters/setters, DTO, 단순 매핑 코드는 100% 달성이 어렵고 불필요합니다.
Q5. 좋은 테스트 코드의 조건은?
① FIRST 원칙: Fast(빠름), Independent(독립적), Repeatable(반복 가능), Self-validating(자동 검증), Timely(적시에 작성). ② AAA 패턴: Arrange(준비) → Act(실행) → Assert(검증)를 명확히 구분. ③ 하나의 테스트는 하나의 시나리오만 검증. ④ 테스트 이름이 "무엇을 테스트하는지" 명확히 표현. ⑤ 외부 의존성은 Mock으로 격리.
Q6. 레거시 코드에 테스트를 추가하는 전략은?
① 새로운 기능 추가 시 무조건 테스트 작성. ② 버그 수정 시 먼저 버그 재현 테스트 작성 후 수정. ③ 리팩토링 전에 핵심 로직 테스트 작성. ④ 커버리지를 단계적으로 높이기 (40% → 60% → 80%). ⑤ 테스트하기 어려운 코드는 의존성 주입 패턴으로 리팩토링. 한 번에 완벽하게 하려 하지 말고 점진적으로 개선하는 것이 현실적입니다.
📋 테스트 현업 체크리스트
✅ 기초 (반드시)
- Service 단위 테스트 작성
- 핵심 비즈니스 로직 Unit Test
- 에러 케이스 테스트 포함
- Jest mock으로 외부 의존성 격리
- beforeEach에서 Mock 초기화
- AAA 패턴 준수
🚀 심화 (현업 수준)
- E2E 테스트로 API 전체 검증
- 커버리지 80% 이상 목표
- CI 파이프라인에 테스트 통합
- 커버리지 기준 미달 시 PR 차단
- 테스트 DB 분리 운영
- 테스트 이름 명확하게 작성
🏆 고급 (시니어 수준)
- TDD로 새 기능 개발
- 계약 테스트 (Pact)
- 성능 테스트 (k6, Artillery)
- 돌연변이 테스트 (Stryker)
- 시각적 회귀 테스트
- 팀 테스트 문화 구축
🗺️ 테스트 학습 로드맵 (5단계)
| 단계 | 학습 내용 | 기간 | 결과물 |
|---|---|---|---|
| 1단계 | Jest 기초 + Matcher + 비동기 테스트 | 3~4일 | 기본 테스트 코드 작성 |
| 2단계 | Mock/Spy + NestJS Unit Test | 1주 | 서비스 테스트 완성 |
| 3단계 | E2E Test + 커버리지 측정 | 1주 | API 전체 테스트 |
| 4단계 | TDD 실습 + CI/CD 통합 | 1주 | 자동화 파이프라인 |
| 5단계 | 기존 프로젝트에 테스트 추가 실전 적용 | 2주 | 커버리지 80%+ 달성 |
🎉 BackendDevGuide0006 완료!
Jest, Mock, E2E 테스트, TDD, 커버리지, CI/CD 통합까지 — 이제 버그 없는 코드를 만드는 완전한 테스트 역량을 갖췄습니다!
다음: BackendDevGuide0007 — Docker + AWS 배포 완전 정복