
1. 버그 조기 발견
2. 리팩토링 안정성
3. 문서화 효과
4. 개발 속도 향상
5. 자신감 있는 배포
TypeScript 프로젝트에서 사용할 수 있는 테스트 프레임워크는 여러 가지가 있습니다.
(1) 올인원 솔루션
Jest = Test Runner + Assertion + Mocking + Coverage
다른 프레임워크는 여러 라이브러리를 조합해야 하지만, Jest는 테스트에 필요한 모든 도구를 하나의 패키지로 제공합니다. 테스트 실행기(Test Runner), 검증 라이브러리(Assertion Library), 모킹 도구(Mocking), 코드 커버리지 분석(Coverage)까지 별도 설치 없이 모두 사용할 수 있습니다.
(2) 제로 설정 (Zero Config)
(3) 강력한 기능들
(4) 풍부한 생태계
(5) 직관적인 API
// Jest - 읽기 쉬운 문법
expect(result).toBe(expected);
expect(array).toContain(item);
expect(fn).toThrow(error);
// 다른 프레임워크 - 체이닝이 복잡할 수 있음
assert.equal(result, expected);
| 기능 | Jest | Mocha + Chai | Vitest |
|---|---|---|---|
| 설정 난이도 | ⭐ 쉬움 | ⭐⭐⭐ 어려움 | ⭐⭐ 보통 |
| TypeScript 지원 | ts-jest 추가 | 여러 패키지 필요 | 네이티브 지원 |
| 실행 속도 | 빠름 | 보통 | 매우 빠름 |
| 커뮤니티 | 매우 큼 | 큼 | 성장 중 |
| 안정성 | 매우 높음 | 높음 | 보통 |
(1) Watch 모드 - 개발 중 자동 테스트
npm run test:watch
파일 저장할 때마다 관련 테스트만 자동 실행
(2) 커버리지 리포트 - 테스트 범위 확인
npm run test:coverage
어떤 코드가 테스트되지 않았는지 한눈에 확인
(3) only/skip - 특정 테스트만 실행
describe.only('이 그룹만 실행', () => {
it('테스트 1', () => { /* ... */ });
});
it.skip('이 테스트는 건너뛰기', () => { /* ... */ });
(4) 강력한 Matcher - 직관적인 검증
expect(value).toBe(expected); // 정확히 같음
expect(value).toEqual(expected); // 깊은 비교 (객체/배열)
expect(array).toContain(item); // 배열에 포함
expect(fn).toThrow(error); // 에러 발생
expect(obj).toHaveProperty('key'); // 속성 존재
expect(string).toMatch(/pattern/); // 정규식 매칭
expect(num).toBeGreaterThan(10); // 숫자 비교
expect(value).toBeTruthy(); // truthy 값
# 프로젝트 초기화
npm init -y
# TypeScript 설치
npm install -D typescript @types/node
# Jest 및 TypeScript 지원 패키지 설치
npm install -D jest @types/jest ts-jest
# Jest 설정 파일 생성
npx ts-jest config:init
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts'
]
};
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:verbose": "jest --verbose"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
주요 포인트:
target: "ES2022": # 문법(Private Fields) 지원strict: true: 엄격한 타입 체크로 버그 사전 방지test('테스트 설명', () => {
// Arrange (준비): 테스트에 필요한 데이터 준비
const input = 5;
// Act (실행): 테스트할 함수 실행
const result = add(input, 3);
// Assert (검증): 결과 확인
expect(result).toBe(8);
});
calculator.ts
export class Calculator {
add(a: number, b: number): number {
return a + b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(a: number, b: number): number {
return a * b;
}
divide(a: number, b: number): number {
if (b === 0) {
throw new Error('0으로 나눌 수 없습니다');
}
return a / b;
}
}
calculator.test.ts
import { Calculator } from './calculator';
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
describe('add', () => {
it('두 숫자를 더한다', () => {
expect(calculator.add(2, 3)).toBe(5);
});
it('음수도 정확히 더한다', () => {
expect(calculator.add(-1, -2)).toBe(-3);
});
});
describe('divide', () => {
it('두 숫자를 나눈다', () => {
expect(calculator.divide(10, 2)).toBe(5);
});
it('0으로 나누면 에러를 던진다', () => {
expect(() => calculator.divide(10, 0))
.toThrow('0으로 나눌 수 없습니다');
});
});
});
userValidator.ts
export interface User {
email: string;
password: string;
age: number;
}
export class UserValidator {
validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
validatePassword(password: string): boolean {
return password.length >= 8;
}
validateAge(age: number): boolean {
return age >= 18 && age <= 120;
}
validate(user: User): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!this.validateEmail(user.email)) {
errors.push('유효하지 않은 이메일입니다');
}
if (!this.validatePassword(user.password)) {
errors.push('비밀번호는 최소 8자 이상이어야 합니다');
}
if (!this.validateAge(user.age)) {
errors.push('나이는 18세 이상 120세 이하여야 합니다');
}
return {
valid: errors.length === 0,
errors
};
}
}
userValidator.test.ts
import { UserValidator, User } from './userValidator';
describe('UserValidator', () => {
let validator: UserValidator;
beforeEach(() => {
validator = new UserValidator();
});
describe('validateEmail', () => {
it('유효한 이메일을 통과시킨다', () => {
expect(validator.validateEmail('test@example.com')).toBe(true);
});
it('유효하지 않은 이메일을 거부한다', () => {
expect(validator.validateEmail('invalid-email')).toBe(false);
expect(validator.validateEmail('test@')).toBe(false);
expect(validator.validateEmail('@example.com')).toBe(false);
});
});
describe('validate', () => {
it('모든 조건을 만족하면 통과한다', () => {
const user: User = {
email: 'test@example.com',
password: 'password123',
age: 25
};
const result = validator.validate(user);
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('여러 조건이 실패하면 모든 에러를 반환한다', () => {
const user: User = {
email: 'invalid',
password: '123',
age: 15
};
const result = validator.validate(user);
expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(3);
expect(result.errors).toContain('유효하지 않은 이메일입니다');
});
});
});
외부 API나 데이터베이스 같은 의존성을 테스트할 때는 Mock을 사용합니다.
userService.ts
export interface EmailService {
sendWelcomeEmail(email: string): Promise<boolean>;
}
export class UserService {
#emailService: EmailService;
constructor(emailService: EmailService) {
this.#emailService = emailService;
}
async registerUser(email: string, password: string): Promise<string> {
// 실제로는 DB 저장 등의 로직이 있겠지만 단순화
const userId = `user-${Date.now()}`;
// 외부 이메일 서비스 호출
await this.#emailService.sendWelcomeEmail(email);
return userId;
}
}
src/userService.test.ts
import { UserService, EmailService } from './userService';
describe('UserService', () => {
let userService: UserService;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
// Mock 생성
mockEmailService = {
sendWelcomeEmail: jest.fn()
};
userService = new UserService(mockEmailService);
});
it('사용자 등록 시 환영 이메일을 보낸다', async () => {
mockEmailService.sendWelcomeEmail.mockResolvedValue(true);
await userService.registerUser('test@example.com', 'password123');
// Mock이 올바른 인자로 호출되었는지 검증
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledTimes(1);
});
it('이메일 전송 실패 시 에러를 처리한다', async () => {
mockEmailService.sendWelcomeEmail.mockRejectedValue(
new Error('Email service unavailable')
);
await expect(
userService.registerUser('test@example.com', 'password123')
).rejects.toThrow('Email service unavailable');
});
});
Mock의 장점:
테스트 주도 개발(TDD)은 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다.
🔴 Red → 실패하는 테스트 작성
🟢 Green → 테스트를 통과하는 최소한의 코드 작성
🔵 Refactor → 코드 개선 (테스트는 계속 통과)

