DesignThroughTest

주니어 개발자들을 위한 패턴 언어 - 테스트를 검증 도구가 아닌 설계 도구로 사용하는 방법

The Story: The Imtestable Fortress vs. The User's Wish

두 명의 개발자, 민수와 하나가 '주문 할인 계산기'를 만들고 있다.

민수의 접근 (Implementation First): 민수는 할인 로직의 모든 복잡한 경우의 수를 생각하며 DiscountManager 클래스를 구현했다. "데이터베이스에서 사용자 등급을 가져오고, 현재 진행 중인 이벤트를 API로 조회하고..." 코드를 다 짠 후, 그는 테스트를 작성하려고 했다. "어... 테스트에서 데이터베이스 연결을 어떻게 흉내 내지? API 서버가 죽으면 테스트도 실패하나?" 민수의 코드는 외부 세상과 너무 단단히 결합되어 있어서, 테스트를 하려면 거대한 설정이 필요했다. 결국 그는 테스트 작성을 포기했다. "이건 테스트하기 너무 어려워."

하나의 접근 (Test First): 하나는 구현을 시작하기 전에, 자신이 이 코드를 어떻게 사용하고 싶은지 상상하며 테스트부터 적었다.

test "VIP user gets 10% discount" {
  calculator = DiscountCalculator(user_grade="VIP")
  price = calculator.apply(10000)
  assert price == 9000
}

"데이터베이스? API? 그건 나중에 생각하자. 지금 중요한 건 계산 로직이야." 하나는 테스트가 요구하는 대로 DiscountCalculator를 만들었다. 필요한 데이터는 생성자로 받게 설계되었다. 결과적으로 하나의 코드는 외부 의존성 없이 순수한 로직만 남았고, 사용하기 쉬웠으며, 테스트하기도 쉬웠다.

Context

새로운 기능을 구현해야 한다. 요구사항은 어느 정도 이해했다(TinyExperiment & TwoWorlds). 이제 코드를 작성하려고 한다.

일상적인 상황:

당신은 테스트를 숙제(Verification)로 여기고 있다. 하지만 테스트는 설계(Design)의 기회다.

Problem

구현을 먼저 하면, "작동하는 코드"는 얻을 수 있지만 "사용하기 편한 코드"는 얻기 힘들다.

구현 관점에서 코드를 짜면 내부 구현의 편의성을 우선하게 된다.

나중에 이 코드를 사용하는 쪽(클라이언트)이나 테스트 코드에서 보면, 사용하기가 끔찍하게 불편하다는 것을 발견한다. 하지만 이미 코드는 굳어졌고, 고치기엔 너무 늦었다.

Solution

코드를 작성하기 전에, 그 코드를 사용하는 테스트를 먼저 작성하라.

Allen Holub은 "Design by Coding"을 강조했다. 소프트웨어 공학에서 설계(Design)는 도면을 그리는 것이 아니라 코드를 작성하는 과정 그 자체다. 따라서 테스트를 먼저 작성하는 것은 구현 전에 설계를 수행하는 가장 구체적인 활동이다.

Principle 1: Coding IS Design (코딩이 곧 설계다)

Allen Holub이 말했듯, 아키텍처 다이어그램은 설계가 아니다. 그것은 여행 지도일 뿐이다. 실제 설계 결정은 코드를 타이핑하는 순간 일어난다.

Principle 2: Test is the First Client (테스트는 첫 번째 클라이언트다)

테스트 코드는 당신이 만드는 모듈의 첫 번째 사용자다.

테스트가 보내는 고통의 신호(Pain Signal)를 무시하지 마라. 그것은 설계를 개선하라는 신호다.

Principle 3: Wishful Thinking (소망적 사고)

구현 방법을 고민하지 말고, "이런 인터페이스가 있었으면 좋겠다"는 소망을 코드로 적어라.

이 소망대로 테스트를 작성하면, 코드는 자연스럽게 느슨한 결합(Loose Coupling)높은 응집도(High Cohesion)를 가지게 된다.

Principle 4: Externalize Dependencies (의존성 드러내기)

테스트를 먼저 짜면 숨겨진 의존성(Global state, Singleton, DB calls)을 사용할 수 없다. 자연스럽게 필요한 모든 것을 인자(Argument)생성자(Constructor)로 받게 된다. 이것이 바로 의존성 주입(Dependency Injection)의 본질이다. 테스트가 그것을 강제한다.

Real Examples

Example 1: Decoupling Time

"오후 10시 이후에는 주문 불가" 로직을 만든다.

Implementation First (민수):

def place_order(order):
    current_hour = datetime.now().hour  // 시스템 시간에 강하게 결합!
    if current_hour >= 22:
        raise Error("Too late")
    ...

테스트하려면? 시스템 시간을 조작해야 한다. 끔찍하다.

Design Through Test (하나): "테스트할 때 시간을 마음대로 넣고 싶어."

test "cannot order after 10pm" {
    service = OrderService(clock=FixedClock(22:00)) // 시간을 주입받길 원함
    assert throws service.place_order(order)
}

이 테스트를 통과시키려면, OrderServiceClock을 주입받아야만 한다. 설계가 개선되었다.

Example 2: Interface Discovery

복잡한 파싱 로직을 만든다.

Wishful Thinking: "그냥 파일 경로를 주면 파싱된 객체가 툭 튀어나왔으면 좋겠어."

parser = LogParser()
result = parser.parse("error.log")

이렇게 쓰고 보니, File 객체를 넘기는 게 더 나을 것 같다.

parser = LogParser()
result = parser.parse(File("error.log"))

구현하기 전에 API 디자인을 수정했다. 비용이 0이다.

Common Pitfalls

"Test First takes too long"

처음에는 느리다. 하지만 "디버깅 시간"과 "나중에 구조를 뜯어고치는 시간"을 포함하면 훨씬 빠르다. 그리고 무엇보다, 깔끔한 인터페이스는 평생 시간을 절약해준다.

Testing Implementation Details

테스트가 "어떻게(How)"를 검증하면 안 된다. "무엇(What)"을 검증해야 한다.

설계를 테스트한다는 것은 행동(Behavior)과 인터페이스(Interface)를 테스트하는 것이다.

Mocking Hell

테스트를 짜려는데 Mock 객체가 10개 필요하다면? "테스트를 잘 짜는 법"을 고민할 게 아니라, "설계를 뜯어고칠 때"다. 그 객체는 너무 많은 일을 하고 있다. 쪼개라(SingleResponsibility).

Connection to Other Patterns

Signs of Success

The Ultimate Insight

테스트는 버그를 찾기 위한 것이 아니다. 테스트는 설계의 결함을 찾기 위한 것이다.

테스트를 먼저 작성하는 순간, 당신은 구현자(Implementer)가 아니라 사용자(User)가 된다. 사용자 관점에서 설계된 코드는 언제나 구현자 관점에서 설계된 코드보다 낫다.


CategoryPatternLanguage CategoryProgramming CategoryTDD CategoryDesign