TDD를 부숴주마 - 어떻게 테스트 하는가?

응큼한포도·2026년 2월 13일

TDD를 부숴주마

목록 보기
4/6

테스트는 구현체와 관계가 없다

테스트는 “구현체”가 아니라 계약(인터페이스) 을 검증한다.
그래서 계약만 잘 정의되어 있으면 구현체가 아직 없어도 테스트를 먼저 만들 수 있다.

관계는 이렇다.

  • 인터페이스(계약) --- 테스트(검증서)
  • 인터페이스(계약) --- 구현체(실제 동작)

즉, 테스트는 구현체가 아니라 인터페이스에 의존한다.


TDD

테스트가 인터페이스에 의존하니까, 구현체보다 테스트를 먼저 만들어도 된다.
이게 TDD의 모든 것이다


계약 테스트는 무엇을 만족해야 하나?

계약 테스트는 조건이 두 개다.

  1. 계약을 검증해야 한다.
  2. 구현체에 의존하면 안 된다.

2번을 만족시키려면 어떻게 하냐?

우리가 평소에 구체 클래스 의존을 끊을 때 쓰는 방법 그대로 쓰면 된다.

  • 추상화(인터페이스/추상 클래스)
  • DI(구현체를 밖에서 주입)

핵심은 이거다.

계약 테스트 템플릿은 구현체를 new 하지 않는다.
구현체는 밖에서 주입(DI) 한다.


예제: Checkout(주문 결제)

계약(비즈니스 규칙)

  • pay(wallet, price)는 결제를 시도한다.
  • 잔액이 충분하면 결제는 성공(true) 하고, 잔액이 price만큼 감소한다.
  • 잔액이 부족하면 결제는 실패(false) 하고, 잔액은 변하지 않는다.

검증 목록

  • 검증 1) 잔액이 충분하면 pay == true 이고, 잔액 == 기존잔액 - price
  • 검증 2) 잔액이 부족하면 pay == false 이고, 잔액 == 기존잔액
  • 검증 3) price <= 0 이면 예외
  • 검증 4) 연속 결제에서도 규칙 유지 (성공 -> 감소, 실패 -> 불변)

계약(인터페이스)

public interface Checkout {
    boolean pay(Wallet wallet, int price);
}

public interface Wallet {
    int balance();
    void withdraw(int amount);
}

NOTE: Wallet을 실제 구현체/스텁/페이크/목 중 무엇으로 대체할지는 중요하지만
이 글의 주제는 “인터페이스 vs 추상 클래스 + DI 구조”라서, 더블 전략은 다음 파트로 넘긴다.


계약 테스트를 재사용하는 2가지 방법

둘 다 목표는 동일하다.

계약 검증 로직은 한 번만 작성하고,
구현체가 바뀌면 주입만 바꿔 끼운다(DI).

차이는 “테스트 템플릿을 무엇으로 만들까”다.


1) 추상 클래스 기반 (픽스처/셋업이 쉬움)

왜 쓰나?

  • 추상 클래스는 값을 가질 수 있다.
    그래서 공통 픽스처(예: price)나 공통 셋업을 protected final 필드로 들고 갈 수 있다.
  • 계약 테스트(검증 1~4)는 템플릿에 한 번만 쓴다.
  • 구현체별 테스트 클래스는 createCheckout()만 오버라이드해서 구현체를 주입(DI) 한다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

public abstract class CheckoutContractTest_Abstract {

    // DI 포인트: 구현체 주입
    protected abstract Checkout createCheckout();

    // 공통 값(픽스처)을 필드로 보유 가능
    protected final int price = 500;

    // Wallet 준비도 DI로 밀어둔다 (대체 전략은 뒤 파트에서)
    protected abstract Wallet createWallet(int initialBalance);

    @Test
    @DisplayName("검증 1) 잔액이 충분하면 pay == true 이고 잔액 == 기존잔액 - price")
    void succeeds_and_decreases_balance_when_enough_funds() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(2000);

        boolean ok = sut.pay(wallet, price);

        assertTrue(ok);
        assertEquals(1500, wallet.balance());
    }

    @Test
    @DisplayName("검증 2) 잔액이 부족하면 pay == false 이고 잔액 == 기존잔액")
    void fails_and_does_not_change_balance_when_insufficient_funds() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(300);
        int before = wallet.balance();

        boolean ok = sut.pay(wallet, price);

        assertFalse(ok);
        assertEquals(before, wallet.balance());
    }

    @Test
    @DisplayName("검증 3) price <= 0 이면 예외")
    void throws_when_price_is_zero_or_negative() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(1000);

        assertThrows(IllegalArgumentException.class, () -> sut.pay(wallet, 0));
        assertThrows(IllegalArgumentException.class, () -> sut.pay(wallet, -1));
    }

    @Test
    @DisplayName("검증 4) 연속 결제에서도 규칙이 유지된다 (성공 -> 잔액 감소, 실패 -> 잔액 불변)")
    void remains_consistent_across_repeated_payments() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(1000);

        assertTrue(sut.pay(wallet, 400));
        assertEquals(600, wallet.balance());

        assertTrue(sut.pay(wallet, 400));
        assertEquals(200, wallet.balance());

        int before = wallet.balance();
        assertFalse(sut.pay(wallet, 400));
        assertEquals(before, wallet.balance());
    }
}

