Go에서 TDD 활용하여 견고한 애플리케이션 구축하기: `testing` 및 `testify` 활용
Min-jun Kim
Dev Intern · Leapcell

소개
빠르게 변화하는 소프트웨어 개발 세계에서 안정적이고 유지보수 가능한 애플리케이션을 구축하는 것이 무엇보다 중요합니다. 적절한 테스트를 소홀히 하면 프로덕션에서의 미묘한 버그부터 커다란 리팩토링 비용까지 일련의 문제로 이어질 수 있습니다. 테스트 주도 개발(TDD)은 테스트 패러다임을 개발의 최전선으로 옮김으로써 이러한 위험을 완화하는 강력한 방법론을 제공합니다. 코드가 완료된 후 테스트를 작성하는 대신, TDD는 프로덕션 코드를 작성하기 전에 실패하는 테스트를 작성하는 것을 옹호합니다. 이 접근 방식은 포괄적인 테스트 커버리지를 보장할 뿐만 아니라 디자인 도구 역할을 하여 개발 프로세스를 더 깨끗하고 모듈화되며 궁극적으로 더 높은 품질의 코드로 안내합니다. 이 글에서는 Go의 TDD 실질적인 적용을 자세히 살펴보고, Go의 내장 testing 패키지와 인기 있는 testify 단언 라이브러리를 효과적으로 함께 사용하여 견고하고 복원력 있는 애플리케이션을 구축하는 방법을 시연합니다.
TDD 및 해당 도구 이해하기
실질적인 예시로 들어가기 전에 TDD의 핵심 원칙과 우리가 사용할 도구들을 이해하는 것이 중요합니다.
TDD란 무엇인가?
TDD는 종종 "Red, Green, Refactor"라고 불리는 간단하면서도 심오한 3단계 주기를 따릅니다.
- Red: 새 기능 또는 기능에 대한 실패하는 테스트를 작성합니다. 이 테스트는 원하는 동작을 명확하게 정의해야 하며, 해당 프로덕션 코드가 아직 존재하지 않기 때문에 처음에는 실패해야 합니다.
- Green: 실패하는 테스트를 통과시키기 위해 필요한 만큼의 프로덕션 코드만 작성합니다. 여기서 목표는 완벽하거나 최적화된 코드를 작성하는 것이 아니라 단순히 테스트를 만족시키는 것입니다.
- Refactor: 테스트가 통과하면 프로덕션 코드를 리팩토링하여 외부 동작을 변경하지 않고 디자인, 가독성 및 유지보수성을 개선합니다. 이 단계에서는 모든 기존 테스트가 계속 통과하여 안전망 역할을 해야 합니다.
이 반복적인 프로세스는 개발자가 작고 관리 가능한 기능 조각에 집중하도록 도와 각 부분이 진행하기 전에 의도한 대로 작동하도록 보장합니다.
Go의 내장 테스트 패키지
Go에는 단위, 통합 및 엔드투엔드 테스트 작성을 위한 기초 역할을 하는 강력하고 잘 통합된 testing 패키지가 함께 제공됩니다. 주요 기능은 다음과 같습니다.
- 테스트 함수: 테스트 함수는
Test로 시작하고 대문자로 시작하는 이름(예:TestMyFunction)으로 식별됩니다.*testing.T타입의 단일 인자를 받습니다. - 테스트 실행: 테스트는
go test명령을 사용하여 실행됩니다. - 하위 테스트:
testing.T타입은t.Run()을 사용하여 하위 테스트를 생성할 수 있으며, 이는 테스트를 구성하고 더 나은 보고를 제공하는 데 도움이 됩니다. - 단언:
testing패키지는 테스트 실패를 나타내기 위한t.Error(),t.Errorf(),t.Fatal(),t.Fatalf()와 같은 기본 단언 메서드를 제공합니다.
Testify 단언 라이브러리
Go의 testing 패키지는 테스트 구조화에 탁월하지만, 내장 단언 메커니즘은 다소 장황합니다. 이것이 testify가 등장하는 곳입니다. testify는 매우 표현력이 좋고 읽기 쉬운 단언 함수 집합을 제공하는 인기 있는 타사 단언 툴킷으로, 테스트를 더 깨끗하고 이해하기 쉽게 만듭니다. testify 내에서 가장 일반적으로 사용되는 모듈은 assert이며, assert.Equal(), assert.NotNil(), assert.True() 등과 같은 함수를 제공합니다.
실습 TDD: 전자상거래 주문 처리기 구축
전자상거래 애플리케이션에 대한 간단한 주문 처리 로직을 구축하여 TDD를 설명해 보겠습니다. 먼저 주문 총액을 계산하는 함수로 시작하겠습니다.
먼저 order.go 파일에 Order 및 LineItem 구조체를 배치합니다.
package order type LineItem struct { ProductID string Quantity int UnitPrice float64 } type Order struct { ID string LineItems []LineItem Discount float64 // 백분율, 예: 10%는 0.10 IsExpedited bool }
1단계: Red - 실패하는 테스트 작성
우리의 첫 번째 요구 사항은 할인 없이 주문 총액을 계산하는 것입니다. order_test.go 테스트 파일을 만들고 특정 총액을 예상하는 테스트를 작성합니다.
package order_test import ( testing "github.com/stretchr/testify/assert" // testify assert 패키지 가져오기 "your_module_path/order" // 모듈 경로로 바꾸기 ) func TestCalculateTotalPrice_NoDiscount(t *testing.T) { // Arrange: 테스트 데이터 설정 items := []order.LineItem{ {ProductID: "P001", Quantity: 2, UnitPrice: 10.0}, {ProductID: "P002", Quantity: 1, UnitPrice: 25.0}, } testOrder := order.Order{ ID: "ORD001", LineItems: items, Discount: 0.0, } expectedTotal := 45.0 // (2 * 10.0) + (1 * 25.0) // Act: 구현하려는 함수 호출 actualTotal := testOrder.CalculateTotalPrice() // 이 함수는 아직 존재하지 않습니다! // Assert: 실제 결과가 예상 결과와 일치하는지 확인 assert.Equal(t, expectedTotal, actualTotal, "할인 없이 총 가격이 올바르게 계산되어야 합니다") }
지금 go test를 실행하려고 하면 CalculateTotalPrice가 Order 구조체에 존재하지 않기 때문에 실패합니다. 이것이 우리의 "Red" 상태입니다.
2단계: Green - 통과하는 프로덕션 코드 작성
이제 order.go에서 CalculateTotalPrice 메서드를 테스트를 통과시킬 만큼만 구현해 보겠습니다.
package order func (o *Order) CalculateTotalPrice() float64 { var total float64 for _, item := range o.LineItems { total += float64(item.Quantity) * item.UnitPrice } return total }
다시 go test를 실행합니다. TestCalculateTotalPrice_NoDiscount 테스트가 이제 통과해야 합니다. 이것이 우리의 "Green" 상태입니다.
3단계: Refactor - 코드 개선 (지금은 이 간단한 경우에 선택 사항)
이 매우 간단한 함수에서는 이 단계에서 많은 리팩토링이 필요하지 않습니다. 그러나 복잡성이 증가함에 따라 이 단계는 코드 품질을 유지하는 데 매우 중요해집니다. 예를 들어, 라인 항목 계산이 더 복잡해지면 자체 메서드로 추출할 수 있습니다.
기능 확장: 할인 적용
이제 할인을 적용하는 기능을 추가해 보겠습니다.
Red: 할인에 대한 실패하는 테스트 작성
func TestCalculateTotalPrice_WithDiscount(t *testing.T) { items := []order.LineItem{ {ProductID: "P001", Quantity: 2, UnitPrice: 10.0}, // 20.0 {ProductID: "P002", Quantity: 1, UnitPrice: 25.0}, // 25.0 } testOrder := order.Order{ ID: "ORD002", LineItems: items, Discount: 0.10, // 10% 할인 } // 기본 총액 = 45.0 expectedTotal := 45.0 * (1 - 0.10) // 40.5 actualTotal := testOrder.CalculateTotalPrice() assert.InDelta(t, expectedTotal, actualTotal, 0.001, "할인이 적용되었을 때 총 가격이 올바르게 계산되어야 합니다") }
부동 소수점 부정확성을 고려하기 위해 부동 소수점 비교에 assert.InDelta를 사용합니다. go test를 실행하면 현재 CalculateTotalPrice 메서드가 할인을 적용하지 않으므로 TestCalculateTotalPrice_WithDiscount가 실패합니다.
Green: 할인 로직 구현
order.go에서 할인을 통합하도록 CalculateTotalPrice를 수정합니다.
package order func (o *Order) CalculateTotalPrice() float64 { var total float64 for _, item := range o.LineItems { total += float64(item.Quantity) * item.UnitPrice } // 할인 적용 total *= (1 - o.Discount) // 할인이 백분율인지 확인 return total }
go test를 실행합니다. TestCalculateTotalPrice_NoDiscount와 TestCalculateTotalPrice_WithDiscount가 이제 모두 통과합니다.
Refactor: 엣지 케이스 및 가독성 고려
만약 o.Discount가 음수이거나 1보다 크면 어떻게 될까요? 우리의 현재 테스트는 이것을 다루지 있지만, TDD는 리팩토링 중 또는 다음 주기 중에 이러한 엣지 케이스를 고려하도록 권장합니다. 지금은 유효한 할인 백분율이라고 가정해 보겠습니다. 주문 생성 시 Discount에 대한 유효성 검사 단계를 추가하거나 CalculateTotalPrice 내에서 처리할 수 있습니다.
더 복잡한 시나리오: 특급 배송 추가 요금
특급 주문에는 고정 요금이 추가된다고 가정해 보겠습니다.
Red: 특급 추가 요금 테스트 작성
func TestCalculateTotalPrice_ExpeditedShipping(t *testing.T) { items := []order.LineItem{ {ProductID: "P003", Quantity: 1, UnitPrice: 50.0}, } testOrder := order.Order{ ID: "ORD003", LineItems: items, Discount: 0.0, IsExpedited: true, } const expeditedSurcharge = 15.0 // 이를 위해 상수를 정의해 보겠습니다 expectedTotal := 50.0 + expeditedSurcharge actualTotal := testOrder.CalculateTotalPrice() assert.Equal(t, expectedTotal, actualTotal, "총 가격에 특급 배송 추가 요금이 포함되어야 합니다") }
추가 요금 로직이 구현되지 않았기 때문에 이 테스트가 실패합니다.
Green: 추가 요금 로직 추가
order.go에 추가 요금을 추가합니다. expeditedSurcharge를 패키지 수준 상수(package-level constant)로 정의하겠습니다.
package order // expeditedSurcharge는 특급 배송에 대한 고정 비용입니다. const expeditedSurcharge float64 = 15.0 // LineItem ... (기존 코드) type LineItem struct { // ... } // Order ... (기존 코드) type Order struct { // ... } func (o *Order) CalculateTotalPrice() float64 { var total float64 for _, item := range o.LineItems { total += float64(item.Quantity) * item.UnitPrice } total *= (1 - o.Discount) if o.IsExpedited { total += expeditedSurcharge } return total }
이제 모든 테스트가 통과해야 합니다.
Refactor: 여러 조건 결합 및 하위 테스트
CalculateTotalPrice 함수가 커짐에 따라 Go의 하위 테스트 기능을 사용하여 테스트를 더 잘 구성하고 여러 조건의 조합을 테스트하는 것이 유익합니다.
// 더 나은 가독성을 위해 이 헬퍼 상수를 추가합니다. const expeditedSurcharge = 15.0 func TestCalculateTotalPrice(t *testing.T) { // 테스트 케이스 슬라이스 정의 tests := []struct { name string order order.Order expected float64 }{ { name: "NoDiscount_RegularShipping", order: order.Order{ LineItems: []order.LineItem{ {ProductID: "P001", Quantity: 2, UnitPrice: 10.0}, {ProductID: "P002", Quantity: 1, UnitPrice: 25.0}, }, Discount: 0.0, }, expected: 45.0, }, { name: "WithDiscount_RegularShipping", order: order.Order{ LineItems: []order.LineItem{ {ProductID: "P001", Quantity: 2, UnitPrice: 10.0}, {ProductID: "P002", Quantity: 1, UnitPrice: 25.0}, }, Discount: 0.10, // 10% }, expected: 40.5, // 45 * 0.9 }, { name: "NoDiscount_ExpeditedShipping", order: order.Order{ LineItems: []order.LineItem{ {ProductID: "P003", Quantity: 1, UnitPrice: 50.0}, }, Discount: 0.0, IsExpedited: true, }, expected: 50.0 + expeditedSurcharge, }, { name: "WithDiscount_ExpeditedShipping", order: order.Order{ LineItems: []order.LineItem{ {ProductID: "P004", Quantity: 3, UnitPrice: 10.0}, // 30.0 {ProductID: "P005", Quantity: 1, UnitPrice: 20.0}, // 20.0 }, // 기본 50.0 Discount: 0.20, // 20% IsExpedited: true, }, expected: (50.0 * (1 - 0.20)) + expeditedSurcharge, // 40.0 + 15.0 = 55.0 }, { name: "EmptyOrder", order: order.Order{ LineItems: []order.LineItem{}, Discount: 0.0, IsExpedited: false, }, expected: 0.0, }, { name: "EmptyOrder_Expedited", order: order.Order{ LineItems: []order.LineItem{}, Discount: 0.0, IsExpedited: true, }, expected: expeditedSurcharge, // 총합이 0이어도 추가 요금은 적용됩니다. }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { act := tc.order.CalculateTotalPrice() assert.InDelta(t, tc.expected, actual, 0.001, "테스트 케이스: %s에 대한 총 가격이 일치하지 않습니다", tc.name) }) } }
이 리팩토링은 개별 테스트 함수들을 테이블 기반 테스트와 하위 테스트를 사용하는 단일 TestCalculateTotalPrice로 대체합니다. 이렇게 하면 테스트를 더 잘 구성하고, 새 케이스를 추가하기 쉽게 만들며, DRY(Don't Repeat Yourself) 원칙을 따르게 됩니다. go test 출력은 각 하위 테스트의 결과를 명확하게 보여줍니다.
결론
Go의 testing 패키지와 testify를 성실하게 연습하면 더 안정적이고 유지보수 가능하며 잘 설계된 Go 애플리케이션으로 이어집니다. 테스트를 먼저 작성함으로써 개발자는 구현하기 전에 API 디자인, 엣지 케이스 및 코드의 전체 동작에 대해 세심하게 생각하도록 권장됩니다. 이 규율 있는 접근 방식은 버그를 조기에 발견할 뿐만 아니라 각 구성 요소가 어떻게 작동해야 하는지에 대한 명확성을 제공하는 살아있는 문서 역할을 합니다. 품질 문화를 조성하여 향후 리팩토링을 더 안전하게 만들고 기능 추가를 더 강력하게 만듭니다. Go에서 testing 및 testify를 사용하여 TDD를 구현하는 것은 소프트웨어 개발 품질을 향상시키는 간단하지만 강력한 방법입니다.