티스토리 뷰

1장 - 돈 문제

레드-그린-리팩터: 테스트 주도 개발 구성 요소

  1. 레드: 실패하는 테스트를 작성합니다(컴파일 실패 포함). 테스트 스위트(suite)를 실행해서 테스트가 실패하는 것을 확인합니다.
  2. 그린: 테스트를 통과할 만큼의 최소한의 코드를 작성합니다. 테스트 스위트를 실행해서 테스트가 성공하는 것을 확인합니다.
  3. 리팩터: 중복 코드, 하드 코딩된 값, 프로그래밍 이디엄(idiom)의 부적절한 사용 등을 제거합니다. 이 과정에서 테스트가 깨진다면, 깨진 모든 테스트를 그린으로 만드는 것을 우선시합니다.

문제 인식

여러 통화로 돈을 관리하거나 주식 포트폴리오를 관리하는 스프레드시트를 만들어야 한다고 가정해 봅시다.

요구사항

  1. 단일 통화로 된 숫자상에서 간단한 산술 연산이 가능해야 합니다.
    • 5달러 * 2 = 10달러
    • 10유로 * 2 = 20유로
    • 4002원 / 4 = 1000.5원
  2. 통화 간 환전을 지원해야 합니다.
    • 5달러 + 10유로 = 17달러
    • 1달러 + 1100원 = 2200원

각 항목은 테스트 주도 개발로 구현할 하나의 피처(feature)가 됩니다.

첫 번째 실패하는 테스트

단일 통화의 곱셈을 테스트합니다.

package main

import "testing"

func TestMultiplication(t *testing.T) {
    fiver := Dollar{
        amount: 5,
    }
    tenner := fiver.times(2)
    if tenner.amount != 10 {
        t.Errorf("Expected 10, got %d", tenner.amount)
    }
}
  • fiver 변수에 amount 필드가 5인 Dollar 구조체를 초기화하여 '5달러'를 나타내는 엔티티를 선언합니다.
  • fiver.times(2)를 호출하여 5달러를 2배로 만들어 '10달러'를 나타내는 엔티티를 얻습니다.
  • tenneramount 필드가 10인지 확인합니다.
$ go test -v .
# tdd [tdd.test]
./money_test.go:6:11: undefined: Dollar
FAIL    tdd [build failed]
FAIL

Dollar가 정의되어 있지 않다는 오류와 함께 테스트가 실패합니다.

그린으로 전환

  1. Dollar의 추상(abstraction)을 만듭니다.
type Dollar struct {
}
$ go test -v .
# tdd [tdd.test]
./money_test.go:9:3: unknown field amount in struct literal of type Dollar
./money_test.go:11:18: fiver.times undefined (type Dollar has no field or method times)
FAIL    tdd [build failed]
FAIL

amount 필드와 times 메서드가 없다는 오류와 함께 테스트가 실패합니다.

  1. Dollar에 amount 필드를 추가합니다.
type Dollar struct {
    amount int
}
$ go test -v .
# tdd [tdd.test]
./money_test.go:13:18: fiver.times undefined (type Dollar has no field or method times)
FAIL    tdd [build failed]
FAIL

times 메서드가 없다는 오류와 함께 테스트가 실패합니다.

  1. Dollar에 times 메서드를 추가합니다.
func (d Dollar) times(multiplier int) Dollar {
    return Dollar{
        amount: 10,
    }
}
  • multiplieramount를 곱한 값을 반환하는 것이 올바른 산술 연산이지만,
  • 우선은 테스트 예상 결과를 반환하는 가장 간단한 코드를 작성합니다.
  • 하드 코딩된 값인 10을 반환합니다.
$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
PASS
ok      tdd     0.002s

테스트가 성공합니다.

이제 리팩터링 단계로 넘어가야 합니다. 리팩터링은 중복 코드, 하드 코딩된 값, 프로그래밍 이디엄의 부적절한 사용 등을 제거하는 것입니다.

이상한 점 찾기

  1. 결합: '5달러를 2배 하면 10달러'를 검증하는 코드를 작성했지만, 이를 '10달러를 2배하면 20달러'로 변경하면 테스트가 실패합니다. 테스트를 변경하면 코드도 변경해야 하는 의존성(논리적 결합)이 존재한다는 것을 알 수 있습니다.
  2. 중복: 테스트와 프로덕션 코드에 10이라는 값이 중복되어 있습니다. 10이라는 값은 실제로 5와 2를 곱한 결과이므로, 이 값을 계산하도록 프로덕션 코드를 수정하면 중복을 제거할 수 있습니다.
