Robuste Go-Web-App-Teststrategien: Von Unit- bis zu Docker-integrierten Tests
Olivia Novak
Dev Intern · Leapcell

Robuste Go-Web-App-Teststrategien: Von Unit- bis zu Docker-integrierten Tests
Das Schreiben robuster und wartbarer Webanwendungen in Go erfordert eine solide Teststrategie. Im heutigen schnelllebigen Entwicklungsumfeld, in dem Anwendungen oft mit zahlreichen externen Diensten und Datenbanken integriert werden, ist eine klar definierte Testpyramide nicht nur eine Best Practice – sie ist eine Notwendigkeit. Sie stellt sicher, dass Codeänderungen keine Regressionen einführen, neue Funktionen wie erwartet funktionieren und die Anwendung unter verschiedenen Bedingungen zuverlässig arbeitet. Dieser Artikel führt Sie durch einen umfassenden Testansatz für Go-Webanwendungen, beginnend auf der granularen Ebene von Unit-Tests bis hin zu Full-Stack-Integrationstests unter Nutzung der Leistungsfähigkeit von Docker.
Landschaft der Tests verstehen
Bevor wir uns in die Implementierung stürzen, definieren wir kurz die Kernterminologie des Testens, die für den Aufbau widerstandsfähiger Go-Anwendungen unerlässlich ist:
- Unit-Tests: Dies sind die kleinsten und schnellsten Tests, die sich auf isolierte Codeabschnitte konzentrieren, typischerweise einzelne Funktionen oder Methoden. Ihr Ziel ist es, zu überprüfen, ob jede Codeeinheit ihre beabsichtigte Aufgabe korrekt und unabhängig von externen Abhängigkeiten ausführt.
- Integrationstests: Diese Tests zielen darauf ab, die Interaktionen zwischen verschiedenen Komponenten oder Modulen einer Anwendung zu überprüfen. Dies kann das Testen der Kommunikation zwischen Ihrem Dienst und einer Datenbank, einer externen API oder anderen internen Microservices umfassen. Integrationstests sind typischerweise langsamer als Unit-Tests, da sie mehr Setup und externe Ressourcen erfordern.
- End-to-End (E2E) Tests: Diese Tests simulieren eine reale Benutzersitzung durch die Anwendung, von Anfang bis Ende. Sie validieren das gesamte System, einschließlich der Benutzeroberfläche, der Backend-Logik und aller integrierten Dienste, und stellen sicher, dass die Anwendung die Geschäftsanforderungen erfüllt. Obwohl entscheidend, sind sie die langsamsten und komplexesten zu warten.
- Mocks: Beim Testen ist ein Mock-Objekt ein simuliertes Objekt, das das Verhalten einer realen Abhängigkeit nachahmt. Mocks werden üblicherweise in Unit-Tests verwendet, um den zu testenden Code von seinen externen Abhängigkeiten zu isolieren, sodass Sie das Verhalten der Abhängigkeit steuern können und langsame oder unvorhersehbare externe Aufrufe vermieden werden.
- Test Doubles: Ein allgemeiner Begriff für jede Art von Objekt, das beim Testen verwendet wird, um ein reales Objekt zu ersetzen. Mocks sind eine spezielle Art von Test Double, neben Stubs, Fakes und Spies.
- Test Fixtures: Ein fester Zustand oder eine Umgebung, die als Basis für die Ausführung von Tests dient. Dies kann das Einrichten von Datenbanken, die Konfiguration externer Dienste oder das Befüllen von Anfangsdaten umfassen.
Unit-Tests von Go-Webanwendungen
Go bietet leistungsstarke integrierte Tools zum Schreiben von Unit-Tests. Das testing
-Paket steht im Mittelpunkt und bietet ein einfaches, aber effektives Framework.
Eine typische Go-Webanwendung beinhaltet oft Handler, Dienste und Repositories. Betrachten wir einen einfachen HTTP-Handler, der mit einem Benutzerservice interagiert.
// user_service.go package main import "errors" type User struct { ID string Name string } // UserService definiert die Schnittstelle für benutzerbezogene Operationen type UserService interface { GetUserByID(id string) (*User, error) CreateUser(name string) (*User, error) } // MockUserService für Tests 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") }
Nun schreiben wir einen HTTP-Handler, der diesen Service nutzt.
// 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) }
Unit-Tests für UserHandler
Um UserHandler
zu testen, müssen wir die UserService
-Abhängigkeit mocken. Dies stellt sicher, dass unser Handler-Test nicht von einer echten Datenbank oder einem externen Dienst abhängt.
// 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 fügt einen Zeilenumbruch hinzu }, { name: "Missing User ID", userID: "", mockService: &MockUserService{}, // Mock-Service wird nicht aufgerufen 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("erwarteter Statuscode %d, erhalten %d", tt.expectedCode, recorder.Code) } if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { if recorder.Body.String() != tt.expectedBody { t.Errorf("erwarteter Body %q, erhalten %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 wird nicht aufgerufen 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("erwarteter Statuscode %d, erhalten %d", tt.expectedCode, recorder.Code) } if reader := bytes.NewReader([]byte(tt.expectedBody)); reader.Len() > 0 { actualBody := bytes.TrimSpace(recorder.Body.Bytes()) // Potenziellen Zeilenumbruch von http.Error entfernen expectedBody := bytes.TrimSpace([]byte(tt.expectedBody)) if !bytes.Equal(actualBody, expectedBody) { t.Errorf("erwarteter Body %q, erhalten %q", string(expectedBody), string(actualBody)) } } }) } }
Dieses Beispiel zeigt:
- Die Verwendung von
httptest.NewRecorder
undhttp.NewRequest
, um HTTP-Anfragen zu simulieren und Antworten aufzufangen, ohne einen echten HTTP-Server zu starten. - Implementierung eines
MockUserService
, um das Verhalten derUserService
-Abhängigkeit zu steuern, was uns die Isolierung vonUserHandler
-Tests ermöglicht. - Tabellengesteuerte Tests (
t.Run
) für eine übersichtliche und umfassende Testsuite.
Integrationstests mit Docker
Obwohl Unit-Tests entscheidend sind, decken sie nicht das vollständige Bild ab. Echte Anwendungen interagieren mit Datenbanken, Message Queues und externen APIs. Integrationstests schließen diese Lücke, indem sie diese Interaktionen testen. Die Verwendung von Docker vereinfacht das Einrichten und Aufräumen dieser externen Abhängigkeiten erheblich und macht Integrationstests zuverlässig und reproduzierbar.
Nehmen wir an, unsere UserService
-Implementierung verwendet eine PostgreSQL-Datenbank.
// real_user_service.go package main import ( "database/sql" "fmt" "log" _ "github.com/lib/pq" // PostgreSQL-Treiber ) // DBUserService implementiert UserService mithilfe einer PostgreSQL-Datenbank 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 erstellt die users-Tabelle, falls sie noch nicht existiert 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 für Testabhängigkeiten
Für Integrationstests werden wir Docker Compose verwenden, um eine dedizierte PostgreSQL-Instanz zu starten. Erstellen Sie eine docker-compose.test.yml
-Datei:
# 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" # Auf einen anderen Port abbilden, um Konflikte mit lokaler Entwicklungs-DB zu vermeiden 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:
Schreiben des Integrationstests
Nun schreiben wir einen Integrationstest für unseren UserHandler
, der den echten DBUserService
verwendet und sich mit dem Docker-Container für PostgreSQL verbindet.
Wir benötigen eine Hilfsfunktion zur Verwaltung der Docker Compose-Dienste.
// integration_test_utils.go package main import ( "fmt" "os/exec" "time" ) // StartTestContainers startet die Docker Compose-Dienste 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.") // Warten, bis die Datenbank einsatzbereit ist 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()) // Zeilenumbruch trimmen fmt.Println("Waiting for db_test to be healthy...") for i := 0; i < 60; i++ { // Bis zu 5 Minuten warten (60 * 5s Intervall) 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 fährt die Docker Compose-Dienste herunter func StopTestContainers() error { cmd := exec.Command("docker-compose", "-f", "docker-compose.test.yml", "down", "-v") // -v entfernt 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 }
Und dann der Integrationstest selbst:
// 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) } // Tests ausführen code := m.Run() if err := StopTestContainers(); err != nil { fmt.Printf("Failed to stop test containers: %v\n", err) // Hier nicht beenden, falls Tests bestanden wurden. Nur den Fehler protokollieren. } os.Exit(code) } func TestUserHandler_Integration(t *testing.T) { // Echten DBUserService initialisieren userService, err := NewDBUserService(testDSN) if err != nil { t.Fatalf("Failed to initialize DBUserService: %v", err) } defer userService.DB.Close() // Sicherstellen, dass das Schema für jeden Testfunktionslauf bereinigt wird 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("erwarteter Statuscode %d, erhalten %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) { // Zuerst einen Benutzer direkt über den Service oder einen vorherigen Integrationstest erstellen 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("erwarteter Statuscode %d, erhalten %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("erwarteter Statuscode %d, erhalten %d", http.StatusNotFound, recorder.Code) } expectedBody := "User not found\n" if recorder.Body.String() != expectedBody { t.Errorf("erwarteter Body %q, erhalten %q", expectedBody, recorder.Body.String()) } }) }
Um diese Integrationstests auszuführen:
- Stellen Sie sicher, dass Docker läuft.
- Wechseln Sie in Ihr Projektverzeichnis.
- Führen Sie
go test -v -run Integration ./...
aus (oder speziellgo test -v -run Integration handlers_integration_test.go
).
Erklärung:
- Die Funktion
TestMain
inhandlers_integration_test.go
ist besonders. Sie wird ausgeführt, bevor Testfunktionen im Paket ausgeführt werden. Wir verwenden sie, um unsere Docker Compose-Dienste zu orchestrieren und zu starten/stoppen. StartTestContainers
verwendetdocker-compose up -d
, um die Dienste im Hintergrund zu starten, und enthält eine Schleife für die Zustandsprüfung, um auf die Bereitschaft der Datenbank zu warten.StopTestContainers
verwendetdocker-compose down -v
, um die Dienste herunterzufahren und zugehörige Volumes zu entfernen, was einen sauberen Zustand für nachfolgende Testläufe gewährleistet.- Innerhalb von
TestUserHandler_Integration
initialisieren wir einen echtenDBUserService
, der mit unserer Docker-Container-Version von PostgreSQL verbunden ist. - Wichtig ist, dass wir für jeden Testlauf oder Testfall sicherstellen, dass das Datenbankschema zurückgesetzt wird (
DROP TABLE IF EXISTS users;
undInitSchema()
). Dies sorgt für Testisolation und verhindert, dass ein Test einen anderen beeinflusst. - Wir verwenden erneut
httptest.NewRecorder
undhttp.NewRequest
, aber diesmal interagiert derUserHandler
mit einer echten Datenbank anstelle eines Mocks.
Fazit
Eine gut strukturierte Teststrategie ist entscheidend für die Entwicklung zuverlässiger Go-Webanwendungen. Durch die Beherrschung von Unit-Tests mit der Standardbibliothek von Go und den sorgfältige Einsatz von Mocks zur Isolierung von Abhängigkeiten bauen Sie eine schnelle und stabile Grundlage. Die Erweiterung mit Docker-gestützten Integrationstests ermöglicht es Ihnen, den gesamten Stapel zu überprüfen, einschließlich der Interaktionen mit echten externen Diensten, und stellt sicher, dass Ihre Anwendung in einer produktionsähnlichen Umgebung korrekt funktioniert. Dieser mehrschichtige Testansatz, von der kleinsten Einheit bis zum vollständigen System, führt letztendlich zu robusteren, wartbareren und vertrauenswürdigeren Webanwendungen.