httptest를 이용한 Go 웹 애플리케이션 단위 및 통합 테스트
Daniel Hayes
Full-Stack Engineer · Leapcell

효과적인 테스트를 통한 견고한 Go 웹 앱 구축
Go에서 견고하고 신뢰할 수 있는 웹 애플리케이션을 개발하려면 포괄적인 테스트가 필요합니다. 강력한 테스트 기반이 없으면 사소한 코드 변경조차 예상치 못한 버그를 초래하여 사용자에게 불편을 주고 비용이 많이 드는 디버깅 주기로 이어질 수 있습니다. Go의 단순성과 강력한 타이핑은 본질적으로 특정 종류의 오류를 줄여주지만, 특히 웹 컨텍스트에서 다양한 구성 요소 간의 상호 작용은 전용 검증이 필요합니다. 이때 단위 및 통합 테스트가 필수적입니다.
이들은 개발자가 리팩토링, 새 기능 추가, 자신 있게 배포할 수 있도록 안전망을 제공합니다. 이 글에서는 Go 웹 애플리케이션을 효과적으로 테스트하는 방법, 특히 종종 복잡한 HTTP 요청 및 응답 시뮬레이션 작업을 단순화하는 Go 표준 라이브러리에서 제공하는 강력한 httptest 패키지에 초점을 맞춰 살펴봅니다.
웹 애플리케이션 테스트의 핵심 기둥 이해
실질적인 내용으로 들어가기 전에 웹 애플리케이션과 관련된 핵심 테스트 개념에 대한 일반적인 이해를 확립합시다.
단위 테스트(Unit Test): 단위 테스트는 애플리케이션의 가장 작은 테스트 가능한 부분, 종종 개별 함수 또는 메서드에 중점을 둡니다. 목표는 이러한 단위를 격리하고 데이터베이스나 HTTP 서버와 같은 외부 종속성과 독립적으로 올바름을 검증하는 것입니다. 웹 핸들러의 경우 단위 테스트는 HTTP 요청/응답 주기에서 분리하여 핸들러 함수 내부의 비즈니스 논리를 테스트할 수 있습니다.
통합 테스트(Integration Test): 통합 테스트는 애플리케이션의 다른 단위 또는 구성 요소가 올바르게 함께 작동하는지 확인합니다. 웹 애플리케이션의 맥락에서 이는 종종 들어오는 HTTP 요청부터 미들웨어, 핸들러, 최종적으로 생성된 HTTP 응답까지 전체 요청-응답 흐름을 테스트하는 것을 의미합니다. 통합 테스트는 구성 요소 간의 "연결부"를 확인합니다.
net/http 패키지: Go의 표준 라이브러리 net/http 패키지는 기본적인 HTTP 클라이언트 및 서버 구현을 제공합니다. 거의 모든 Go 웹 애플리케이션의 백본을 형성하며, http.Handler 인터페이스, http.Request 및 http.ResponseWriter 유형, http.Handle 및 http.ListenAndServe와 같은 함수를 정의합니다.
net/http/httptest 패키지: 이것이 우리의 스타 플레이어입니다. httptest 패키지는 HTTP 테스트를 위한 유틸리티를 제공합니다. http.ResponseWriter 및 http.Request 객체를 프로그래밍 방식으로 생성할 수 있어 실제 네트워크 서버를 시작하지 않고도 http.Handler 구현에 HTTP 요청을 시뮬레이션하는 것이 매우 쉽습니다. 사실상 통합 테스트를 매우 빠르고 메모리 내 프로세스로 전환합니다.
httptest 사용의 기본 원리는 간단합니다. 실행 중인 서버에 실제 네트워크 요청을 보내는 대신 메모리에서 http.Request 객체를 구성하고 httptest.ResponseRecorder( http.ResponseWriter를 구현함)를 생성한 다음 http.Handler의 ServeHTTP 메서드를 직접 호출합니다. ResponseRecorder는 응답 상태, 헤더 및 본문을 캡처하여 이를 기준으로 어설션할 수 있게 합니다.
실용적인 예제를 통해 설명해 보겠습니다.
사용자에게 인사하는 핸들러가 있는 간단한 Go 웹 애플리케이션을 생각해 보겠습니다.
// main.go package main import ( "fmt" "log" "net/http" ) func GreetHandler(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { name = "Guest" } fmt.Fprintf(w, "Hello, %s!", name) } func main() { http.HandleFunc("/greet", GreetHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
GreetHandler 단위 테스트 (논리 중점)
GreetHandler는 작지만, 더 복잡했다면 핵심 논리를 "단위 테스트"하는 방법을 보여줄 수 있습니다. 그러나 이러한 간단한 핸들러의 경우 단위 테스트와 통합 테스트의 경계가 흐려집니다. 진정으로 격리 가능한 "단위"를 위해서는 이상적으로는 인사 논리를 별도의 함수로 추출해야 합니다.
// handler_test.go package main import ( "net/http" "net/http/httptest" "testing" ) func TestGreetHandler_NoName(t *testing.T) { req, err := http.NewRequest("GET", "/greet", nil) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() handler := http.HandlerFunc(GreetHandler) // 우리의 함수를 http.Handler로 래핑 handler.ServeHTTP(rr, req) if status := rr.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } expected := "Hello, Guest!" if rr.Body.String() != expected { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } } func TestGreetHandler_WithName(t *testing.T) { req, err := http.NewRequest("GET", "/greet?name=Alice", nil) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() handler := http.HandlerFunc(GreetHandler) handler.ServeHTTP(rr, req) if status := rr.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } expected := "Hello, Alice!" if rr.Body.String() != expected { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } }
이 예제에서는 원하는 메서드와 URL을 가진 http.Request를 생성한 다음 출력을 캡처하기 위한 httptest.ResponseRecorder를 생성합니다. 그런 다음 handler.ServeHTTP(rr, req)를 직접 호출합니다. 이렇게 하면 네트워크 스택이 완전히 우회되므로 테스트가 매우 빠르고 격리됩니다. 이것은 핸들러에 대한 통합 테스트의 한 형태로, HTTP 요청의 맥락에서 해당 동작을 검증합니다.
라우터 및 미들웨어와의 통합 테스트
실제 애플리케이션은 종종 라우팅 라이브러리(Gorilla Mux, Chi 또는 Echo와 같은)와 미들웨어를 사용합니다. httptest는 이러한 시나리오에서도 마찬가지로 효과적입니다. Gorilla Mux를 사용하는 애플리케이션을 고려해 봅시다.
// main.go (Gorilla Mux를 사용하도록 수정됨) package main import ( "fmt" "log" "net/http" "github.com/gorilla/mux" ) func GreetHandlerMux(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) name := vars["name"] if name == "" { name = "Guest" } fmt.Fprintf(w, "Hello, %s!", name) } func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("Request received: %s %s", r.Method, r.URL.Path) next.ServeHTTP(w, r) }) } func NewRouter() *mux.Router { r := mux.NewRouter() r.Use(LoggingMiddleware) r.HandleFunc("/greet/{name}", GreetHandlerMux).Methods("GET") r.HandleFunc("/greet", GreetHandlerMux).Methods("GET") // /greet?name=... 용 return r } func main() { router := NewRouter() log.Fatal(http.ListenAndServe(":8080", router)) }
이제 이 설정에 대한 통합 테스트를 작성하여 라우팅 및 미들웨어가 예상대로 작동하는지 확인하겠습니다.
// router_test.go package main import ( "net/http" "net/http/httptest" "testing" ) func TestRouter_GreetWithNameFromPath(t *testing.T) { router := NewRouter() // 구성된 라우터 가져오기 req, err := http.NewRequest("GET", "/greet/Bob", nil) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() router.ServeHTTP(rr, req) // 라우터가 요청을 처리하도록 함 if status := rr.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } expected := "Hello, Bob!" if rr.Body.String() != expected { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } } func TestRouter_GreetWithQueryParam(t *testing.T) { router := NewRouter() req, err := http.NewRequest("GET", "/greet?name=Charlie", nil) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if status := rr.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) } expected := "Hello, Charlie!" if rr.Body.String() != expected { t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected) } } func TestRouter_NotFoundRoute(t *testing.T) { router := NewRouter() req, err := http.NewRequest("GET", "/nonexistent", nil) if err != nil { t.Fatal(err) } rr := httptest.NewRecorder() router.ServeHTTP(rr, req) // Gorilla Mux는 기본적으로 일치하지 않는 경로에 대해 404를 반환합니다. if status := rr.Code; status != http.StatusNotFound { t.Errorf("handler returned wrong status code for not found: got %v want %v", status, http.StatusNotFound) } }
이 예제에서는 전체 mux.Router를 인스턴스화한 다음 httptest.ResponseRecorder의 ServeHTTP 메서드에 전달합니다. 이렇게 하면 실제 네트워크 통신 오버헤드 없이 라우팅 및 적용된 미들웨어를 포함한 전체 요청 처리 파이프라인을 테스트할 수 있습니다.
고급 사용법: httptest.Server
때로는 클라이언트 측 논리 또는 실제 실행 중인 HTTP 서버를 진정으로 기대하는 외부 서비스 통합을 테스트해야 할 수도 있습니다. 이러한 경우 httptest는 httptest.Server를 제공합니다. 이 유틸리티는 실제(비록 로컬이지만) HTTP 서버를 사용 가능한 포트에서 시작합니다.
// client_test.go package main import ( "io/ioutil" "net/http" "net/http/httptest" "testing" ) func TestExternalServiceCall(t *testing.T) { // 우리의 모의 외부 서비스 핸들러 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/data" && r.Method == "GET" { w.WriteHeader(http.StatusOK) w.Write([]byte(`{"message": "Hello from mock server!"}`)) } else { w.WriteHeader(http.StatusNotFound) } }) // 실제 HTTP 테스트 서버 시작 server := httptest.NewServer(handler) defer server.Close() // 테스트 후 서버가 닫히도록 보장 // 이제 server.URL을 사용하여 모의 서버에 실제 HTTP 요청을 할 수 있습니다. resp, err := http.Get(server.URL + "/data") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected status OK, got %v", resp.StatusCode) } body, err := ioutil.ReadAll(resp.Body) if err != nil { t.Fatal(err) } expectedBody := `{"message": "Hello from mock server!"}` if string(body) != expectedBody { t.Errorf("expected body %q, got %q", expectedBody, string(body)) } // 실패해야 하는 경로도 테스트할 수 있습니다. resp, err = http.Get(server.URL + "/unknown") if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("expected status NotFound, got %v", resp.StatusCode) } }
httptest.Server는 http.Client를 사용하여 외부 API와 상호 작용하는 코드를 테스트할 때 매우 중요합니다. 이러한 외부 API를 시뮬레이션하고 응답을 제어하여 클라이언트 측 논리가 다양한 시나리오를 올바르게 처리하도록 보장할 수 있습니다.
결론
httptest 패키지는 견고하고 신뢰할 수 있는 Go 웹 애플리케이션을 구축하는 데 필수적인 도구입니다. 개발자는 실제 네트워크 통신 오버헤드 없이 HTTP 핸들러, 미들웨어 및 라우팅 논리에 대한 빠르고 격리된 포괄적인 단위 및 통합 테스트를 작성할 수 있습니다. httptest.ResponseRecorder 및 httptest.Server를 활용하면 개발자는 웹 구성 요소의 정확성을 자신 있게 검증하여 더 안정적인 애플리케이션과 효율적인 개발 주기를 달성할 수 있습니다. httptest를 마스터하면 Go 웹 애플리케이션 테스트를 마스터하게 됩니다.