Go에서 올바르게 테스트 대역 선택하기
Min-jun Kim
Dev Intern · Leapcell

테스트는 소프트웨어 개발에서 애플리케이션의 신뢰성과 정확성을 보장하는 데 필수적인 부분입니다. Go에서 HTTP를 통해 통신하는 서비스와 거래할 때 일반적인 딜레마가 발생합니다: 통합 테스트를 위해 실제, 비록 일시적이라도 HTTP 서버를 구동해야 할까요, 아니면 단위 테스트 중에 이러한 상호 작용을 시뮬레이션하기 위해 세심하게 모의 객체를 만들어야 할까요? 이 질문은단순히 학문적인 것이 아닙니다. 테스트 유지보수성, 실행 속도 및 커버리지에 상당한 영향을 미칩니다. 이 글은 Go에서 효과적인 테스트 전략을 안내하기 위해 통합 테스트를 위한 httptest.NewServer와 단위 테스트를 위한 모의 서비스 인터페이스 간의 절충점을 심층적으로 살펴봅니다.
세부 사항을 살펴보기 전에 논의의 핵심이 되는 몇 가지 핵심 개념을 명확히 합시다:
- 단위 테스트: 소프트웨어의 개별 단위 또는 구성 요소를 격리하여 테스트하는 데 중점을 둡니다. 목표는 각 코드 단위가 예상대로 작동하는지 확인하는 것입니다. 외부 종속성은 일반적으로 단위를 격리된 상태로 유지하기 위해 "모의" 또는 "스텁" 처리됩니다.
- 통합 테스트: 소프트웨어 시스템의 서로 다른 단위 또는 구성 요소가 서로 어떻게 상호 작용하는지 테스트하는 것을 목표로 합니다. 이는 종종 시스템이 외부 서비스, 데이터베이스 또는 API와 상호 작용하는 것을 테스트합니다.
httptest.NewServer: HTTP 테스트 유틸리티를 제공하는 Go 패키지입니다.httptest.NewServer는 임의의 로컬 네트워크 주소에서 수신 대기하는 새 HTTP 서버를 생성하므로, 실제 HTTP 엔드포인트가 필요하지만 응답 및 동작을 제어하려는 통합 테스트에 이상적입니다.- 모의 서비스 인터페이스: 단위 테스트의 맥락에서 이는 실제 종속성의 동작을 모방하는 대체 객체를 만드는 것을 의미합니다. 실제 외부 서비스를 호출하는 대신 코드는 이러한 모의와 상호 작용하며, 이는 사전 정의된 응답을 제공하여 실제 네트워크 호출 없이 코드를 격리하여 테스트할 수 있습니다.
이제 두 가지 주요 접근 방식을 살펴보겠습니다.
통합 테스트를 위한 httptest.NewServer
httptest.NewServer는 통합 테스트를 위한 강력한 도구입니다. 테스트 스위트 내에서 완전한 기능의 HTTP 서버를 생성할 수 있으며, 테스트 중인 코드는 여기에 요청을 보낼 수 있습니다. 이는 실제 외부 서비스를 시뮬레이션하여 HTTP 헤더, 상태 코드 및 본문 내용을 포함한 전체 요청-응답 주기를 테스트할 수 있습니다.
작동 방식:
http.Handler를 httptest.NewServer에 전달하면 들어오는 요청을 처리합니다. 서버는 임의의 사용 가능한 포트에서 시작되며 URL은 ts.URL을 통해 액세스할 수 있습니다.
예시:
외부 API에서 사용자 데이터를 가져오는 클라이언트를 고려해 봅시다:
package main import ( "encoding/json" "fmt" "io" "net/http" ) type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } type UserClient struct { baseURL string client *http.Client } func NewUserClient(baseURL string) *UserClient { return &UserClient{ baseURL: baseURL, client: &http.Client{}, } } func (uc *UserClient) GetUser(id string) (*User, error) { resp, err := uc.client.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
이제 httptest.NewServer를 사용하여 통합 테스트를 작성해 봅시다:
package main import ( "encoding/json" "net/http" "net/http/httptest" testing "testing" ) func TestUserClient_GetUser_Integration(t *testing.T) { // 모의 서버 생성 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/users/123" { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(User{ID: "123", Name: "John Doe", Email: "john@example.com"}) } else if r.URL.Path == "/users/404" { w.WriteHeader(http.StatusNotFound) } else { w.WriteHeader(http.StatusInternalServerError) } })) defer ts.Close() // 테스트 완료 시 서버 닫기 client := NewUserClient(ts.URL) // 테스트 케이스 1: 성공적인 사용자 검색 user, err := client.GetUser("123") if err != nil { t.Fatalf("Expected no error, got %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Expected user John Doe with ID 123, got %v", user) } // 테스트 케이스 2: 사용자를 찾을 수 없음 _, err = client.GetUser("404") if err == nil { t.Fatalf("Expected an error for user not found, got nil") } expectedErrMsg := "unexpected status code: 404" if err.Error() != expectedErrMsg { t.Errorf("Expected error '%s', got '%s'", expectedErrMsg, err.Error()) } }
장점:
- 높은 충실도: 네트워크 직렬화/역직렬화, HTTP 상태 코드 및 헤더를 포함하여 HTTP 서비스와의 실제 상호 작용을 테스트합니다.
- 포괄적인 오류 처리: 다양한 HTTP 오류 시나리오(4xx, 5xx)를 정확하게 테스트할 수 있습니다.
- 적은 모의 상용구:
http.Handler에 대한 동작을 정의하며, 이는 여러 메서드가 있는 전체 모의 인터페이스를 정의하는 것보다 종종 간단합니다.
단점:
- 느린 실행: 실제 HTTP 서버를 구동하고 해체하는 데는 완전한 메모리 내 단위 테스트보다 더 많은 시간이 소요됩니다.
- 네트워크 종속성(로컬): 로컬 서버이기는 하지만 여전히 네트워크 스택 상호 작용이 포함되어 있어 미묘한 문제 또는 느린 처리의 원인이 될 수 있습니다.
- 디버깅 복잡성:
http.Handler내의 문제를 디버깅하는 것은 모의 객체를 직접 디버깅하는 것보다 때로는 더 까다로울 수 있습니다.
단위 테스트를 위한 모의 서비스 인터페이스
단위 테스트를 위해 외부 종속성으로부터 코드를 격리하는 것이 무엇보다 중요합니다. 여기서 모의 서비스 인터페이스가 빛을 발합니다. 실제 HTTP 엔드포인트에 접속하는 대신, 외부 서비스 인터페이스를 정의하고 해당 인터페이스의 모의 구현을 만듭니다. 그런 다음 테스트 중인 코드는 이 모의와 상호 작용합니다.
작동 방식:
먼저 클라이언트 UserClient가 만족해야 하는 인터페이스를 정의합니다(또는 더 일반적인 경우 UserClient가 인터페이스를 종속성으로 받는 경우 UserClient가 의존하는 종속성에 대한 인터페이스를 정의합니다). 그런 다음 인터페이스를 구현하는 모의 구조체를 만들어 해당 메서드의 반환 값과 부작용을 제어할 수 있습니다.
예시:
HTTP 요청을 수행하기 위한 인터페이스에 의존하도록 UserClient를 리팩터링해 봅시다:
package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" ) // HTTPClient는 HTTP 요청을 수행하기 위한 인터페이스를 정의합니다. // 현재 UserClient에는 Get 메서드만 필요합니다. type HTTPClient interface { Get(url string) (*http.Response, error) } // ConcreteHttpClient는 표준 http.Client를 래핑합니다. type ConcreteHttpClient struct { client *http.Client } func NewConcreteHttpClient() *ConcreteHttpClient { return &ConcreteHttpClient{client: &http.Client{}} } func (c *ConcreteHttpClient) Get(url string) (*http.Response, error) { return c.client.Get(url) } type UserClientWithInterface struct { baseURL string httpClient HTTPClient // 종속성 주입된 HTTPClient } func NewUserClientWithInterface(baseURL string, client HTTPClient) *UserClientWithInterface { return &UserClientWithInterface{ baseURL: baseURL, httpClient: client, } } func (uc *UserClientWithInterface) GetUser(id string) (*User, error) { resp, err := uc.httpClient.Get(fmt.Sprintf("%s/users/%s", uc.baseURL, id)) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var user User if err := json.Unmarshal(body, &user); err != nil { return nil, fmt.Errorf("failed to unmarshal user data: %w", err) } return &user, nil }
이제 단위 테스트를 위해 HTTPClient 모의 객체를 만들어 봅시다:
package main import ( "bytes" "io" "net/http" testing "testing" ) // MockHTTPClient는 HTTPClient 인터페이스의 모의 구현입니다. type MockHTTPClient struct { GetResponse *http.Response GetError error } func (m *MockHTTPClient) Get(url string) (*http.Response, error) { return m.GetResponse, m.GetError } func TestUserClientWithInterface_GetUser_Unit(t *testing.T) { // 테스트 케이스 1: 성공적인 사용자 검색 mockClientSuccess := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewBufferString(`{"id":"123","name":"John Doe","email":"john@example.com"}`)), }, GetError: nil, } clientSuccess := NewUserClientWithInterface("http://api.example.com", mockClientSuccess) user, err := clientSuccess.GetUser("123") if err != nil { t.Fatalf("Expected no error, got %v", err) } if user.ID != "123" || user.Name != "John Doe" { t.Errorf("Expected user John Doe with ID 123, got %v", user) } // 테스트 케이스 2: 사용자를 찾을 수 없음 (상태 404) mockClientNotFound := &MockHTTPClient{ GetResponse: &http.Response{ StatusCode: http.StatusNotFound, Body: io.NopCloser(bytes.NewBufferString("")), // 404에 대한 빈 본문 }, GetError: nil, } clientNotFound := NewUserClientWithInterface("http://api.example.com", mockClientNotFound) _, err = clientNotFound.GetUser("404") if err == nil { t.Fatalf("Expected an error for user not found, got nil") } expectedErrMsgNotFound := "unexpected status code: 404" if err.Error() != expectedErrMsgNotFound { t.Errorf("Expected error '%s', got '%s'", expectedErrMsgNotFound, err.Error()) } // 테스트 케이스 3: 네트워크 오류 mockClientNetworkError := &MockHTTPClient{ GetResponse: nil, GetError: fmt.Errorf("network connection refused"), } clientNetworkError := NewUserClientWithInterface("http://api.example.com", mockClientNetworkError) _, err = clientNetworkError.GetUser("123") if err == nil { t.Fatalf("Expected a network error, got nil") } expectedErrMsgNetwork := "failed to make request: network connection refused" if err.Error() != expectedErrMsgNetwork { t.Errorf("Expected error '%s', got '%s'", expectedErrMsgNetwork, err.Error()) } }
장점:
- 빠른 실행: 테스트가 완전히 메모리 내에서 실행되므로 매우 빠르며 지속적인 통합에 적합합니다.
- 격리: 테스트 중인 코드는 외부 요인으로부터 완전히 격리되어 테스트 실패가 단위 자체의 문제를 나타냄을 보장합니다.
- 정밀한 제어: 모의 객체에서 반환되는 응답 및 오류에 대해 세부적인 제어를 할 수 있으므로 엣지 케이스를 쉽게 테스트할 수 있습니다.
단점:
- 낮은 충실도: 실제 HTTP 전송 계층을 테스트하지 않으므로 직렬화/역직렬화 또는 헤더 처리의 잠재적인 문제가 누락될 수 있습니다.
- 상용구: 인터페이스와 모의 구현을 만드는 것이 필요하며, 특히 외부 모의 라이브러리를 사용하지 않는 경우 더 많은 상용구 코드로 이어질 수 있습니다.
- 부정확한 모의 위험: 모의가 실제 서비스의 동작을 정확하게 모방하지 않으면 실제 통합이 실패하는 동안 테스트가 통과할 수 있습니다.
올바른 선택하기
httptest.NewServer와 모의 서비스 인터페이스 간의 선택은 주로 작성하는 테스트 유형과 확인하려는 특정 측면에 따라 달라집니다.
- 단위 테스트에는 모의 서비스 인터페이스를 사용하세요. 구성 요소의 내부 로직을 격리하여 테스트하고 다양한 응답(성공, 다른 오류 코드)과 네트워크 오류를 올바르게 처리하도록 하려는 경우. 이는 내 코드가 다양한
HTTPClient결과에 어떻게 반응하는지에 관한 것입니다. 이러한 테스트는 빠르고 집중적이어야 합니다. - HTTP 서비스와 올바르게 인터페이스되는지 확인해야 할 때 통합 테스트에
httptest.NewServer를 사용하세요. HTTP 프로토콜의 미묘한 차이를 포함하여. 이는 HTTP를 통한 엔드투엔드 통신 및 데이터 교환을 확인하는 것입니다. 이러한 테스트는 느리지만 실제 시나리오에서 더 높은 신뢰를 제공합니다.
강력한 테스트 전략은 종종 두 가지 접근 방식을 모두 포함합니다. 핵심 비즈니스 로직 및 클라이언트 측 HTTP 상호 작용 로직에 대한 모의 인터페이스를 사용하여 포괄적인 단위 테스트로 시작하세요. 이러한 테스트는 실제(비록 로컬이라도) 서버의 응답을 올바르게 해석하는지 확인하기 위해 httptest.NewServer를 사용하여 통합 테스트로 보완하세요.
결론적으로 httptest.NewServer는 HTTP 상호 작용에 대한 높은 충실도의 통합 테스트를 제공하여 속도를 희생하고 실제 시나리오에서 자신감을 제공합니다. 반면에 모의 서비스 인터페이스는 번개처럼 빠르고 격리된 단위 테스트를 가능하게 하여 내부 로직 및 오류 처리를 확인하는 데 적합합니다. 최적의 전략은 이 두 가지 강력한 기술을 조화시켜 구성 요소의 모듈식 정확성과 더 큰 시스템으로의 원활한 통합을 모두 보장합니다.

