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를 구현하는 것은 소프트웨어 개발 품질을 향상시키는 간단하지만 강력한 방법입니다.