구현체별 테스트 클래스는 “주입만” 담당한다.

public class SimpleCheckoutContractTest_Abstract extends CheckoutContractTest_Abstract {

    @Override
    protected Checkout createCheckout() {
        return new SimpleCheckout(); // 구현체 주입
    }

    @Override
    protected Wallet createWallet(int initialBalance) {
        return new DemoWallet(initialBalance); // (대체 전략은 뒤 파트에서)
    }
}

2) 인터페이스 기반 (다중 계약 조합이 쉬움)

왜 쓰나?

  • 추상 클래스는 다중 상속이 불가능하다.
  • 구현체가 계약(인터페이스)을 여러 개 구현하면, 계약 테스트도 여러 개를 “조립”해서 붙이고 싶다.
  • 테스트 템플릿을 인터페이스로 만들면, 테스트 클래스가 여러 계약 테스트를 동시에 구현할 수 있다.
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

public interface CheckoutContractTest_Interface {

    // DI 포인트: 구현체 주입
    Checkout createCheckout();

    // Wallet 준비도 DI로 밀어둔다 (대체 전략은 뒤 파트에서)
    Wallet createWallet(int initialBalance);

    @Test
    @DisplayName("검증 1) 잔액이 충분하면 pay == true 이고 잔액 == 기존잔액 - price")
    default void succeeds_and_decreases_balance_when_enough_funds() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(2000);
        int price = 500; // 인터페이스는 값(상태) 보유가 어려워 로컬로 둔다

        boolean ok = sut.pay(wallet, price);

        assertTrue(ok);
        assertEquals(1500, wallet.balance());
    }

    @Test
    @DisplayName("검증 2) 잔액이 부족하면 pay == false 이고 잔액 == 기존잔액")
    default void fails_and_does_not_change_balance_when_insufficient_funds() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(300);
        int price = 500;
        int before = wallet.balance();

        boolean ok = sut.pay(wallet, price);

        assertFalse(ok);
        assertEquals(before, wallet.balance());
    }

    @Test
    @DisplayName("검증 3) price <= 0 이면 예외")
    default void throws_when_price_is_zero_or_negative() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(1000);

        assertThrows(IllegalArgumentException.class, () -> sut.pay(wallet, 0));
        assertThrows(IllegalArgumentException.class, () -> sut.pay(wallet, -1));
    }

    @Test
    @DisplayName("검증 4) 연속 결제에서도 규칙이 유지된다 (성공 -> 잔액 감소, 실패 -> 잔액 불변)")
    default void remains_consistent_across_repeated_payments() {
        Checkout sut = createCheckout();
        Wallet wallet = createWallet(1000);

        assertTrue(sut.pay(wallet, 400));
        assertEquals(600, wallet.balance());

        assertTrue(sut.pay(wallet, 400));
        assertEquals(200, wallet.balance());

        int before = wallet.balance();
        assertFalse(sut.pay(wallet, 400));
        assertEquals(before, wallet.balance());
    }
}

구현체별 테스트 클래스는 역시 “주입만” 담당한다.

public class SimpleCheckoutContractTest_Interface implements CheckoutContractTest_Interface {

    @Override
    public Checkout createCheckout() {
        return new SimpleCheckout(); // 구현체 주입
    }

    @Override
    public Wallet createWallet(int initialBalance) {
        return new DemoWallet(initialBalance); // (대체 전략은 뒤 파트에서)
    }
}

트레이드 오프

추상 클래스

  • 장점: 값(상태)을 가질 수 있어서 공통 픽스처/셋업이 편하다.
  • 단점: 단일 상속이라 계약 테스트 템플릿을 여러 개 조합하기 어렵다.

인터페이스

  • 장점: 테스트 템플릿을 여러 개 구현해서 조립할 수 있다.
  • 단점: 값(상태)을 들고 가기 어렵다 보니 공통 픽스처가 로컬/헬퍼로 흩어지기 쉽다.

선택 가이드

  • 셋업/픽스처가 길고 공통이 많으면 → 추상 클래스
  • 구현체가 계약(인터페이스)을 여러 개 구현하고, 테스트도 조립이 필요하면 → 인터페이스
  • 둘 다 필요하면 → “검증 로직”을 헬퍼로 분리하고 템플릿은 얇게 유지

나는 평소에 추상 클래스를 선호한다.
테스트에서 setup 과정이 대부분의 시간과 노력을 잡아먹는데 인터페이스 테스트는 너무 귀찮아서 여러개 조립 하는거 아니면 추상클래스로 테스트를 만든다.