회의실 예약 시스템을 TDD로 개발해봅시다.
핵심 기능
meetingRoom.test.ts
import { MeetingRoomService } from './meetingRoom';
describe('MeetingRoomService', () => {
let service: MeetingRoomService;
beforeEach(() => {
service = new MeetingRoomService();
});
describe('예약 생성', () => {
it('회의실 예약을 생성할 수 있다', () => {
const reservation = service.createReservation({
roomId: 'room-101',
userId: 'user-001',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '팀 회의'
});
expect(reservation.id).toBeDefined();
expect(reservation.roomId).toBe('room-101');
expect(reservation.title).toBe('팀 회의');
expect(reservation.status).toBe('confirmed');
});
});
});
테스트 실행: npm test → ❌ 실패 (MeetingRoomService 없음)
meetingRoom.ts
export interface Reservation {
id: string;
roomId: string;
userId: string;
startTime: Date;
endTime: Date;
title: string;
status: 'confirmed' | 'cancelled';
}
export interface CreateReservationInput {
roomId: string;
userId: string;
startTime: Date;
endTime: Date;
title: string;
}
export class MeetingRoomService {
#reservations: Map<string, Reservation> = new Map();
#nextId: number = 1;
createReservation(input: CreateReservationInput): Reservation {
const reservation: Reservation = {
id: `res-${this.#nextId++}`,
...input,
status: 'confirmed'
};
this.#reservations.set(reservation.id, reservation);
return reservation;
}
}
테스트 실행: npm test → ✅ 통과!
describe('MeetingRoomService', () => {
// ... 이전 테스트
describe('시간 중복 검증', () => {
it('같은 회의실에 시간이 겹치는 예약은 생성할 수 없다', () => {
// 첫 번째 예약
service.createReservation({
roomId: 'room-101',
userId: 'user-001',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '팀 회의'
});
// 시간이 겹치는 두 번째 예약 시도
expect(() => {
service.createReservation({
roomId: 'room-101',
userId: 'user-002',
startTime: new Date('2025-10-09T10:30:00'),
endTime: new Date('2025-10-09T11:30:00'),
title: '기획 회의'
});
}).toThrow('해당 시간에 이미 예약이 존재합니다');
});
it('다른 회의실이면 같은 시간에 예약할 수 있다', () => {
service.createReservation({
roomId: 'room-101',
userId: 'user-001',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '팀 회의'
});
const reservation = service.createReservation({
roomId: 'room-102',
userId: 'user-002',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '기획 회의'
});
expect(reservation).toBeDefined();
expect(reservation.status).toBe('confirmed');
});
});
});
테스트 실행: npm test → ❌ 실패 (중복 체크 로직 없음)
export class MeetingRoomService {
#reservations: Map<string, Reservation> = new Map();
#nextId: number = 1;
createReservation(input: CreateReservationInput): Reservation {
// 중복 검증
if (this.#hasConflict(input.roomId, input.startTime, input.endTime)) {
throw new Error('해당 시간에 이미 예약이 존재합니다');
}
const reservation: Reservation = {
id: `res-${this.#nextId++}`,
...input,
status: 'confirmed'
};
this.#reservations.set(reservation.id, reservation);
return reservation;
}
#hasConflict(
roomId: string,
startTime: Date,
endTime: Date
): boolean {
const existingReservations = Array.from(this.#reservations.values())
.filter(r => r.roomId === roomId && r.status === 'confirmed');
return existingReservations.some(reservation => {
// 시간 겹침 확인
return (
startTime < reservation.endTime &&
endTime > reservation.startTime
);
});
}
}
테스트 실행: npm test → ✅ 모두 통과!
describe('예약 취소', () => {
it('예약을 취소할 수 있다', () => {
const reservation = service.createReservation({
roomId: 'room-101',
userId: 'user-001',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '팀 회의'
});
service.cancelReservation(reservation.id);
const cancelled = service.getReservation(reservation.id);
expect(cancelled?.status).toBe('cancelled');
});
it('취소된 예약 시간에는 다시 예약할 수 있다', () => {
const first = service.createReservation({
roomId: 'room-101',
userId: 'user-001',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '팀 회의'
});
service.cancelReservation(first.id);
const second = service.createReservation({
roomId: 'room-101',
userId: 'user-002',
startTime: new Date('2025-10-09T10:00:00'),
endTime: new Date('2025-10-09T11:00:00'),
title: '기획 회의'
});
expect(second).toBeDefined();
expect(second.status).toBe('confirmed');
});
});
테스트 실행: npm test → ❌ 실패
export class MeetingRoomService {
// ... 이전 코드
cancelReservation(id: string): void {
const reservation = this.#reservations.get(id);
if (!reservation) {
throw new Error('예약을 찾을 수 없습니다');
}
reservation.status = 'cancelled';
}
getReservation(id: string): Reservation | undefined {
return this.#reservations.get(id);
}
// #hasConflict에서 취소된 예약은 제외 (이미 구현됨)
}
테스트 실행: npm test → ✅ 모두 통과!
export class MeetingRoomService {
#reservations: Map<string, Reservation> = new Map();
#nextId: number = 1;
createReservation(input: CreateReservationInput): Reservation {
this.#validateReservationTime(input.startTime, input.endTime);
this.#checkTimeConflict(input.roomId, input.startTime, input.endTime);
const reservation: Reservation = {
id: `res-${this.#nextId++}`,
...input,
status: 'confirmed'
};
this.#reservations.set(reservation.id, reservation);
return reservation;
}
cancelReservation(id: string): void {
const reservation = this.#getReservationOrThrow(id);
reservation.status = 'cancelled';
}
getReservation(id: string): Reservation | undefined {
return this.#reservations.get(id);
}
getActiveReservations(roomId: string): Reservation[] {
return Array.from(this.#reservations.values())
.filter(r => r.roomId === roomId && r.status === 'confirmed');
}
// Private helper methods
#validateReservationTime(startTime: Date, endTime: Date): void {
if (startTime >= endTime) {
throw new Error('종료 시간은 시작 시간보다 늦어야 합니다');
}
}
#checkTimeConflict(
roomId: string,
startTime: Date,
endTime: Date
): void {
if (this.#hasConflict(roomId, startTime, endTime)) {
throw new Error('해당 시간에 이미 예약이 존재합니다');
}
}
#hasConflict(
roomId: string,
startTime: Date,
endTime: Date
): boolean {
const activeReservations = this.getActiveReservations(roomId);
return activeReservations.some(reservation =>
this.#isTimeOverlapping(
startTime,
endTime,
reservation.startTime,
reservation.endTime
)
);
}
#isTimeOverlapping(
start1: Date,
end1: Date,
start2: Date,
end2: Date
): boolean {
return start1 < end2 && end1 > start2;
}
#getReservationOrThrow(id: string): Reservation {
const reservation = this.#reservations.get(id);
if (!reservation) {
throw new Error('예약을 찾을 수 없습니다');
}
return reservation;
}
}
테스트 실행: npm test → ✅ 여전히 모두 통과!
리팩토링 후에도 테스트가 통과하므로 안전하게 코드를 개선할 수 있습니다.
1. 단위테스트는 필수
2. Jest는 시작하기 좋은 선택
3. TDD는 강력한 설계 도구
4. TypeScript + Jest는 완벽한 조합
# 문법(Private Fields)으로 더 강력한 캡슐화5. Mock으로 외부 의존성 제거
Q: 모든 코드에 테스트를 작성해야 하나요?
A: 핵심 비즈니스 로직에 집중하세요. 단순한 getter/setter, 타입 정의, 외부 라이브러리 래퍼 등은 생략해도 됩니다.
Q: 테스트 작성 시간이 아깝지 않나요?
A: 초기에는 시간이 더 걸리지만, 디버깅 시간이 크게 줄어듭니다. 특히 코드가 복잡해질수록 테스트의 가치가 높아집니다.
Q: TDD를 항상 해야 하나요?
A: 상황에 따라 다릅니다. 요구사항이 명확한 기능, 복잡한 비즈니스 로직에는 효과적이에요. 탐색적인 프로토타이핑에는 오히려 방해가 될 수 있습니다.
Q: 비동기 코드는 어떻게 테스트하나요?
A: async/await를 사용하면 됩니다.
it('비동기 작업 테스트', async () => {
const result = await fetchData();
expect(result).toBe(expected);
});