Split View: 소프트웨어 테스팅 패턴 완벽 가이드: Stub, Mock, Spy, Fake
소프트웨어 테스팅 패턴 완벽 가이드: Stub, Mock, Spy, Fake
개요
소프트웨어 테스트를 작성할 때 가장 많이 혼동되는 개념이 바로 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 사용으로 단위 테스트가 실제 동작을 보장하지 못하는 문제를 해결하기 위해, 실제 의존성을 더 많이 사용하는 통합 테스트를 중심에 두어야 한다고 주장합니다.
Software Testing Patterns Complete Guide: Stub, Mock, Spy, Fake
Overview
When writing software tests, the most commonly confused concepts are Stub, Mock, Spy, and Fake. These terms are often used interchangeably, but according to the Test Double taxonomy established by Gerard Meszaros in his book "xUnit Test Patterns," each plays a distinctly different role.
This guide covers the precise meaning of each test double, when to use them, language-specific implementation examples, and testing strategies including TDD and BDD.
1. The 5 Types of Test Doubles
A Test Double is a term borrowed from film production, where a stunt double steps in for dangerous scenes. In software, a test double is any object that replaces a real dependency to make testing possible.
Gerard Meszaros's Taxonomy
Test Double
├── Dummy Object (passed but never used)
├── Test Stub (returns canned answers - state verification)
├── Test Spy (records how it was called)
├── Mock Object (pre-programmed with expectations - behavior verification)
└── Fake Object (working implementation unsuitable for production)
1.1 Dummy Object
A Dummy is passed to satisfy method signatures but is never actually used during the test.
// Example: Logger is required but not relevant to the test
class DummyLogger implements Logger {
log(message: string): void {}
error(message: string): void {}
warn(message: string): void {}
}
test('user creation - email validation', () => {
const dummyLogger = new DummyLogger()
const userService = new UserService(dummyLogger)
expect(userService.validateEmail('test@example.com')).toBe(true)
})
Dummies are used purely to avoid null pointer exceptions or compilation errors.
1.2 Stub
A Stub returns canned (pre-programmed) answers to calls. It only returns what you set up and performs no other behavior. Stubs are used for State Verification.
// UserRepository Stub
class StubUserRepository implements UserRepository {
findById(id: string): User | null {
if (id === 'user-123') {
return { id: 'user-123', name: 'John Doe', email: 'john@example.com' }
}
return null
}
save(user: User): void {}
}
test('user retrieval service', () => {
const stub = new StubUserRepository()
const service = new UserService(stub)
const user = service.getUser('user-123')
expect(user?.name).toBe('John Doe')
})
1.3 Spy
A Spy wraps a real implementation or acts like a Stub, while also recording call information so you can verify later how it was called.
class SpyEmailService implements EmailService {
public sentEmails: Array<{ to: string; subject: string }> = []
sendEmail(to: string, subject: string, body: string): void {
this.sentEmails.push({ to, subject })
// No actual sending
}
}
test('email sent on order completion', () => {
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
A Mock is an object with pre-programmed expectations. After the test executes, the mock automatically verifies that it was called as expected. Mocks are used for Behavior Verification.
While a Spy records and you assert after the fact, a Mock pre-declares what it expects and fails immediately if the expectation is not met.
// Jest Mock example
test('email sent exactly once on order completion', () => {
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',
'Order Complete',
expect.stringContaining('order-456')
)
})
1.5 Fake Object
A Fake has a working implementation but is not suitable for production. Examples include in-memory databases, in-memory queues, and file-based storage.
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('prevent duplicate email registration', () => {
const fake = new FakeUserRepository()
const service = new UserService(fake)
service.register({ id: '1', name: 'John Doe', email: 'test@example.com' })
expect(() => service.register({ id: '2', name: 'Jane Doe', email: 'test@example.com' })).toThrow(
'Email is already in use'
)
})
2. Stub In Depth
2.1 When to Use a Stub
Stubs are valuable in these situations:
- Depends on an external service (payment API, SMS provider)
- Returns non-deterministic results (current time, random values)
- Slow dependencies (database queries, network calls)
- Reproducing specific error conditions
2.2 In-Memory Repository Stub Pattern
// Go language example
package repository
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
// Test 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 Time Stub (Clock Interface)
Code that depends on time is notoriously hard to test. Abstract it behind a Clock interface.
// Clock interface
type Clock interface {
Now() time.Time
}
// Production implementation
type SystemClock struct{}
func (c *SystemClock) Now() time.Time {
return time.Now()
}
// Test 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
}
// Usage
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")
// Check expiry 2 hours later
expiredClock := NewStubClock(fixedTime.Add(2 * time.Hour))
tokenService2 := NewTokenService(expiredClock)
if tokenService2.IsValid(token) {
t.Error("expired token considered valid")
}
}
2.4 External API Stub (TypeScript)
// Payment gateway interface
interface PaymentGateway {
charge(amount: number, cardToken: string): Promise<PaymentResult>
refund(paymentId: string): Promise<RefundResult>
}
// Test 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) example
// @ExtendWith(MockitoExtension.class)
// class OrderServiceTest {
// @Mock
// PaymentGateway paymentGateway;
//
// @Test
// void should_cancel_order_on_payment_failure() {
// when(paymentGateway.charge(anyDouble(), anyString()))
// .thenReturn(new PaymentResult(false, "CARD_DECLINED"));
// ...
// }
// }
3. Mock In Depth
3.1 Mock vs Stub: The Key Distinction
This distinction is crucial. Martin Fowler's article "Mocks Aren't Stubs" clearly separates the two:
| Aspect | Stub | Mock |
|---|---|---|
| Verification type | State Verification | Behavior Verification |
| Assertion target | State of the system under test | Calls made on the double |
| Expectation setup | Returns values | Pre-declares expected calls |
| Failure timing | After test assertions | On expectation violation |
// Stub approach - state verification
test('order completion state (Stub)', async () => {
const stubPayment = new StubPaymentGateway()
const orderService = new OrderService(stubPayment)
const order = await orderService.placeOrder(items, cardToken)
// Verify the state of the returned object
expect(order.status).toBe('COMPLETED')
expect(order.paymentId).toBeDefined()
})
// Mock approach - behavior verification
test('payment API called on order completion (Mock)', async () => {
const mockPayment = { charge: jest.fn().mockResolvedValue({ success: true, paymentId: 'PAY-1' }) }
const orderService = new OrderService(mockPayment)
await orderService.placeOrder(items, 'card-token')
// Verify that the payment API was called with correct arguments
expect(mockPayment.charge).toHaveBeenCalledWith(totalAmount, 'card-token')
})
3.2 The Danger of Over-Mocking
Overusing Mocks couples your tests to implementation details. If internal implementation changes while behavior stays the same, tests break.
// Bad - over-verifying implementation internals
test('save user - excessive mocking', async () => {
const mockRepo = {
beginTransaction: jest.fn(),
save: jest.fn(),
commitTransaction: jest.fn(),
closeConnection: jest.fn(),
}
// Depends on internal call order - fragile under refactoring
expect(mockRepo.beginTransaction).toHaveBeenCalledBefore(mockRepo.save)
})
// Good - verify only meaningful behavior
test('user save succeeds', async () => {
const mockRepo = { save: jest.fn().mockResolvedValue(undefined) }
const service = new UserService(mockRepo)
await service.createUser({ name: 'John Doe', email: 'john@example.com' })
expect(mockRepo.save).toHaveBeenCalledTimes(1)
})
3.3 The "Don't Mock What You Don't Own" Principle
Never mock third-party libraries directly. Instead, create an adapter or wrapper interface that you control and mock that.
// Bad - mocking axios directly
jest.mock('axios')
test('API call', async () => {
;(axios.get as jest.Mock).mockResolvedValue({ data: { name: 'John Doe' } })
// ...
})
// Good - define an adapter and mock the adapter
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
}
}
// In tests, mock the HttpClient interface
4. The Spy Pattern
4.1 Spy vs Mock
| Aspect | Spy | Mock |
|---|---|---|
| Expectation setup | After-the-fact assertions | Pre-declared expectations |
| Real implementation | Optionally delegates to real | Always returns doubles |
| Use case | Watch real behavior | Full replacement |
4.2 Jest spyOn Example
// spyOn watches methods on real objects
class EmailService {
sendWelcomeEmail(user: User): void {
console.log(`Sending email to: ${user.email}`)
}
}
test('welcome email sent on registration', () => {
const emailService = new EmailService()
const spy = jest.spyOn(emailService, 'sendWelcomeEmail')
spy.mockImplementation(() => {}) // Prevent actual sending
const userService = new UserService(emailService)
userService.register({ name: 'John Doe', email: 'john@example.com' })
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ email: 'john@example.com' }))
spy.mockRestore() // Restore original implementation
})
4.3 Go Language Spy Pattern
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("order completion notification not sent")
}
}
4.4 Java Mockito @Spy
// @Spy observes a real object
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Spy
EmailService emailService = new EmailService();
@InjectMocks
UserService userService;
@Test
void should_send_welcome_email_on_registration() {
userService.register(new User("John Doe", "john@example.com"));
verify(emailService, times(1)).sendWelcomeEmail(
argThat(user -> user.getEmail().equals("john@example.com"))
);
}
}
5. Fake Objects
5.1 InMemoryDatabase Fake Implementation
A Fake behaves like the real database but stores data in memory. This lets you run integration tests across multiple services without a real database.
// Go in-memory 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 Implementation
class FakeEmailService implements EmailService {
private emails: Email[] = []
async send(email: Email): Promise<void> {
this.emails.push({ ...email, sentAt: new Date() })
}
// Test helper methods
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('welcome email sent on registration', async () => {
const service = new UserService(fakeEmail)
await service.register({ name: 'John Doe', email: 'john@example.com' })
const emails = fakeEmail.getEmailsSentTo('john@example.com')
expect(emails).toHaveLength(1)
expect(emails[0].subject).toBe('Welcome!')
})
test('password reset email sent', async () => {
const service = new UserService(fakeEmail)
await service.requestPasswordReset('john@example.com')
expect(fakeEmail.hasEmailWithSubject('Password Reset')).toBe(true)
})
5.3 Fake Clock Implementation
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('session token expiry', () => {
const clock = new FakeClock()
const authService = new AuthService(clock)
const token = authService.createSession('user-123')
expect(authService.isSessionValid(token)).toBe(true)
// 25 hours later
clock.advanceBy({ hours: 25 })
expect(authService.isSessionValid(token)).toBe(false)
})
5.4 Fake vs Mock: When to Choose Which
Choose Fake when:
- The same dependency is reused across many tests
- The dependency has complex stateful behavior (e.g., a database)
- Interactions between tests matter
- You want tests that are resilient to refactoring
Choose Mock when:
- You need to precisely verify a specific call scenario
- You need to prevent side effects of external services
- You need quick setup in a simple unit test
6. Testing Strategies
6.1 The Testing Pyramid
The classic model proposed by Mike Cohn.
/\
/ \
/ E2E\ Few, expensive
/------\
/Integrat.\ Moderate
/------------\
/ Unit Tests \ Many, cheap
/--------------\
- Unit Tests: Fast and isolated. Test doubles used heavily.
- Integration Tests: Test interactions between components.
- E2E Tests: Full user journey tests.
6.2 The Testing Trophy (Kent C. Dodds)
Proposed by Kent C. Dodds, creator of React Testing Library.
/\
/e2e\
/------\
/integrat.\ <- Focus here
/------------\
/ unit tests \
/--------------\
/ static analysis\ <- Foundation
/------------------\
The philosophy is "write more integration tests" — minimize test doubles and test closer to real behavior.
"Write tests. Not too many. Mostly integration." - Kent C. Dodds
6.3 The Ice Cream Cone Anti-Pattern
/\
/ \
/ E2E \ Many - slow and fragile
/--------\
/Integrat. \
/------------\
/ Unit Tests \ Few - insufficient unit coverage
/----------------\
This anti-pattern over-relies on E2E tests: slow, brittle, and hard to debug.
6.4 The Honeycomb (Spotify Model)
Proposed by Spotify engineering for microservices environments.
Focus on integration tests at the center:
- Service integration tests (within a service)
- User journey tests (across services)
- Unit tests (business logic isolation)
Emphasizes service contracts and internal integration over excessive mocking.
7. TDD (Test-Driven Development)
7.1 Red-Green-Refactor Cycle
1. RED - Write a failing test
2. GREEN - Write the minimum code to make it pass
3. REFACTOR - Improve code quality (tests must stay green)
7.2 Outside-In TDD vs Inside-Out TDD
London School (Outside-In, Mockist)
- Start from the external interface, work inward
- Use Mocks extensively to define collaborators
- Top-down design driven by user scenarios
Chicago School (Inside-Out, Classicist)
- Start from the domain model, work outward
- Use real objects as much as possible, minimize Mocks
- Build stable unit tests first, then integrate
7.3 TDD Example in Go: FizzBuzz
// Step 1: Write failing tests (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)
}
})
}
}
// Step 2: Minimal code to make tests pass (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)
}
}
// Step 3: Refactor if needed (REFACTOR)
7.4 Shopping Cart TDD Example
func TestCart_AddItem(t *testing.T) {
cart := NewCart()
item := Item{ID: "book-1", Name: "Clean Code", Price: 2500}
cart.AddItem(item, 2)
if cart.TotalItems() != 2 {
t.Errorf("expected 2 items, got %d", cart.TotalItems())
}
if cart.TotalPrice() != 5000 {
t.Errorf("expected 5000, 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% discount
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 Structure
BDD extends TDD from a business perspective. Tests are written in a near-natural-language format.
Given (preconditions): the initial context or setup
When (action): an action performed by the user or system
Then (outcome): the expected result
8.2 Cucumber/Gherkin Syntax
Feature: Shopping Cart Checkout
As a registered customer
I want to add items to my cart and pay
So that I can purchase products
Scenario: Successful checkout
Given my cart contains 1 copy of "Clean Code"
And a valid payment card is registered
When I click the checkout button
Then my order is completed
And an order confirmation email is sent
Scenario: Checkout fails due to out-of-stock
Given my cart contains 1 "Out of Stock Item"
When I click the checkout button
Then I see the error "Item is out of stock"
And no order is created
8.3 TypeScript BDD Style (Jest)
describe('Shopping Cart Checkout', () => {
describe('Successful checkout', () => {
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: 'Clean Code', price: 2500 })
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('Order Confirmation')).toBe(true)
})
})
describe('Out of stock', () => {
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: 1000 })
const orderService = new OrderService(
new FakePaymentGateway(),
new FakeEmailService(),
stubInventory
)
// When & Then
await expect(orderService.checkout(cart, 'card-token')).rejects.toThrow(
'Item is out of stock'
)
})
})
})
8.4 Java JBehave Concept
// JBehave Story file: order_checkout.story
// Scenario: Successful checkout
//
// Given the cart contains 1 item
// When checkout is requested
// Then the order is completed
// Step implementation
public class OrderCheckoutSteps {
private Cart cart;
private Order completedOrder;
@Given("the cart contains 1 item")
public void givenCartWithOneItem() {
cart = new Cart();
cart.addItem(new Item("item-1", "Test Product", 1000));
}
@When("checkout is requested")
public void whenCheckoutRequested() {
completedOrder = orderService.checkout(cart, "test-card-token");
}
@Then("the order is completed")
public void thenOrderCompleted() {
assertThat(completedOrder.getStatus()).isEqualTo("COMPLETED");
}
}
9. Contract Testing
9.1 What Is Contract Testing?
In microservices architectures, Contract Testing verifies the API contracts between services without running all services simultaneously.
9.2 Pact.io - Consumer-Driven Contract Testing
// Consumer test (Order Service consuming User Service API)
import { PactV3, MatchersV3 } from '@pact-foundation/pact'
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService',
dir: './pacts',
})
describe('UserService Contract', () => {
test('get user by ID', () => {
return provider
.addInteraction({
states: [{ description: 'user-123 exists' }],
uponReceiving: 'a request for user-123',
withRequest: {
method: 'GET',
path: '/users/user-123',
},
willRespondWith: {
status: 200,
body: {
id: MatchersV3.string('user-123'),
name: MatchersV3.string('John Doe'),
email: MatchersV3.email('john@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('John Doe')
})
})
})
9.3 Provider Verification
// The provider verifies the consumer's contract
import { Verifier } from '@pact-foundation/pact'
test('UserService satisfies OrderService contract', () => {
return new Verifier({
providerBaseUrl: 'http://localhost:3001',
pactUrls: ['./pacts/OrderService-UserService.json'],
stateHandlers: {
'user-123 exists': async () => {
await testDatabase.seed({ id: 'user-123', name: 'John Doe', email: 'john@example.com' })
},
},
}).verifyProvider()
})
10. Quiz
Quiz 1: What is the most important difference between a Stub and a Mock?
Answer: The type of verification. A Stub is used for State Verification, while a Mock is used for Behavior Verification.
Explanation: A Stub verifies the final state of the system under test (return values, property changes, etc.). A Mock verifies how a collaborator object (dependency) was called (call count, arguments, etc.). Martin Fowler's article "Mocks Aren't Stubs" clearly explains this distinction.
Quiz 2: What does the "Don't mock what you don't own" principle mean?
Answer: You should never directly mock external libraries or third-party code. Instead, create an adapter or wrapper interface that you own and mock that.
Explanation: Mocking external libraries directly ties your tests to the library's API. Any library upgrade that changes the API can break tests, and your mock may not accurately reflect the library's real behavior. Using the adapter pattern improves test stability and makes the dependency replaceable.
Quiz 3: What is the difference between a Fake and a Stub?
Answer: A Fake has a real, working simplified implementation, whereas a Stub returns pre-configured values.
Explanation: A FakeUserRepository actually performs CRUD operations using an in-memory map. A StubUserRepository just returns hardcoded values for specific IDs. Fakes are reusable across many tests and suited for dependencies with complex stateful behavior.
Quiz 4: What is the difference between the London School and Chicago School of TDD?
Answer: The London School (Mockist) starts from the external interface, using Mocks heavily for top-down design. The Chicago School (Classicist) starts from the internal domain model, using real objects for bottom-up design.
Explanation: The London School pre-defines collaborating objects with Mocks before they are implemented, driving design from the outside. The Chicago School builds a solid domain model first and integrates outward. Both are valid approaches used depending on context.
Quiz 5: What is the core argument of the Testing Trophy compared to the Testing Pyramid?
Answer: The Testing Trophy argues that integration tests should be the majority. Rather than accumulating unit tests, tests that are closer to real usage provide more value.
Explanation: Kent C. Dodds emphasizes "Write tests. Not too many. Mostly integration." Excessive use of Mocks in unit tests can lead to tests that don't actually guarantee real behavior. The Trophy places integration tests at the center to test closer to how the software is actually used.