강력한 Go 웹 앱 테스팅 전략: 단위 테스트부터 Docker 통합까지
Olivia Novak
Dev Intern · Leapcell

Go로 강력하고 유지보수 가능한 웹 애플리케이션을 작성하려면 탄탄한 테스팅 전략이 필요합니다. 오늘날 애플리케이션이 수많은 외부 서비스 및 데이터베이스와 통합되는 빠른 개발 환경에서 잘 정의된 테스트 피라미드는 단순한 모범 사례가 아니라 필수 요소입니다. 이는 코드 변경이 회귀를 도입하지 않고, 새 기능이 예상대로 작동하며, 애플리케이션이 다양한 조건에서 안정적으로 작동함을 보장합니다. 이 글에서는 Go 웹 애플리케이션에 대한 포괄적인 테스팅 접근 방식을 안내하며, 단위 테스트의 세부 수준에서 시작하여 Docker의 강력한 기능을 활용하는 전체 스택 통합 테스트로 점진적으로 나아갑니다.
테스팅 환경 이해
구현에 들어가기 전에 복원력 있는 Go 애플리케이션 구축에 필수적인 핵심 테스팅 용어를 간략하게 정의해 보겠습니다.
- 단위 테스트: 코드의 격리된 부분, 일반적으로 개별 함수 또는 메서드에 초점을 맞춘 가장 작고 가장 빠른 테스트입니다. 목표는 각 코드 단위가 외부 종속성과 독립적으로 의도된 작업을 올바르게 수행하는지 확인하는 것입니다.
- 통합 테스트: 이러한 테스트는 애플리케이션의 다른 구성 요소 또는 모듈 간의 상호 작용을 확인하는 것을 목표로 합니다. 여기에는 서비스와 데이터베이스, 외부 API 또는 기타 내부 마이크로서비스 간의 통신을 테스트하는 것이 포함될 수 있습니다. 통합 테스트는 더 많은 설정과 외부 리소스를 포함하므로 일반적으로 단위 테스트보다 느립니다.
- 종단 간(E2E) 테스트: 이러한 테스트는 처음부터 끝까지 애플리케이션을 통한 실제 사용자의 여정을 시뮬레이션합니다. UI, 백엔드 로직 및 모든 통합 서비스를 포함한 전체 시스템을 검증하여 애플리케이션이 비즈니스 요구 사항을 충족하는지 확인합니다. 필수적이지만 유지 관리 속도가 가장 느리고 가장 복잡합니다.
- 목(Mock): 테스팅에서 목 객체는 실제 종속성의 동작을 모방하는 시뮬레이션된 객체입니다. 목은 일반적으로 단위 테스팅에서 테스트 중인 코드를 외부 종속성으로부터 격리하는 데 사용되며, 종속성의 동작을 제어하고 느리거나 예측할 수 없는 외부 호출을 피할 수 있도록 합니다.
- 테스트 더블(Test Doubles): 실제 객체를 대체하기 위해 테스팅에서 사용되는 모든 종류의 객체에 대한 일반 용어입니다. 목은 스텁, 팩트(fake), 스파이와 함께 테스트 더블의 특정 유형입니다.
- 테스트 픽스처(Test Fixtures): 테스트 실행을 위한 기준선으로 사용되는 고정된 상태 또는 환경입니다. 여기에는 데이터베이스 설정, 외부 서비스 구성 또는 초기 데이터 채우기가 포함될 수 있습니다.
Go 웹 애플리케이션 단위 테스팅
Go는 단위 테스트 작성을 위한 강력한 내장 도구를 제공합니다. testing
패키지가 핵심이며 간단하면서도 효과적인 프레임워크를 제공합니다.
일반적인 Go 웹 애플리케이션은 종종 핸들러, 서비스 및 리포지토리를 포함합니다. 사용자 서비스를 사용하는 간단한 HTTP 핸들러를 살펴보겠습니다.
// user_service.go package main import "errors" type User struct { ID string Name string } // UserService defines the interface for user-related operations type UserService interface { GetUserByID(id string) (*User, error) CreateUser(name string) (*User, error) } // MockUserService for testing type MockUserService struct { GetUserByIDFunc func(id string) (*User, error) CreateUserFunc func(name string) (*User, error) } func (m *MockUserService) GetUserByID(id string) (*User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(id) } return nil, errors.New("not implemented") } func (m *MockUserService) CreateUser(name string) (*User, error) { if m.CreateUserFunc != nil { return m.CreateUserFunc(name) } return nil, errors.New("not implemented") }
이제 이 서비스를 사용하는 HTTP 핸들러를 작성해 보겠습니다.
// handlers.go package main import ( "encoding/json" "fmt" "net/http" ) type UserHandler struct { Service UserService } func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) { userID := r.URL.Query().Get("id") if userID == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } user, err := h.Service.GetUserByID(userID) if err != nil { http.Error(w, "User not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var user User err := json.NewDecoder(r.Body).Decode(&user) if err != nil { http.Error(w, "Invalid request body", http.StatusBadRequest) return } createdUser, err := h.Service.CreateUser(user.Name) if err != nil { http.Error(w, "Failed to create user", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdUser) }
UserHandler
단위 테스팅
UserHandler
를 테스트하려면 UserService
종속성을 목해야 합니다. 이렇게 하면 핸들러 테스트가 실제 데이터베이스 또는 외부 서비스에 종속되지 않습니다.
// handlers_test.go package main import ( "bytes" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" ) func TestUserHandler_GetUser(t *testing.T) { tests := []struct { name string userID string mockService *MockUserService expectedCode int expectedBody string }{ { name: "Successful Get", userID: "123", mockService: &MockUserService{ GetUserByIDFunc: func(id string) (*User, error) { if id == "123" { return &User{ID: "123", Name: "Alice"}, nil } return nil, errors.New("user not found") }, }, expectedCode: http.StatusOK, expectedBody: `{"ID":"123","Name":"Alice"}`, }, { name: "User Not Found", userID: "456", mockService: &MockUserService{ GetUserByIDFunc: func(id string) (*User, error) { return nil, errors.New("user not found") }, }, expectedCode: http.StatusNotFound, expectedBody: `User not found` + "\n", // http.Error adds a newline }, { name: "Missing User ID", userID: "", mockService: &MockUserService{}, // Mock service not called expectedCode: http.StatusBadRequest, expectedBody: `User ID is required` + "\n", } } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := &UserHandler{Service: tt.mockService} req, err := http.NewRequest("GET", "/users?id="+tt.userID, nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != tt.expectedCode { t.Errorf("expected status code %d, got %d", tt.expectedCode, recorder.Code) } if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { if recorder.Body.String() != tt.expectedBody { t.Errorf("expected body %q, got %q", tt.expectedBody, recorder.Body.String()) } } }) } } func TestUserHandler_CreateUser(t *testing.T) { tests := []struct { name string requestBody string mockService *MockUserService expectedCode int expectedBody string }{ { name: "Successful Create", requestBody: `{"Name":"Bob"}`, mockService: &MockUserService{ CreateUserFunc: func(name string) (*User, error) { return &User{ID: "new-id", Name: name}, nil }, }, expectedCode: http.StatusCreated, expectedBody: `{"ID":"new-id","Name":"Bob"}`, }, { name: "Invalid Request Body", requestBody: `{Invalid JSON}`, mockService: &MockUserService{}, // Mock service not called expectedCode: http.StatusBadRequest, expectedBody: `Invalid request body` + "\n", }, { name: "Service Failure", requestBody: `{"Name":"Charlie"}`, mockService: &MockUserService{ CreateUserFunc: func(name string) (*User, error) { return nil, errors.New("database error") }, }, expectedCode: http.StatusInternalServerError, expectedBody: `Failed to create user` + "\n", } } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler := &UserHandler{Service: tt.mockService} req, err := http.NewRequest("POST", "/users", bytes.NewBufferString(tt.requestBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.CreateUser(recorder, req) if recorder.Code != tt.expectedCode { t.Errorf("expected status code %d, got %d", tt.expectedCode, recorder.Code) } if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { actualBody := bytes.TrimSpace(recorder.Body.Bytes()) // Remove potential trailing newline from http.Error expectedBody := bytes.TrimSpace([]byte(tt.expectedBody)) if !bytes.Equal(actualBody, expectedBody) { t.Errorf("expected body %q, got %q", string(expectedBody), string(actualBody)) } } }) } }
이 예제는 다음을 보여줍니다:
- 실제 HTTP 서버를 시작하지 않고 HTTP 요청을 시뮬레이션하고 응답을 캡처하기 위해
httptest.NewRecorder
및http.NewRequest
사용. UserService
종속성의 동작을 제어하기 위해MockUserService
를 구현하여UserHandler
를 격리된 상태로 테스트할 수 있습니다.- 깔끔하고 포괄적인 테스트 제품군을 위한 테이블 기반 테스트 (
t.Run
).
Docker를 사용한 통합 테스팅
단위 테스트는 중요하지만 전체 그림을 다루지는 않습니다. 실제 애플리케이션은 데이터베이스, 메시지 큐 및 외부 API와 상호 작용합니다. 통합 테스트는 이러한 상호 작용을 테스트하여 이 간극을 메웁니다. Docker를 사용하면 이러한 외부 종속성의 설정 및 해제 프로세스를 크게 단순화하여 통합 테스트를 안정적이고 재현 가능하게 만들 수 있습니다.
UserService
구현이 PostgreSQL 데이터베이스를 사용한다고 가정해 봅시다.
// real_user_service.go package main import ( "database/sql" "fmt" "log" _ "github.com/lib/pq" // PostgreSQL driver ) // DBUserService implements UserService using a PostgreSQL database type DBUserService struct { DB *sql.DB } func NewDBUserService(dataSourceName string) (*DBUserService, error) { db, err := sql.Open("postgres", dataSourceName) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } if err = db.Ping(); err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } log.Println("Successfully connected to PostgreSQL") return &DBUserService{DB: db}, nil } func (s *DBUserService) GetUserByID(id string) (*User, error) { row := s.DB.QueryRow("SELECT id, name FROM users WHERE id = $1", id) user := &User{} err := row.Scan(&user.ID, &user.Name) if err == sql.ErrNoRows { return nil, errors.New("user not found") } if err != nil { return nil, fmt.Errorf("failed to get user: %w", err) } return user, nil } func (s *DBUserService) CreateUser(name string) (*User, error) { user := &User{Name: name} err := s.DB.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", name).Scan(&user.ID) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } return user, nil } // InitSchema creates the users table if it doesn't exist func (s *DBUserService) InitSchema() error { const schema = ` CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL ); ` _, err := s.DB.Exec(schema) return err }
테스트 종속성을 위한 Docker Compose
통합 테스트에서는 전용 PostgreSQL 인스턴스를 시작하기 위해 Docker Compose를 사용합니다. docker-compose.test.yml
파일을 만듭니다.
# docker-compose.test.yml version: '3.8' services: db_test: image: postgres:13 environment: POSTGRES_DB: test_db POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password ports: - "5433:5432" # Map to a different port to avoid conflicts with local dev DB volumes: - pg_data_test:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U test_user -d test_db"] interval: 5s timeout: 5s retries: 5 volumes: pg_data_test:
통합 테스트 작성
이제 Docker화된 PostgreSQL에 연결하고 실제 DBUserService
를 사용하는 UserHandler
에 대한 통합 테스트를 작성해 보겠습니다.
서비스를 관리하기 위한 헬퍼 함수가 필요합니다.
// integration_test_utils.go package main import ( "fmt" "os/exec" "time" ) // StartTestContainers brings up the Docker Compose services func StartTestContainers() error { cmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "up", "-d") output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to start containers: %s, %w", string(output), err) } fmt.Println("Docker containers started.") // Wait for the database to be healthy healthCheckCmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "ps", "-q", "db_test") containerIDBytes, err := healthCheckCmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to get db_test container ID: %s, %w", string(containerIDBytes), err) } containerID := string(healthCheckCmd.Output()) // Trim newline fmt.Println("Waiting for db_test to be healthy...") for i := 0; i < 60; i++ { // Wait up to 5 minutes (60 * 5s interval) healthCmd := exec.Command("docker", "inspect", "-f", "{{.State.Health.Status}}", containerID) healthOutput, err := healthCmd.CombinedOutput() if err == nil && string(healthOutput) == "healthy\n" { fmt.Println("db_test is healthy.") return nil } time.Sleep(5 * time.Second) } return errors.New("db_test health check failed after timeout") } // StopTestContainers brings down the Docker Compose services func StopTestContainers() error { cmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "down", "-v") // -v removes volumes output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to stop containers: %s, %w", string(output), err) } fmt.Println("Docker containers stopped.") return nil }
그리고 통합 테스트 자체:
// handlers_integration_test.go package main import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" ) const ( testDSN = "host=localhost port=5433 user=test_user password=test_password dbname=test_db sslmode=disable" ) func TestMain(m *testing.M) { if err := StartTestContainers(); err != nil { fmt.Printf("Failed to start test containers: %v\n", err) os.Exit(1) } // Run tests code := m.Run() if err := StopTestContainers(); err != nil { fmt.Printf("Failed to stop test containers: %v\n", err) // Don't exit here, as tests might have passed. Just log the error. } os.Exit(code) } func TestUserHandler_Integration(t *testing.T) { // Initialize real DBUserService userService, err := NewDBUserService(testDSN) if err != nil { t.Fatalf("Failed to initialize DBUserService: %v", err) } defer userService.DB.Close() // Ensure schema is clean for each test function run if _, err := userService.DB.Exec("DROP TABLE IF EXISTS users;"); err != nil { t.Fatalf("Failed to drop existing users table: %v", err) } if err := userService.InitSchema(); err != nil { t.Fatalf("Failed to initialize schema: %v", err) } handler := &UserHandler{Service: userService} t.Run("Create a user successfully", func(t *testing.T) { requestBody := `{"Name":"Integration Test User"}` req, err := http.NewRequest("POST", "/users", bytes.NewBufferString(requestBody)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "application/json") recorder := httptest.NewRecorder() handler.CreateUser(recorder, req) if recorder.Code != http.StatusCreated { t.Errorf("expected status code %d, got %d. Body: %q", http.StatusCreated, recorder.Code, recorder.Body.String()) } var createdUser User err = json.Unmarshal(recorder.Body.Bytes(), &createdUser) if err != nil { t.Fatalf("Failed to unmarshal response: %v", err) } if createdUser.ID == "" { t.Error("Expected a user ID, got empty") } if createdUser.Name != "Integration Test User" { t.Errorf("Expected name 'Integration Test User', got '%s'", createdUser.Name) } }) t.Run("Get a created user successfully", func(t *testing.T) { // First, create a user directly via the service or a previous integration step testUser := &User{Name: "Another Integration User"} err := userService.DB.QueryRow("INSERT INTO users (name) VALUES ($1) RETURNING id", testUser.Name).Scan(&testUser.ID) if err != nil { t.Fatalf("Failed to pre-create user for GET test: %v", err) } req, err := http.NewRequest("GET", "/users?id="+testUser.ID, nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != http.StatusOK { t.Errorf("expected status code %d, got %d. Body: %q", http.StatusOK, recorder.Code, recorder.Body.String()) } var fetchedUser User err = json.Unmarshal(recorder.Body.Bytes(), &fetchedUser) if err != nil { t.Fatalf("Failed to unmarshal response: %v", err) } if fetchedUser.ID != testUser.ID { t.Errorf("Expected user ID %s, got %s", testUser.ID, fetchedUser.ID) } if fetchedUser.Name != testUser.Name { t.Errorf("Expected name %s, got %s", testUser.Name, fetchedUser.Name) } }) t.Run("Get a non-existent user", func(t *testing.T) { req, err := http.NewRequest("GET", "/users?id=non-existent", nil) if err != nil { t.Fatal(err) } recorder := httptest.NewRecorder() handler.GetUser(recorder, req) if recorder.Code != http.StatusNotFound { t.Errorf("expected status code %d, got %d", http.StatusNotFound, recorder.Code) } expectedBody := "User not found\n" if recorder.Body.String() != expectedBody { t.Errorf("expected body %q, got %q", expectedBody, recorder.Body.String()) } }) }
이러한 통합 테스트를 실행하려면:
- Docker가 실행 중인지 확인합니다.
- 프로젝트 디렉터리로 이동합니다.
go test -v -run Integration ./...
(또는 특정go test -v -run Integration handlers_integration_test.go
)를 실행합니다.
설명:
handlers_integration_test.go
의TestMain
함수는 특별합니다. 패키지의 모든 테스트 함수가 실행되기 전에 실행됩니다. Docker Compose 서비스를 시작하고 중지하는 것을 조정하는 데 사용합니다.StartTestContainers
는docker-compose up -d
를 사용하여 백그라운드에서 서비스를 시작하고 데이터베이스 준비 상태를 기다리는 상태 확인 루프를 포함합니다.StopTestContainers
는docker-compose down -v
를 사용하여 서비스를 종료하고 연결된 볼륨을 제거하여 후속 테스트 실행을 위해 깨끗한 상태를 보장합니다.TestUserHandler_Integration
내에서 Docker화된 PostgreSQL에 연결된 실제DBUserService
를 초기화합니다.- 중요하게도 각 테스트 실행 또는 테스트 케이스의 경우 데이터베이스 스키마가 재설정되었는지 확인합니다 (
DROP TABLE IF EXISTS users;
및InitSchema()
). 이렇게 하면 테스트 격리가 제공되어 한 테스트가 다른 테스트에 영향을 미치지 않습니다. httptest.NewRecorder
및http.NewRequest
를 다시 사용하지만,UserHandler
는 목 대신 실제 데이터베이스와 상호 작용합니다.
결론
잘 구조화된 테스팅 전략은 안정적인 Go 웹 애플리케이션 개발에 매우 중요합니다. Go의 표준 라이브러리를 사용한 단위 테스트와 세심한 목 사용을 통한 종속성 격리를 마스터함으로써 빠르고 안정적인 기반을 구축합니다. Docker 기반 통합 테스트를 통해 이를 확장하면 실제 외부 서비스와의 상호 작용을 포함한 전체 스택을 검증하여 프로덕션과 유사한 환경에서 애플리케이션이 올바르게 작동하는지 확인할 수 있습니다. 가장 작은 단위부터 전체 시스템에 이르기까지 이러한 계층적 테스팅 접근 방식은 궁극적으로 더 강력하고 유지 관리 가능하며 신뢰할 수 있는 웹 애플리케이션으로 이어집니다.