검증서(보증서)로서의 테스트란?

테스트는 검증서다
정확히는 아래를 뜻한다.

구현체가 어떻게 바뀌든 간에, 구현체가 계약(인터페이스)을 이행하는지
반복적으로 확인해 주는 문서(자동 검증 장치)다.

즉 테스트가 보증하는 건 “코드가 돌아간다”가 아니라 불변조건(invariant) 이다.

  • 바뀌어도 괜찮다: 구현 방식(알고리즘/구조/최적화/리팩토링/캐싱 추가)
  • 이것만은 바뀌면 안 된다: 계약(규칙)
    예: “충분하면 성공+차감 / 부족하면 실패+불변 / price<=0이면 예외”

1) 리팩토링: 구현을 갈아엎어도 “계약”을 만족하는지 확인

리팩토링은 내부를 바꾸는 작업이다.
그런데 내부 변경은 쉽게 계약을 깨뜨린다.

  • 최적화하다가 부족한데도 차감해버림
  • 예외 처리 리팩토링하다가 price<=0 검증이 빠짐
  • 캐시를 넣다가 연속 결제에서 상태가 꼬임

이때 테스트는 “리팩토링이 예쁘다/빠르다”를 평가하지 않는다.
오직 하나만 본다.

리팩토링 전과 후가 같은 계약을 만족하는가?

그래서 테스트는 리팩토링의 브레이크/가드레일 역할을 한다.


2) 하나의 계약에 구현체가 여러 개: “모든 구현체가 같은 규칙을 따르는지” 확인

Checkout 구현체는 상황에 따라 여러 개가 될 수 있다.

  • SimpleCheckout (메모리 기반)
  • DbCheckout (DB 트랜잭션)
  • RemoteCheckout (외부 결제 API)

구현 방식은 다르지만, 사용자 입장에서는 계약이 하나다.

Checkout라는 이름으로 제공되는 기능은 어디서든 같은 의미를 가져야 한다.

그래서 계약 테스트를 한 번 작성해두면, 구현체가 늘어날 때마다
그 구현체에 계약 테스트를 “붙이기만” 하면 된다.

  • 테스트를 복사하지 않는다.
  • 규칙이 바뀌면 테스트 한 군데만 고치면 된다.
  • 구현체가 늘어도 “계약이 동일하다”는 사실이 자동으로 유지된다.

3) 하나의 구현체가 계약을 여러 개 구현: “여러 보증서로 한 구현체를 검증”한다

어떤 구현체는 계약을 여러 개 만족해야 한다.

예: PaymentService implements Checkout, Refundable, Auditable

이 구현체는 “결제 규칙”만 지키면 끝이 아니다.

  • Checkout 계약을 지켜야 하고
  • Refundable 계약도 지켜야 하고
  • Auditable 계약도 지켜야 한다

이때 중요한 건 “구현체 단위로 테스트를 쓰는 것”이 아니라,
계약 단위로 쪼개진 검증서를 조립해서 붙이는 것이다.

구현체는 바뀌어도, 계약별 검증서는 독립적으로 존재하고,
구현체는 그 검증서들을 모두 통과해야 한다.

// 계약(인터페이스) 여러 개
public interface Checkout {
    boolean pay(Wallet wallet, int price);
}

public interface Refundable {
    boolean refund(Wallet wallet, int amount);
}

public interface Auditable {
    void audit(String message);
}

// 구현체가 인터페이스(계약) 여러 개를 동시에 구현하는 구조
public class PaymentService implements Checkout, Refundable, Auditable {
    @Override public boolean pay(Wallet wallet, int price) { return true; }
    @Override public boolean refund(Wallet wallet, int amount) { return true; }
    @Override public void audit(String message) {}
}

그래서 계약 테스트를 인터페이스 기반으로 만들면(조합 가능),
한 구현체에 여러 계약 테스트를 동시에 붙여 검증할 수 있다.


주절 주절 말이 많았는데

검증서인 테스트를 만들어 놓으면 심판자 역할을 한다.

내가 최적화를 하고 분리하고 코드 품질을 위해 온갖 노력을 다 해도 계약 테스트를 통과 못하면 헛수고란걸 바로바로 알 수 있다.

그래서 다시 고쳐야되는걸 바로 알려준다.


결론

테스트가 “검증서”라는 말의 핵심은 이것이다.

  • 테스트는 구현체를 설명하지 않는다.
  • 테스트는 구현체가 계약을 이행하는지를 보증한다.
  • 구현은 자유롭게 바꿔도 되지만(리팩토링/최적화/구현체 추가),
    계약을 깨는 순간 테스트가 즉시 알려준다.

그래서 어떤 경우든, 테스트는 구현체가 아니라 계약의 보증서로 남아야 한다.

0개의 댓글