func (d Dollar) times(multiplier int) Dollar {
    return Dollar{
        amount: d.amount * multiplier,
    }
}

테스트를 실행하면 성공합니다.

변경 사항 반영하기

첫 번째 피처를 구현했으므로, 코드를 버전 관리 시스템에 반영합니다.

$ git add .
$ git commit -m "feat: first green test"

커밋 메시지는 시맨틱 커밋 메시지 규칙을 따릅니다.

구현한 피처는 체크리스트에 체크합니다.

  • 5달러 * 2 = 10달러
  • 10유로 * 2 = 20유로
  • 4002원 / 4 = 1000.5원
  • 5달러 + 10유로 = 17달러
  • 1달러 + 1100원 = 2200원

2장 - 다양한 통화로 돈 계산

유로에 발 들이기

피처 목록 중 두 번째 항목을 구현해 보겠습니다.

달러에 더해서 유로를 지원하려면 1장에서 만든 Dollar보다 더 일반적인 엔티티, 이미 정의된 amount에 더해 currency 속성을 가진 새로운 엔티티가 필요합니다.

func TestMultiplicationInEuros(t *testing.T) {
    tenEuros := Money{
        amount:   10,
        currency: "EUR",
    }
    twentyEuros := tenEuros.times(2)
    if twentyEuros.amount != 20 {
        t.Errorf("Expected 20, got %d", twentyEuros.amount)
    }
    if twentyEuros.currency != "EUR" {
        t.Errorf("Expected EUR, got %s", twentyEuros.currency)
    }
}

테스트는 금액(amount) 뿐만 아니라 통화(currency)를 포함하는 구조체 인스턴스인 '10 EUR'과 '20 EUR'의 개념을 표현합니다. 이제 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd [tdd.test]
./money_test.go:26:14: undefined: Money
FAIL    tdd [build failed]
FAIL

Money가 정의되지 않았다는 에러가 발생합니다. Money 구조체를 정의해 보겠습니다.

type Money struct {
    amount   int
    currency string
}

다시 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd [tdd.test]
./money_test.go:35:26: tenEuros.times undefined (type Money has no field or method times)
FAIL    tdd [build failed]
FAIL

times 메서드가 정의되지 않았다는 에러가 발생합니다. times 메서드를 정의해 보겠습니다.

