- Authors

- Name
- Youngju Kim
- @fjvbn20031
개요
소프트웨어 테스트를 작성할 때 가장 많이 혼동되는 개념이 바로 Stub, Mock, Spy, Fake입니다. 이 용어들은 종종 혼용되지만, Gerard Meszaros가 저서 "xUnit Test Patterns"에서 정립한 테스트 대역(Test Double) 분류 체계에 따르면 각각 명확히 다른 역할을 담당합니다.
이 가이드에서는 각 테스트 대역의 정확한 의미, 올바른 사용 시점, 언어별 구현 예시, 그리고 TDD/BDD 전략까지 상세히 다룹니다.
1. 테스트 대역(Test Double)의 5가지 종류
**테스트 대역(Test Double)**이란 영화 촬영에서 위험한 장면을 대신하는 스턴트 대역(Stunt Double)에서 유래한 용어입니다. 프로덕션 코드에서 실제 의존성을 대체하여 테스트를 가능하게 해주는 객체들을 통칭합니다.
Gerard Meszaros의 분류 체계
Test Double
├── Dummy Object (전달되지만 사용되지 않는 객체)
├── Test Stub (미리 정해진 응답을 반환 - 상태 검증)
├── Test Spy (호출 방식을 기록하는 Stub)
├── Mock Object (기대값이 명세된 객체 - 행위 검증)
└── Fake Object (실제로 동작하지만 단순화된 구현)
1.1 Dummy Object
Dummy는 메서드 시그니처를 충족시키기 위해 전달되지만 실제로는 전혀 사용되지 않는 객체입니다.
// 예시: 로거가 필요하지만 테스트에서 사용되지 않을 때
class DummyLogger implements Logger {
log(message: string): void {}
error(message: string): void {}
warn(message: string): void {}
}
test('사용자 생성 - 이메일 검증', () => {
const dummyLogger = new DummyLogger()
const userService = new UserService(dummyLogger)
expect(userService.validateEmail('test@example.com')).toBe(true)
})
Dummy는 단순히 컴파일 에러를 피하거나 null을 방지하기 위해 사용합니다.
1.2 Stub
Stub은 **미리 정해진 답변(canned answer)**을 반환하는 객체입니다. 테스트에서 설정한 값만 반환하며 다른 동작은 수행하지 않습니다. **상태 검증(State Verification)**에 주로 사용됩니다.
// UserRepository Stub
class StubUserRepository implements UserRepository {
findById(id: string): User | null {
if (id === 'user-123') {
return { id: 'user-123', name: '홍길동', email: 'hong@example.com' }
}
return null
}
save(user: User): void {}
}
test('사용자 조회 서비스', () => {
const stub = new StubUserRepository()
const service = new UserService(stub)
const user = service.getUser('user-123')
expect(user?.name).toBe('홍길동')
})
1.3 Spy
Spy는 실제 구현을 래핑하거나 Stub처럼 동작하면서 **호출 기록(call recording)**을 남기는 객체입니다. 나중에 어떻게 호출되었는지 검증할 수 있습니다.
class SpyEmailService implements EmailService {
public sentEmails: Array<{ to: string; subject: string }> = []
sendEmail(to: string, subject: string, body: string): void {
this.sentEmails.push({ to, subject })
// 실제 전송은 하지 않음
}
}
test('주문 완료 시 이메일 발송', () => {
const spyEmail = new SpyEmailService()
const orderService = new OrderService(spyEmail)
orderService.completeOrder('order-456')
expect(spyEmail.sentEmails).toHaveLength(1)
expect(spyEmail.sentEmails[0].to).toBe('customer@example.com')
})
1.4 Mock Object
Mock은 **기대값(expectations)**이 사전에 명세된 객체입니다. 호출이 완료된 후 기대한 대로 호출되었는지 자동으로 검증합니다. **행위 검증(Behavior Verification)**에 사용됩니다.
Spy가 사후에 어설션을 수행하는 반면, Mock은 기대값을 미리 설정하고 검증을 자동화합니다.
// Jest의 Mock 예시
test('주문 완료 시 정확히 한 번 이메일 발송', () => {
const mockEmailService = {
sendEmail: jest.fn(),
}
const orderService = new OrderService(mockEmailService)
orderService.completeOrder('order-456')
expect(mockEmailService.sendEmail).toHaveBeenCalledTimes(1)
expect(mockEmailService.sendEmail).toHaveBeenCalledWith(
'customer@example.com',
'주문 완료',
expect.stringContaining('order-456')
)
})
1.5 Fake Object
Fake는 실제로 동작하는 구현을 가지고 있지만, 프로덕션 환경에서는 사용하기 부적합한 단순화된 버전입니다. 예를 들어 인메모리 데이터베이스, 인메모리 큐, 로컬 파일 기반 저장소 등이 해당합니다.
class FakeUserRepository implements UserRepository {
private store: Map<string, User> = new Map()
findById(id: string): User | null {
return this.store.get(id) ?? null
}
save(user: User): void {
this.store.set(user.id, user)
}
findByEmail(email: string): User | null {
for (const user of this.store.values()) {
if (user.email === email) return user
}
return null
}
}
test('중복 이메일 등록 방지', () => {
const fake = new FakeUserRepository()
const service = new UserService(fake)
service.register({ id: '1', name: '홍길동', email: 'test@example.com' })
expect(() => service.register({ id: '2', name: '이순신', email: 'test@example.com' })).toThrow(
'이미 사용 중인 이메일입니다'
)
})
2. Stub 심화
2.1 언제 Stub을 써야 하는가
Stub은 다음 상황에서 유용합니다:
- 외부 서비스에 의존할 때 (결제 API, SMS 서비스)
- 비결정적(non-deterministic) 결과를 반환할 때 (현재 시간, 랜덤 값)
- 느린 의존성을 대체할 때 (데이터베이스 쿼리, 네트워크 요청)
- 특정 에러 상황을 재현할 때
2.2 인메모리 Repository Stub 패턴
// Go 언어 예시
package repository
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
// 프로덕션 구현
type PostgresUserRepository struct {
db *sql.DB
}
// 테스트용 Stub
type StubUserRepository struct {
users map[string]*User
err error
}
func NewStubUserRepository() *StubUserRepository {
return &StubUserRepository{
users: make(map[string]*User),
}
}
func (s *StubUserRepository) FindByID(id string) (*User, error) {
if s.err != nil {
return nil, s.err
}
user, ok := s.users[id]
if !ok {
return nil, ErrUserNotFound
}
return user, nil
}
func (s *StubUserRepository) WithError(err error) *StubUserRepository {
s.err = err
return s
}
func (s *StubUserRepository) AddUser(user *User) *StubUserRepository {
s.users[user.ID] = user
return s
}
2.3 시간 Stub (Clock Interface)
시간에 의존하는 코드는 테스트하기 어렵습니다. Clock 인터페이스로 추상화합니다.
// Clock 인터페이스
type Clock interface {
Now() time.Time
}
// 프로덕션 구현
type SystemClock struct{}
func (c *SystemClock) Now() time.Time {
return time.Now()
}
// 테스트용 Stub
type StubClock struct {
fixedTime time.Time
}
func NewStubClock(t time.Time) *StubClock {
return &StubClock{fixedTime: t}
}
func (c *StubClock) Now() time.Time {
return c.fixedTime
}
// 사용 예시
func TestTokenExpiry(t *testing.T) {
fixedTime := time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC)
stubClock := NewStubClock(fixedTime)
tokenService := NewTokenService(stubClock)
token := tokenService.CreateToken("user-123")
// fixedTime + 1시간 후 만료 검사
expiredClock := NewStubClock(fixedTime.Add(2 * time.Hour))
tokenService2 := NewTokenService(expiredClock)
if tokenService2.IsValid(token) {
t.Error("만료된 토큰이 유효하다고 판단됨")
}
}
2.4 외부 API Stub (TypeScript)
// 결제 API 인터페이스
interface PaymentGateway {
charge(amount: number, cardToken: string): Promise<PaymentResult>
refund(paymentId: string): Promise<RefundResult>
}
// 테스트용 Stub
class StubPaymentGateway implements PaymentGateway {
private shouldFail = false
setToFail(): void {
this.shouldFail = true
}
async charge(amount: number, cardToken: string): Promise<PaymentResult> {
if (this.shouldFail) {
return { success: false, error: 'CARD_DECLINED' }
}
return { success: true, paymentId: `PAY-${Date.now()}` }
}
async refund(paymentId: string): Promise<RefundResult> {
return { success: true, refundId: `REF-${paymentId}` }
}
}
// Java 예시 (Mockito)
// @ExtendWith(MockitoExtension.class)
// class OrderServiceTest {
// @Mock
// PaymentGateway paymentGateway;
//
// @Test
// void 결제_실패시_주문_취소() {
// when(paymentGateway.charge(anyDouble(), anyString()))
// .thenReturn(new PaymentResult(false, "CARD_DECLINED"));
// ...
// }
// }
3. Mock 심화
3.1 Mock vs Stub 차이점
이 차이는 매우 중요합니다. Martin Fowler의 "Mocks Aren't Stubs" 아티클에서 명확히 구분합니다:
| 구분 | Stub | Mock |
|---|---|---|
| 검증 방식 | 상태 검증 (State Verification) | 행위 검증 (Behavior Verification) |
| 어설션 위치 | 테스트 대상 객체의 상태 확인 | 대역 객체에 대한 호출 확인 |
| 기대값 설정 | 반환값만 설정 | 호출 횟수, 인자 등을 미리 설정 |
| 실패 시점 | 테스트 종료 후 검증 | 기대값 불일치 시 즉시 실패 |
// Stub 방식 - 상태 검증
test('주문 완료 상태 검증 (Stub)', async () => {
const stubPayment = new StubPaymentGateway()
const orderService = new OrderService(stubPayment)
const order = await orderService.placeOrder(items, cardToken)
// 주문 객체의 상태를 검증
expect(order.status).toBe('COMPLETED')
expect(order.paymentId).toBeDefined()
})
// Mock 방식 - 행위 검증
test('주문 완료 시 결제 API 호출 검증 (Mock)', async () => {
const mockPayment = { charge: jest.fn().mockResolvedValue({ success: true, paymentId: 'PAY-1' }) }
const orderService = new OrderService(mockPayment)
await orderService.placeOrder(items, 'card-token')
// 결제 API가 올바른 인자로 호출되었는지 검증
expect(mockPayment.charge).toHaveBeenCalledWith(totalAmount, 'card-token')
})
3.2 행위 검증의 위험성
Mock을 남용하면 구현 세부 사항에 결합됩니다. 내부 구현이 바뀌면 기능은 동일해도 테스트가 깨집니다.
// 나쁜 예 - 지나친 행위 검증
test('사용자 저장 - 과도한 Mock 사용', async () => {
const mockRepo = {
beginTransaction: jest.fn(),
save: jest.fn(),
commitTransaction: jest.fn(),
closeConnection: jest.fn(),
}
// 내부 구현 순서에 의존 - 리팩토링 시 깨지기 쉬움
expect(mockRepo.beginTransaction).toHaveBeenCalledBefore(mockRepo.save)
})
// 좋은 예 - 의미 있는 행위만 검증
test('사용자 저장 성공', async () => {
const mockRepo = { save: jest.fn().mockResolvedValue(undefined) }
const service = new UserService(mockRepo)
await service.createUser({ name: '홍길동', email: 'test@example.com' })
expect(mockRepo.save).toHaveBeenCalledTimes(1)
})
3.3 "Don't mock what you don't own" 원칙
외부 라이브러리나 서드파티 코드를 직접 Mock하지 말고, 어댑터(Adapter)나 래퍼(Wrapper)를 만들어서 Mock하세요.
// 나쁜 예 - axios를 직접 Mock
jest.mock('axios')
test('API 호출', async () => {
;(axios.get as jest.Mock).mockResolvedValue({ data: { name: '홍길동' } })
// ...
})
// 좋은 예 - 어댑터를 정의하고 어댑터를 Mock
interface HttpClient {
get<T>(url: string): Promise<T>
post<T>(url: string, data: unknown): Promise<T>
}
class AxiosHttpClient implements HttpClient {
async get<T>(url: string): Promise<T> {
const response = await axios.get<T>(url)
return response.data
}
async post<T>(url: string, data: unknown): Promise<T> {
const response = await axios.post<T>(url, data)
return response.data
}
}
// 테스트에서는 HttpClient 인터페이스를 Mock
4. Spy 패턴
4.1 Spy vs Mock
| 구분 | Spy | Mock |
|---|---|---|
| 기대값 설정 시점 | 테스트 후 검증 | 테스트 전 설정 |
| 실제 구현 | 선택적으로 실제 호출 가능 | 항상 모의 동작 |
| 용도 | 실제 동작을 유지하면서 감시 | 완전한 대체 |
4.2 Jest spyOn 예시
// spyOn은 실제 객체의 메서드를 감시
class EmailService {
sendWelcomeEmail(user: User): void {
// 실제 이메일 전송 로직
console.log(`이메일 전송: ${user.email}`)
}
}
test('회원가입 시 환영 이메일 발송', () => {
const emailService = new EmailService()
const spy = jest.spyOn(emailService, 'sendWelcomeEmail')
spy.mockImplementation(() => {}) // 실제 전송 방지
const userService = new UserService(emailService)
userService.register({ name: '홍길동', email: 'hong@example.com' })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ email: 'hong@example.com' }))
spy.mockRestore() // 원래 구현 복원
})
4.3 Go 언어 Spy 패턴
type SpyNotificationService struct {
Calls []NotificationCall
delegate NotificationService // 실제 구현을 래핑
}
type NotificationCall struct {
UserID string
Message string
Type string
}
func (s *SpyNotificationService) Notify(userID, message, notifType string) error {
s.Calls = append(s.Calls, NotificationCall{
UserID: userID,
Message: message,
Type: notifType,
})
if s.delegate != nil {
return s.delegate.Notify(userID, message, notifType)
}
return nil
}
func (s *SpyNotificationService) WasCalledWith(userID, notifType string) bool {
for _, call := range s.Calls {
if call.UserID == userID && call.Type == notifType {
return true
}
}
return false
}
func TestOrderCompletion_SendsNotification(t *testing.T) {
spy := &SpyNotificationService{}
service := NewOrderService(spy)
service.CompleteOrder("user-123", "order-456")
if !spy.WasCalledWith("user-123", "ORDER_COMPLETE") {
t.Error("주문 완료 알림이 발송되지 않음")
}
}
4.4 Java Mockito @Spy
// @Spy는 실제 객체를 감시
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Spy
EmailService emailService = new EmailService();
@InjectMocks
UserService userService;
@Test
void 회원가입_환영메일_발송() {
userService.register(new User("홍길동", "hong@example.com"));
verify(emailService, times(1)).sendWelcomeEmail(
argThat(user -> user.getEmail().equals("hong@example.com"))
);
}
}
5. Fake 객체
5.1 InMemoryDatabase Fake 구현
Fake는 실제 데이터베이스와 같은 동작을 하지만 메모리에 저장합니다. 통합 테스트에서 실제 DB 없이 여러 서비스를 함께 테스트할 수 있습니다.
// Go 언어 InMemory Fake Repository
type InMemoryUserRepository struct {
mu sync.RWMutex
store map[string]*User
seq int64
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{
store: make(map[string]*User),
}
}
func (r *InMemoryUserRepository) FindByID(id string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.store[id]
if !ok {
return nil, ErrUserNotFound
}
// 복사본 반환 (불변성 유지)
copied := *user
return &copied, nil
}
func (r *InMemoryUserRepository) Save(user *User) error {
r.mu.Lock()
defer r.mu.Unlock()
if user.ID == "" {
r.seq++
user.ID = fmt.Sprintf("user-%d", r.seq)
}
copied := *user
r.store[user.ID] = &copied
return nil
}
func (r *InMemoryUserRepository) FindByEmail(email string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, user := range r.store {
if user.Email == email {
copied := *user
return &copied, nil
}
}
return nil, ErrUserNotFound
}
func (r *InMemoryUserRepository) Reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.store = make(map[string]*User)
r.seq = 0
}
5.2 Fake EmailService 구현
class FakeEmailService implements EmailService {
private emails: Email[] = []
async send(email: Email): Promise<void> {
this.emails.push({ ...email, sentAt: new Date() })
}
// 테스트 헬퍼 메서드
getEmailsSentTo(address: string): Email[] {
return this.emails.filter((e) => e.to === address)
}
getLastEmail(): Email | undefined {
return this.emails[this.emails.length - 1]
}
clear(): void {
this.emails = []
}
hasEmailWithSubject(subject: string): boolean {
return this.emails.some((e) => e.subject.includes(subject))
}
}
// 여러 테스트에서 재사용 가능
const fakeEmail = new FakeEmailService()
beforeEach(() => fakeEmail.clear())
test('회원가입 시 환영 이메일 발송', async () => {
const service = new UserService(fakeEmail)
await service.register({ name: '홍길동', email: 'hong@example.com' })
const emails = fakeEmail.getEmailsSentTo('hong@example.com')
expect(emails).toHaveLength(1)
expect(emails[0].subject).toBe('환영합니다!')
})
test('비밀번호 재설정 이메일 발송', async () => {
const service = new UserService(fakeEmail)
await service.requestPasswordReset('hong@example.com')
expect(fakeEmail.hasEmailWithSubject('비밀번호 재설정')).toBe(true)
})
5.3 Fake Clock 구현
interface Clock {
now(): Date
advance(ms: number): void
}
class FakeClock implements Clock {
private currentTime: Date
constructor(initialTime?: Date) {
this.currentTime = initialTime ?? new Date('2026-03-17T09:00:00Z')
}
now(): Date {
return new Date(this.currentTime)
}
advance(ms: number): void {
this.currentTime = new Date(this.currentTime.getTime() + ms)
}
advanceBy(options: { hours?: number; minutes?: number; days?: number }): void {
const ms =
(options.hours ?? 0) * 3600000 +
(options.minutes ?? 0) * 60000 +
(options.days ?? 0) * 86400000
this.advance(ms)
}
}
test('세션 토큰 만료 테스트', () => {
const clock = new FakeClock()
const authService = new AuthService(clock)
const token = authService.createSession('user-123')
expect(authService.isSessionValid(token)).toBe(true)
// 25시간 후
clock.advanceBy({ hours: 25 })
expect(authService.isSessionValid(token)).toBe(false)
})
5.4 Fake vs Mock: 언제 무엇을 선택하는가
Fake를 선택하는 경우:
- 여러 테스트에서 동일한 의존성을 재사용할 때
- 의존성이 복잡한 상태를 갖고 있을 때 (예: 데이터베이스)
- 테스트 간의 상호작용이 중요할 때
- 리팩토링에 견고한 테스트가 필요할 때
Mock을 선택하는 경우:
- 특정 호출 시나리오를 정확히 검증해야 할 때
- 외부 서비스의 부작용(side effect)을 차단해야 할 때
- 단순한 단위 테스트에서 빠르게 설정이 필요할 때
6. 테스트 전략
6.1 테스트 피라미드 (Testing Pyramid)
Mike Cohn이 제안한 고전적인 테스트 전략입니다.
/\
/ \
/ E2E\ 소수, 비용 높음
/------\
/통합 테스트\ 중간
/------------\
/ 단위 테스트 \ 다수, 비용 낮음
/--------------\
- 단위 테스트: 빠르고 격리된 테스트. 테스트 대역을 가장 많이 사용
- 통합 테스트: 여러 컴포넌트 간의 상호작용 테스트
- E2E 테스트: 실제 사용자 시나리오 전체 흐름 테스트
6.2 테스트 트로피 (Testing Trophy - Kent C. Dodds)
React Testing Library 창시자 Kent C. Dodds가 제안한 모델입니다.
/\
/e2e\
/------\
/통합 테스트\ ← 중심
/------------\
/ 단위 테스트 \
/--------------\
/ 정적 타입 검사 \ ← 기반
/------------------\
"가능한 한 통합 테스트를 많이 작성하라"는 철학으로, 테스트 대역을 최소화하고 실제 동작에 더 가까운 테스트를 선호합니다.
"Write tests. Not too many. Mostly integration." - Kent C. Dodds
6.3 테스트 아이스크림 콘 안티패턴
/\
/ \
/ E2E \ 다수 - 느리고 불안정
/--------\
/통합 테스트 \
/------------\
/ 단위 테스트 \ 소수 - 단위 테스트 부족
/----------------\
E2E 테스트에 과도하게 의존하는 안티패턴입니다. 느리고, 불안정하고, 실패 원인 파악이 어렵습니다.
6.4 허니콤 테스트 (Spotify 모델)
Spotify 엔지니어링 팀이 마이크로서비스 환경에서 제안한 모델입니다.
통합 테스트를 중심에 놓고:
- 서비스 내부 통합 테스트 (Service Integration)
- 사용자 여정 테스트 (User Journey)
- 단위 테스트 (Unit)
마이크로서비스 환경에서 서비스 간 계약(Contract)과 내부 통합에 집중합니다.
7. TDD (Test-Driven Development)
7.1 Red-Green-Refactor 사이클
1. RED - 실패하는 테스트 작성
2. GREEN - 테스트를 통과하는 최소한의 코드 작성
3. REFACTOR - 코드 품질 개선 (테스트는 계속 통과)
7.2 Outside-In TDD vs Inside-Out TDD
London School (Outside-In, Mockist)
- 외부 인터페이스부터 시작하여 내부로 진입
- Mock을 적극 활용하여 협력 객체를 정의
- 사용자 시나리오부터 시작하여 하향식으로 설계
Chicago School (Inside-Out, Classicist)
- 내부 도메인 모델부터 시작하여 외부로 확장
- 실제 객체를 최대한 사용, Mock 최소화
- 안정적인 단위 테스트를 먼저 쌓고 통합
7.3 TDD 실전 예시 (Go로 FizzBuzz)
// 1단계: 실패하는 테스트 작성 (RED)
func TestFizzBuzz(t *testing.T) {
tests := []struct {
input int
expected string
}{
{1, "1"},
{3, "Fizz"},
{5, "Buzz"},
{15, "FizzBuzz"},
{9, "Fizz"},
{10, "Buzz"},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("input=%d", tt.input), func(t *testing.T) {
got := FizzBuzz(tt.input)
if got != tt.expected {
t.Errorf("FizzBuzz(%d) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// 2단계: 최소한의 코드로 테스트 통과 (GREEN)
func FizzBuzz(n int) string {
switch {
case n%15 == 0:
return "FizzBuzz"
case n%3 == 0:
return "Fizz"
case n%5 == 0:
return "Buzz"
default:
return strconv.Itoa(n)
}
}
// 3단계: 리팩토링 (REFACTOR) - 필요 시 수행
7.4 쇼핑카트 TDD 예시
// 쇼핑카트 도메인 - TDD로 개발
func TestCart_AddItem(t *testing.T) {
cart := NewCart()
item := Item{ID: "book-1", Name: "클린코드", Price: 25000}
cart.AddItem(item, 2)
if cart.TotalItems() != 2 {
t.Errorf("expected 2 items, got %d", cart.TotalItems())
}
if cart.TotalPrice() != 50000 {
t.Errorf("expected 50000, got %d", cart.TotalPrice())
}
}
func TestCart_ApplyDiscount(t *testing.T) {
cart := NewCart()
cart.AddItem(Item{ID: "item-1", Price: 10000}, 3)
cart.ApplyDiscount(0.1) // 10% 할인
if cart.TotalPrice() != 27000 {
t.Errorf("expected 27000 after 10%% discount, got %d", cart.TotalPrice())
}
}
8. BDD (Behavior-Driven Development)
8.1 Given-When-Then 구조
BDD는 TDD를 비즈니스 관점에서 확장한 방법론입니다. 자연어에 가까운 형식으로 테스트를 작성합니다.
Given (주어진 조건): 초기 컨텍스트 또는 전제 조건
When (행위): 사용자나 시스템의 동작
Then (결과): 기대하는 결과
8.2 Cucumber/Gherkin 문법
Feature: 쇼핑카트 결제
As a 쇼핑몰 회원
I want to 상품을 장바구니에 담고 결제하기
So that 상품을 구매할 수 있다
Scenario: 정상 결제
Given 장바구니에 "클린코드" 책이 1권 담겨 있다
And 결제 카드가 등록되어 있다
When 결제 버튼을 누른다
Then 주문이 완료된다
And 주문 확인 이메일이 발송된다
Scenario: 재고 부족 결제 실패
Given 장바구니에 "품절 상품"이 1개 담겨 있다
When 결제 버튼을 누른다
Then "재고가 부족합니다" 에러가 표시된다
And 주문이 생성되지 않는다
8.3 TypeScript BDD 스타일 (Jest)
describe('쇼핑카트 결제', () => {
describe('정상 결제', () => {
it('should complete order and send confirmation email', async () => {
// Given
const fakeEmail = new FakeEmailService()
const fakePayment = new FakePaymentGateway()
const cart = new Cart()
cart.addItem({ id: 'book-1', name: '클린코드', price: 25000 })
const orderService = new OrderService(fakePayment, fakeEmail)
// When
const order = await orderService.checkout(cart, 'card-token-123')
// Then
expect(order.status).toBe('COMPLETED')
expect(fakeEmail.hasEmailWithSubject('주문 완료')).toBe(true)
})
})
describe('재고 부족', () => {
it('should fail with out-of-stock error', async () => {
// Given
const stubInventory = new StubInventoryService()
stubInventory.setOutOfStock('out-of-stock-item')
const cart = new Cart()
cart.addItem({ id: 'out-of-stock-item', price: 10000 })
const orderService = new OrderService(
new FakePaymentGateway(),
new FakeEmailService(),
stubInventory
)
// When & Then
await expect(orderService.checkout(cart, 'card-token')).rejects.toThrow('재고가 부족합니다')
})
})
})
8.4 Java JBehave 개념
// JBehave Story 파일: order_checkout.story
// Scenario: 정상 결제
//
// Given 장바구니에 상품이 1개 있다
// When 결제를 요청한다
// Then 주문이 완료된다
// Step 구현 클래스
public class OrderCheckoutSteps {
private Cart cart;
private Order completedOrder;
@Given("장바구니에 상품이 1개 있다")
public void givenCartWithOneItem() {
cart = new Cart();
cart.addItem(new Item("item-1", "테스트 상품", 10000));
}
@When("결제를 요청한다")
public void whenCheckoutRequested() {
completedOrder = orderService.checkout(cart, "test-card-token");
}
@Then("주문이 완료된다")
public void thenOrderCompleted() {
assertThat(completedOrder.getStatus()).isEqualTo("COMPLETED");
}
}
9. Contract Testing
9.1 Contract Testing이란
마이크로서비스 환경에서 서비스 간의 **API 계약(Contract)**을 테스트하는 기법입니다. 실제 서비스를 구동하지 않고 계약만 검증합니다.
9.2 Pact.io - Consumer-Driven Contract Testing
// Consumer 테스트 (주문 서비스가 사용자 서비스 API를 소비)
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService',
dir: './pacts',
})
describe('사용자 서비스 Contract', () => {
test('사용자 정보 조회', () => {
return provider
.addInteraction({
states: [{ description: 'user-123이 존재한다' }],
uponReceiving: 'user-123 조회 요청',
withRequest: {
method: 'GET',
path: '/users/user-123',
},
willRespondWith: {
status: 200,
body: {
id: MatchersV3.string('user-123'),
name: MatchersV3.string('홍길동'),
email: MatchersV3.email('hong@example.com'),
},
},
})
.executeTest(async (mockServer) => {
const client = new UserServiceClient(mockServer.url)
const user = await client.getUser('user-123')
expect(user.id).toBe('user-123')
expect(user.name).toBe('홍길동')
})
})
})
9.3 Provider Verification
// Provider 쪽에서 Consumer가 정의한 계약을 검증
import { Verifier } from '@pact-foundation/pact'
test('UserService가 OrderService의 계약을 충족한다', () => {
return new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactUrls: ['./pacts/OrderService-UserService.json'],
stateHandlers: {
'user-123이 존재한다': async () => {
await testDatabase.seed({ id: 'user-123', name: '홍길동', email: 'hong@example.com' })
},
},
}).verifyProvider()
})
10. 퀴즈
퀴즈 1: Stub과 Mock의 가장 큰 차이점은 무엇인가요?
정답: 검증 방식의 차이입니다. Stub은 **상태 검증(State Verification)**에 사용되고, Mock은 **행위 검증(Behavior Verification)**에 사용됩니다.
설명: Stub은 테스트 대상 객체의 최종 상태(반환값, 속성 변경 등)를 검증합니다. Mock은 협력 객체(의존성)가 어떻게 호출되었는지(호출 횟수, 인자 등)를 검증합니다. Martin Fowler는 "Mocks Aren't Stubs" 아티클에서 이 차이를 명확히 설명합니다.
퀴즈 2: "Don't mock what you don't own" 원칙이 의미하는 것은?
정답: 외부 라이브러리나 서드파티 코드를 직접 Mock하지 말고, 그것을 감싸는 어댑터나 래퍼 인터페이스를 만들어서 Mock해야 한다는 원칙입니다.
설명: 외부 라이브러리를 직접 Mock하면 라이브러리 API 변경 시 테스트가 깨질 수 있고, Mock이 라이브러리의 실제 동작을 정확히 반영하지 못할 수 있습니다. 어댑터 패턴으로 추상화하면 테스트 안정성이 높아지고 교체도 쉬워집니다.
퀴즈 3: Fake 객체와 Stub의 차이점은 무엇인가요?
정답: Fake는 실제로 동작하는 단순화된 구현이고, Stub은 미리 설정된 값을 반환하는 객체입니다.
설명: FakeUserRepository는 인메모리 맵을 사용해 실제로 CRUD를 수행합니다. 반면 StubUserRepository는 특정 ID에 대해 하드코딩된 값을 반환할 뿐입니다. Fake는 여러 테스트에서 재사용 가능하고 복잡한 상태를 갖는 의존성에 적합합니다.
퀴즈 4: TDD의 London School(Outside-In)과 Chicago School(Inside-Out)의 차이는?
정답: London School은 외부 인터페이스부터 Mock을 적극 활용하여 하향식으로 개발하고, Chicago School은 내부 도메인 모델부터 실제 객체를 사용하여 상향식으로 개발합니다.
설명: London School(Mockist)은 아직 구현되지 않은 협력 객체를 Mock으로 먼저 정의하고 설계를 유도합니다. Chicago School(Classicist)은 도메인 모델을 견고하게 먼저 만들고 외부로 확장합니다. 둘 다 유효한 접근법이며 상황에 따라 선택합니다.
퀴즈 5: 테스트 트로피(Testing Trophy)가 테스트 피라미드와 다른 핵심 주장은?
정답: 테스트 트로피는 통합 테스트를 가장 많이 작성해야 한다고 주장합니다. 단순히 단위 테스트를 많이 쌓는 피라미드보다, 실제 사용 방식에 가까운 통합 테스트가 더 가치 있다고 봅니다.
설명: Kent C. Dodds는 "Write tests. Not too many. Mostly integration."을 강조합니다. 지나친 Mock 사용으로 단위 테스트가 실제 동작을 보장하지 못하는 문제를 해결하기 위해, 실제 의존성을 더 많이 사용하는 통합 테스트를 중심에 두어야 한다고 주장합니다.