Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

개요

소프트웨어 테스트를 작성할 때 가장 많이 혼동되는 개념이 바로 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를 소비)

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가 정의한 계약을 검증

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. 퀴즈

**정답**: 검증 방식의 차이입니다. Stub은 **상태 검증(State Verification)**에 사용되고, Mock은 **행위 검증(Behavior Verification)**에 사용됩니다.

**설명**: Stub은 테스트 대상 객체의 최종 상태(반환값, 속성 변경 등)를 검증합니다. Mock은 협력 객체(의존성)가 어떻게 호출되었는지(호출 횟수, 인자 등)를 검증합니다. Martin Fowler는 "Mocks Aren't Stubs" 아티클에서 이 차이를 명확히 설명합니다.

**정답**: 외부 라이브러리나 서드파티 코드를 직접 Mock하지 말고, 그것을 감싸는 **어댑터나 래퍼 인터페이스**를 만들어서 Mock해야 한다는 원칙입니다.

**설명**: 외부 라이브러리를 직접 Mock하면 라이브러리 API 변경 시 테스트가 깨질 수 있고, Mock이 라이브러리의 실제 동작을 정확히 반영하지 못할 수 있습니다. 어댑터 패턴으로 추상화하면 테스트 안정성이 높아지고 교체도 쉬워집니다.

**정답**: Fake는 **실제로 동작하는 단순화된 구현**이고, Stub은 **미리 설정된 값을 반환하는 객체**입니다.

**설명**: FakeUserRepository는 인메모리 맵을 사용해 실제로 CRUD를 수행합니다. 반면 StubUserRepository는 특정 ID에 대해 하드코딩된 값을 반환할 뿐입니다. Fake는 여러 테스트에서 재사용 가능하고 복잡한 상태를 갖는 의존성에 적합합니다.

**정답**: London School은 **외부 인터페이스부터 Mock을 적극 활용하여 하향식**으로 개발하고, Chicago School은 **내부 도메인 모델부터 실제 객체를 사용하여 상향식**으로 개발합니다.

**설명**: London School(Mockist)은 아직 구현되지 않은 협력 객체를 Mock으로 먼저 정의하고 설계를 유도합니다. Chicago School(Classicist)은 도메인 모델을 견고하게 먼저 만들고 외부로 확장합니다. 둘 다 유효한 접근법이며 상황에 따라 선택합니다.

**정답**: 테스트 트로피는 **통합 테스트를 가장 많이 작성**해야 한다고 주장합니다. 단순히 단위 테스트를 많이 쌓는 피라미드보다, 실제 사용 방식에 가까운 통합 테스트가 더 가치 있다고 봅니다.

**설명**: Kent C. Dodds는 "Write tests. Not too many. Mostly integration."을 강조합니다. 지나친 Mock 사용으로 단위 테스트가 실제 동작을 보장하지 못하는 문제를 해결하기 위해, 실제 의존성을 더 많이 사용하는 통합 테스트를 중심에 두어야 한다고 주장합니다.

현재 단락 (1/706)

소프트웨어 테스트를 작성할 때 가장 많이 혼동되는 개념이 바로 Stub, Mock, Spy, Fake입니다. 이 용어들은 종종 혼용되지만, Gerard Meszaros가 저서 "x...

작성 글자: 0원문 글자: 19,285작성 단락: 0/706