test
TDD(Test Driven Development)는 “실패하는 테스트 → 최소 구현 → 리팩토링”의 순환 구조를 반복하며 점진적으로 품질 높은 코드를 만들어가는 개발 방식입니다. 이번 글에서는 TypeScript와 Jest 환경에서 Red-Green-Refactor 사이클을 따라가며 회원가입 기능을 실제로 구현해봅니다.
먼저 테스트 코드를 작성합니다.
user.service.spec.ts
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user';
describe('UserService', () => {
it('registerUser() 호출 시 유효한 ID가 반환', () => {
const email = 'hanpy@example.com';
const password = 'hanpy123';
const repo: jest.Mocked<UserRepository> = {
save: jest.fn((user: User) => {
user.id = 1;
return user;
}),
// findByEmail: jest.fn(() => null),
};
const userService = new UserService(repo);
const userId = userService.registerUser(email, password);
expect(userId).toBeGreaterThan(0);
expect(repo.save).toHaveBeenCalledTimes(1);
expect(repo.save.mock.calls[0][0]).toEqual(
expect.objectContaining({ email, password })
);
});
});| 구문 | 역할 | 설명 |
|---|---|---|
describe() | 테스트 묶음 | 클래스나 모듈 단위 그룹 |
it() | 개별 테스트 케이스 | 기능별 시나리오 정의 |
expect(...).toBeGreaterThan(0) | 값 비교 | ID가 양수인지 확인 |
expect(...).toHaveBeenCalledTimes(1) | 호출 횟수 확인 | save()가 1회 호출되었는지 |
expect(...).toEqual(expect.objectContaining(...)) | 객체 속성 비교 | 호출 시 전달된 인자 검증 |
이 시점에는 아직 UserService가 없기 때문에 테스트는 실패(레드)합니다. 이 단계의 목적은 무엇을 구현해야 하는지 명확히 하는 것에 포인트를 둡니다.
Green 단계에서 최소 구현을 진행합니다.
user.ts
export class User {
id?: number;
constructor(public email: string, public password: string) {}
}user.repository.ts
import { User } from './user';
export interface UserRepository {
save(user: User): User;
// findByEmail(email: string): User | null; // 이후 중복 체크에 사용
}user.service.ts
import { User } from './user';
import { UserRepository } from './user.repository';
export class UserService {
constructor(private readonly repository: UserRepository) {}
registerUser(email: string, password: string): number {
const user = new User(email, password);
const saved = this.repository.save(user);
return saved.id!;
}
}테스트가 통과하도록 가장 단순한 구현만 작성합니다. 이제 기능은 동작하지만, 구조는 아직 미흡합니다.
유효하지 않은 이메일, 중복 이메일은 거부하는 테스트를 추가로 작성합니다.
user.service.spec.ts
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { User } from './user';
describe('UserService - registerUser', () => {
// ... (기존 코드)
it('잘못된 이메일이면 예외가 발생한다', () => {
const repo: UserRepository = {
save: jest.fn(),
findByEmail: jest.fn(() => null),
};
const userService = new UserService(repo);
expect(() => userService.registerUser('not-an-email', 'pw')).toThrow('Invalid email');
});
it('이미 존재하는 이메일이면 예외가 발생한다', () => {
const existing = new User('dup@example.com', 'hashed');
existing.id = 1;
const repo: UserRepository = {
save: jest.fn(),
findByEmail: jest.fn(() => existing),
};
const userService = new UserService(repo);
expect(() => userService.registerUser('dup@example.com', 'pw')).toThrow('Email already exists');
expect(repo.save).not.toHaveBeenCalled();
});
});
추가 요구사항을 테스트로 정의하고 다시 실패시킵니다. 이렇게 테스트로 명세를 확장해 나가면, 코드가 안정적으로 진화합니다.
user.service.ts
import { User } from './user';
import { UserRepository } from './user.repository';
export class UserService {
constructor(private readonly repository: UserRepository) {}
registerUser(email: string, password: string): number {
// 최소한의 검증(조악)
if (!email.includes('@')) throw new Error('Invalid email');
const exists = this.repository.findByEmail(email);
if (exists) throw new Error('Email already exists');
const user = new User(email, password); // 아직 해시도 안 함
const saved = this.repository.save(user);
return saved.id!;
}
}새 테스트를 통과시키기 위해 빠르게 수정합니다. 이 시점에서는 “정확히 동작하지만, 구조적으로 불안정한 코드”입니다. 코드를 확인하면 규칙은 지켰지만, 유효성/중복/보안 로직이 서비스에 뒤섞여 있고, 비밀번호 해시도 없습니다. 이제 리팩토링으로 설계를 정리합니다.
email.ts
export class Email {
private constructor(public readonly value: string) {}
static parse(raw: string): Email {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!re.test(raw)) throw new Error('Invalid email');
return new Email(raw.trim().toLowerCase());
}
}errors.ts
export class DuplicateEmailError extends Error {
constructor() {
super('Email already exists');
}
}password.hasher.ts
export interface PasswordHasher {
hash(plain: string): string;
}user.ts
import { Email } from './email';
export class User {
id?: number;
constructor(
public readonly email: Email,
public readonly passwordHash: string,
) {}
}user.repository.ts
import { User } from './user';
import { Email } from './email';
export interface UserRepository {
save(user: User): User; // 영속화 후 id 채워서 반환
findByEmail(email: Email): User | null; // Email 값객체로 검색
}user.service.ts
import { Email } from './email';
import { User } from './user';
import { UserRepository } from './user.repository';
import { PasswordHasher } from './password.hasher';
import { DuplicateEmailError } from './errors';
export class UserService {
constructor(
private readonly repository: UserRepository,
private readonly hasher: PasswordHasher,
) {}
registerUser(rawEmail: string, plainPassword: string): number {
const email = Email.parse(rawEmail);
if (this.repository.findByEmail(email)) throw new DuplicateEmailError();
const passwordHash = this.hasher.hash(plainPassword);
const saved = this.repository.save(new User(email, passwordHash));
return saved.id!;
}
}user.service.spec.ts
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { PasswordHasher } from './password.hasher';
import { Email } from './email';
import { User } from './user';
import { DuplicateEmailError } from './errors';
function mkRepo(overrides?: Partial<UserRepository>): jest.Mocked<UserRepository> {
return {
save: jest.fn((u: User) => ({ ...u, id: 1 })),
findByEmail: jest.fn(() => null),
...overrides,
} as jest.Mocked<UserRepository>;
}
function mkHasher(): jest.Mocked<PasswordHasher> {
return { hash: jest.fn((p: string) => `hashed:${p}`) } as jest.Mocked<PasswordHasher>;
}
describe('UserService - registerUser (Refactor v2)', () => {
it('정상 가입 시 ID 반환 + 비밀번호 해시', () => {
const repo = mkRepo();
const hasher = mkHasher();
const sut = new UserService(repo, hasher);
const id = sut.registerUser('Hanpy@Example.com', 'pw123');
expect(id).toBe(1);
// 저장된 사용자 검증
const savedUser = repo.save.mock.calls[0][0];
expect(savedUser.email.value).toBe('hanpy@example.com'); // 소문자 정규화
expect(savedUser.passwordHash).toMatch(/^hashed:/);
// 상호작용
expect(repo.findByEmail).toHaveBeenCalledWith(Email.parse('Hanpy@Example.com'));
expect(hasher.hash).toHaveBeenCalledWith('pw123');
});
it('잘못된 이메일이면 예외', () => {
const sut = new UserService(mkRepo(), mkHasher());
expect(() => sut.registerUser('bad', 'pw')).toThrow('Invalid email');
});
it('중복 이메일이면 DuplicateEmailError', () => {
const email = Email.parse('dup@example.com');
const existing = new User(email, '...');
existing.id = 42;
const repo = mkRepo({ findByEmail: jest.fn(() => existing) });
const sut = new UserService(repo, mkHasher());
expect(() => sut.registerUser('dup@example.com', 'pw')).toThrow(DuplicateEmailError);
expect(repo.save).not.toHaveBeenCalled();
});
});| 파일 | 책임 |
|---|---|
| email.ts | 이메일의 유효성·정규화 책임 (값 객체) |
| errors.ts | 도메인 규칙 위반을 의미하는 명시적 예외 |
| password.hasher.ts | 비밀번호 해시 알고리즘의 추상화 (DIP 핵심 포트) |
| user.ts | 사용자 도메인 엔티티 (Email 포함) |
| user.repository.ts | 저장소 추상화 (DB 접근 분리) |
| user.service.ts | 비즈니스 흐름 조율 (Use Case) |
| user.service.spec.ts | 행위 검증 테스트 (mock 기반) |
Red-Green 사이클을 반복하며 얻은 코드를 리팩토링합니다. 이 단계에서 도메인 객체로 책임을 이동하고, 의존성을 분리해 유지보수성과 확장성을 높입니다.
추가로 만약 hasher 부분 사용부분을 bcrypt로 만든다면, 아래정도로 참고하면 될 것 같습니다.
// bcrypt-password.hasher.ts
import { PasswordHasher } from './password.hasher';
import bcrypt from 'bcryptjs'; // npm install bcryptjs
export class BcryptPasswordHasher implements PasswordHasher {
private readonly saltRounds = 10;
hash(plain: string): string {
return bcrypt.hashSync(plain, this.saltRounds);
}
// (선택) 비밀번호 검증 메서드도 종종 추가함
verify(plain: string, hashed: string): boolean {
return bcrypt.compareSync(plain, hashed);
}
}실제 실무 사용 시에는 aync로 아래와 같이 비동기로 사용해야합니다.
// bcrypt-password.hasher.ts (async)
import { PasswordHasher } from './password.hasher';
import bcrypt from 'bcryptjs';
export class AsyncBcryptPasswordHasher implements PasswordHasher {
private readonly saltRounds = 10;
async hash(plain: string): Promise<string> {
return bcrypt.hash(plain, this.saltRounds);
}
async verify(plain: string, hashed: string): Prmise<boolean> {
return bcrypt.compare(plain, hashed);
}
}
// password.hasher.ts (async 버전)
export interface PasswordHasher {
hash(plain: string): Promise<string>;
verify?(plain: string, hashed: string): Promise<boolean>;
}| 단계 | 목표 | 결과 |
|---|---|---|
| Red | 실패하는 테스트로 요구 명세 정의 | “무엇을 만들어야 하는가” 명확화 |
| Green | 최소 구현으로 테스트 통과 | 기능 확보 |
| Refactor | 구조 개선 및 책임 분리 | 품질 향상 |