Skip to content
Published on

소프트웨어 테스팅 패턴 완벽 가이드: Stub, Mock, Spy, Fake

Authors

개요

소프트웨어 테스트를 작성할 때 가장 많이 혼동되는 개념이 바로 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" 아티클에서 명확히 구분합니다:

구분StubMock
검증 방식상태 검증 (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

구분SpyMock
기대값 설정 시점테스트 후 검증테스트 전 설정
실제 구현선택적으로 실제 호출 가능항상 모의 동작
용도실제 동작을 유지하면서 감시완전한 대체

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 사용으로 단위 테스트가 실제 동작을 보장하지 못하는 문제를 해결하기 위해, 실제 의존성을 더 많이 사용하는 통합 테스트를 중심에 두어야 한다고 주장합니다.