func (m Money) times(multiplier int) Money {
    return Money{
        amount:   m.amount * multiplier,
        currency: m.currency,
    }
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
=== RUN   TestMultiplicationInEuros
--- PASS: TestMultiplicationInEuros (0.00s)
PASS
ok      tdd     0.002s

테스트가 그린 상태가 되었습니다.

DRY 한 코드를 유지하라

  • DRY(Don't Repeat Yourself): 반복하지 말라, 중복을 피하라

테스트를 통과하기 위해 새로운 구조체 Money를 만들었지만, 이 구조체는 Dollar와 유사합니다. 두 구조체를 합칠 수 있을 것 같습니다. Money 구조체가 Dollar 구조체를 포함하는 관계이므로 Dollar 구조체를 Money 구조체로 대체할 수 있습니다. Dollar 구조체를 제거하고 Money 구조체로 대체해 보겠습니다.

func TestMultiplication(t *testing.T) {
    fiver := Money{
        amount:   5,
        currency: "USD",
    }
    tenner := fiver.times(2)
    if tenner.amount != 10 {
        t.Errorf("Expected 10, got %v", tenner.amount)
    }
    if tenner.currency != "USD" {
        t.Errorf("Expected USD, got %s", tenner.currency)
    }
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
=== RUN   TestMultiplicationInEuros
--- PASS: TestMultiplicationInEuros (0.00s)
PASS
ok      tdd     0.002s

테스트가 그린 상태가 되었습니다.

반복하지 말라고 하지 않았나?

currency 속성을 추가하고 Money 구조체를 만드는 과정에서 두 개의 테스트가 비슷한 코드를 포함하고 있습니다. 테스트 중 하나를 삭제할 수도 있고 그냥 내버려 둘 수도 있지만, 코드에 발생될 리그레션을 방지하기 위한 대비책이 필요합니다. 우선은 두 테스트를 모두 유지하도록 하겠습니다.

리그레션: 소프트웨어의 변경으로 인해 기존의 기능이 올바르게 작동하지 않게 되는 현상

분할 정복

다음 피처인 나눗셈을 구현해 보겠습니다. 이번에는 두 가지의 새로운 하위 요구사항이 있습니다.

  1. 새로운 통화 단위: 대한민국 원(KRW)
  2. 소수부를 포함하는 금액
func TestDivision(t *testing.T) {
    originalMoney := Money{amount: 4002, currency: "KRW"}
    actualMoneyAfterDivision := originalMoney.Divide(4)
    expectedMoneyAfterDivision := Money{amount: 1000.5, currency: "KRW"}
    if actualMoneyAfterDivision != expectedMoneyAfterDivision {
        t.Errorf("Expected %v, got %v", expectedMoneyAfterDivision, actualMoneyAfterDivision)
    }
}

이전과 다르게 각 필드를 비교하지 않고 예상되는 Money 인스턴스와 실제 Money 인스턴스를 비교하고 있습니다. 이제 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd [tdd.test]
./money_test.go:47:44: originalMoney.divide undefined (type Money has no field or method divide)
./money_test.go:48:46: cannot use 1000.5 (untyped float constant) as int value in struct literal (truncated)
FAIL    tdd [build failed]
FAIL

divide 메서드가 정의되지 않았다는 에러와 1000.5가 int 타입이 아니라는 에러가 발생합니다.

Divide 메서드를 정의해 보겠습니다.

func (m Money) Divide(divisor int) Money {
    return Money{
        amount:   m.amount / divisor,
        currency: m.currency,
    }
}

다음으로 amount 필드가 소수부를 포함할 수 있도록 타입을 변경해 보겠습니다.

type Money struct {
    amount   float64
    currency string
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd [tdd.test]
./money_test.go:26:13: invalid operation: m.amount * multiplier (mismatched types float64 and int)
./money_test.go:33:13: invalid operation: m.amount / divisor (mismatched types float64 and int)
FAIL    tdd [build failed]
FAIL

이번에는 float64 타입의 amountint 타입의 multiplier, divisor 간의 연산이 불가능하다는 에러가 발생합니다. 모든 피연산자가 같은 타입(float64)을 가지도록 수정해 보겠습니다.

func (m Money) times(multiplier float64) Money {
    return Money{
        amount:   m.amount * multiplier,
        currency: m.currency,
    }
}

func (m Money) Divide(divisor float64) Money {
    return Money{
        amount:   m.amount / divisor,
        currency: m.currency,
    }
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd
# [tdd]
./money_test.go:12:3: (*testing.common).Errorf format %d has arg tenner.amount of wrong type float64
./money_test.go:45:3: (*testing.common).Errorf format %d has arg twentyEuros.amount of wrong type float64
FAIL    tdd [build failed]
FAIL

테스트가 실패했습니다. 이번에는 기존의 amountint 타입이었던 것과 달리 float64 타입이 되었기 때문에 테스트 코드에서 값을 출력하기 위해 사용한 포맷 문자열(% d)이 float64 타입에 맞지 않아서 에러가 발생한 것입니다. 포맷 문자열을 모든 타입의 값을 출력할 수 있는 %v로 수정해 보겠습니다.

func TestMultiplication(t *testing.T) {
    fiver := Money{
        amount:   5,
        currency: "USD",
    }
    tenner := fiver.times(2)
    if tenner.amount != 10 {
        t.Errorf("Expected 10, got %v", tenner.amount)
    }
    if tenner.currency != "USD" {
        t.Errorf("Expected USD, got %s", tenner.currency)
    }
}

다음으로 테스트를 실행해 보겠습니다.

$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
=== RUN   TestMultiplicationInEuros
--- PASS: TestMultiplicationInEuros (0.00s)
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
PASS
ok      tdd     0.002s

테스트가 그린 상태가 되었습니다. 이제 테스트를 통과하기 위해 구현한 코드를 리팩터링 해보겠습니다.

마무리하기

각 테스트에서 공통된 코드를 찾아내고, 중복을 제거하면서 테스트를 통과하는 코드를 작성해 보겠습니다.

예상되는 Money 인스턴스와 실제 Money 인스턴스를 비교하는 코드를 함수로 추출해 보겠습니다.

func assertEqual(t *testing.T, expected, actual Money) {
    if actual != expected {
        t.Errorf("Expected %v, got %v", expected, actual)
    }
}

추출한 assertEqual 함수를 사용해 테스트 코드를 리팩터링 해보겠습니다.

func TestMultiplication(t *testing.T) {
    fiver := Money{
        amount:   5,
        currency: "USD",
    }
    tenner := fiver.times(2)
    expectedTenner := Money{
        amount:   10,
        currency: "USD",
    }
    assertEqual(t, expectedTenner, tenner)
}

func TestMultiplicationInEuros(t *testing.T) {
    tenEuros := Money{
        amount:   10,
        currency: "EUR",
    }
    twentyEuros := tenEuros.times(2)
    expectedTwentyEuros := Money{
        amount:   20,
        currency: "EUR",
    }
    assertEqual(t, expectedTwentyEuros, twentyEuros)
}

func TestDivision(t *testing.T) {
    originalMoney := Money{amount: 4002, currency: "KRW"}
    actualMoneyAfterDivision := originalMoney.Divide(4)
    expectedMoneyAfterDivision := Money{amount: 1000.5, currency: "KRW"}
    assertEqual(t, expectedMoneyAfterDivision, actualMoneyAfterDivision)
}
$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
=== RUN   TestMultiplicationInEuros
--- PASS: TestMultiplicationInEuros (0.00s)
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
PASS
ok      tdd     0.002s

테스트가 정상적으로 실행됩니다. 이제 변경 사항을 반영하고 마무리하겠습니다.

변경 사항 반영하기

$ git add .
$ git commit -m "feat: division and multiplication features done"

중간 점검

  1. 달러와 유로를 모두 지원하는 Money 구조체를 만들었습니다.
  2. 나눗셈을 구현했고 실수를 사용할 수 있도록 설계를 변경했습니다.
  3. 테스트 코드를 리팩터링 하여 중복을 제거했습니다.
  • 5달러 * 2 = 10달러
  • 10유로 * 2 = 20유로
  • 4002원 / 4 = 1000.5원
  • 5달러 + 10유로 = 17달러
  • 1달러 + 1100원 = 2200원

3장 - 포트폴리오

이번에는 통화를 혼합한 덧셈을 구현해 보겠습니다.

다음 테스트 설계하기

  • 5달러 + 10유로 = 17달러

다음 피처를 개발하기에 앞서, 먼저 밑그림을 그려보는 것이 좋습니다. 여기서 1유로가 1.2달러로 교환된다고 가정합니다. 그러면 다음과 같은 것도 고려해야 합니다.

  • 1유로 + 1유로 = 2.4달러
  • 1유로 + 1유로 = 2유로

두 개의 Money 엔티티를 더한 결과는, 연관된 모든 통화 간 환율을 알 수 있다면 어떤 통화로도 표현될 수 있습니다. '어떤 통화로도 표현'이라는 말은 도메인 모델이 확장되어야 한다는 것을 의미합니다. 이처럼 테스트를 설계할 때는 도메인 모델이 어떻게 확장될지 고려해야 합니다.

확장된 도메인 모델은 Portfolio라는 새로운 개념을 도입할 수 있습니다. Portfolio는 여러 통화를 포함할 수 있으며, 각 통화는 환율을 가지고 있습니다. 이제 테스트를 작성해 보겠습니다.

func TestAddition(t *testing.T) {
    var portfolio Portfolio
    var portfolioInDollars Money

    fiveDollars := Money{amount: 5, currency: "USD"}
    tenDollars := Money{amount: 10, currency: "USD"}
    fifteenDollars := Money{amount: 15, currency: "USD"}

    portfolio = portfolio.Add(fiveDollars)
    portfolio = portfolio.Add(tenDollars)
    portfolioInDollars = portfolio.Evaluate("USD")

    assertEqual(t, fifteenDollars, portfolioInDollars)
}

포트폴리오에 5달러와 10달러를 추가하고, 포트폴리오에 들어있는 돈을 달러로 평가하면 15달러가 나와야 합니다. 이제 테스트를 실행해 보겠습니다.

$ go test -v .
# tdd [tdd.test]
./money_test.go:64:16: undefined: Portfolio
FAIL    tdd [build failed]
FAIL

당연하게도 Portfolio가 정의되지 않았다는 에러가 발생합니다. Portfolio를 정의해 보겠습니다.

type Portfolio []Money

func (p Portfolio) Add(money Money) Portfolio {
    return p
}

func (p Portfolio) Evaluate(currency string) Money {
    return Money{amount: 15, currency: "USD"}
}

PortfolioMoney 타입의 슬라이스로 정의하고, AddEvaluate 메서드를 정의했습니다. 각 메서드는 테스트를 통과하기 위한 최소한의 구현을 가지고 있습니다. 하드코딩된 값을 반환하도록 구현했기 때문에 테스트를 통과할 것입니다. 이제 테스트를 실행해 보겠습니다.

$ go test -v .
=== RUN   TestMultiplication
--- PASS: TestMultiplication (0.00s)
=== RUN   TestMultiplicationInEuros
--- PASS: TestMultiplicationInEuros (0.00s)
=== RUN   TestDivision
--- PASS: TestDivision (0.00s)
=== RUN   TestAddition
--- PASS: TestAddition (0.00s)
PASS
ok      tdd     (cached)

테스트가 그린 상태가 되었습니다. 이제 테스트를 통과하기 위해 구현한 코드를 리팩터링 해보겠습니다. 테스트와 프로덕션 코드에서 중복된 코드를 찾아내고, 중복을 제거하면서 테스트를 통과하는 코드를 작성해 보겠습니다.

우선은 테스트와 PortfolioEvaluate 메서드에서 15라는 값이 중복되어 나타나는 것을 발견했습니다. Evaluate 메서드에서 실제로 계산된 값을 반환하도록 수정해 보겠습니다.

func (p Portfolio) Evaluate(currency string) Money {
    total := 0.0
    for _, m := range p {
        total += m.amount
    }

    return Money{amount: total, currency: currency}
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
...
=== RUN   TestAddition
    money_test.go:59: Expected {15 USD}, got {0 USD}
--- FAIL: TestAddition (0.00s)

테스트가 실패했습니다. 이번에는 15라는 값이 아닌 0이라는 값이 반환되었습니다. 이는 빈 슬라이스를 순회하면서 total 변수에 더해지는 값이 없기 때문입니다. 이번에는 Add 메서드가 실제로 Money를 추가하도록 수정해 보겠습니다.

func (p Portfolio) Add(money Money) Portfolio {
    return append(p, money)
}

이제 테스트를 실행해 보겠습니다.

$ go test -v .
...
=== RUN   TestAddition
--- PASS: TestAddition (0.00s)

테스트가 그린 상태가 되었습니다. 그러나 테스트로는 발견되지 않은 문제가 있습니다. Evaluate 메서드는 통화를 고려하지 않고 단순히 금액을 더하고 있습니다.

코드에서 이런 '미련한' 동작을 지우는 방법을 테스트해야 할까요 아니면 '리팩터링'을 해야 할까요? 여기에 만능인 답은 없습니다. 테스트 주도 개발은 스스로 속도를 조절할 수 있습니다. 여기서는 아직 환율의 개념이 정의되지 않았으므로, 우선은 속도를 맞추기 위해 '미련한' 동작의 수정을 나중으로 미루도록 하겠습니다.

변경 사항 반영하기

$ git add .
$ git commit -m "feat: addition feature for Moneys in the same currency done"

중간 점검

  1. 다른 통화를 더하는 피처를 구현하기 위해 새로운 도메인 모델인 Portfolio를 도입했습니다.
  2. 한 번에 모두 해결하기에는 양이 많으므로, 먼저 동일한 통화를 가진 두 Money 인스턴스를 더하는 피처를 구현했습니다.
  3. 개선해야 할 부분이 있지만, 일단은 미뤄두고 새로운 개념을 도입하는 과정에서 함께 살펴볼 수 있도록 했습니다.
  4. 테스트와 프로덕션 코드가 늘어남에 따라 하나의 파일에 모든 코드를 작성하는 것이 불편해졌습니다. 이제 코드를 분리해 보겠습니다.
  • 5달러 * 2 = 10달러
  • 10유로 * 2 = 20유로
  • 4002원 / 4 = 1000.5원
  • 5달러 + 10유로 = 17달러
  • 1달러 + 1100원 = 2200원

정리

  1. 테스트 주도 개발은 레드-그린-리팩터(RGR) 요소를 순차적으로 적용하고 반복하는 과정을 통해 이루어진다.
  2. 테스트를 그린으로 만들기 위해 최소한의 코드만을 작성한 뒤, 리팩터링함으로써 코드의 단순성을 유지한다.
  3. 피처를 구현하는 과정에서 도메인의 확장이 일어날 수 있고, 테스트 코드가 수정될 수도 있다. 그러나 이를 두려워해서는 안된다!

참고

 

테스트 주도 개발 입문 - 예스24

단위 테스트 코드 작성을 기본으로 하는 테스트 주도 개발 방식은 소프트웨어가 테스트 가능한(Testable) 구조를 갖도록 강제하며, 이로 인해 보다 간단하면서도 구조화된 설계로 이끌 뿐만 아니

www.yes24.com

 

'Go > TDD' 카테고리의 다른 글

테스트 주도 개발 입문 - 테스트 주도 개발이란?  (1) 2024.07.16
최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함