# Go API Testing Guide with Ginkgo + Gomega A comprehensive guide to building and testing Go APIs using behaviour-driven development with Ginkgo and Gomega, following contract-first development principles. ## Table of Contents 1. [Overview](#overview) 2. [Project Structure](#project-structure) 3. [Dependencies and Setup](#dependencies-and-setup) 4. [Contract-First Development](#contract-first-development) 5. [Testing Layers](#testing-layers) 6. [Integration Testing with Ginkgo](#integration-testing-with-ginkgo) 7. [Unit Testing Components](#unit-testing-components) 8. [Test Data Management](#test-data-management) 9. [Best Practices](#best-practices) 10. [Running Tests](#running-tests) 11. [CI/CD Integration](#cicd-integration) 12. [Advanced Testing Patterns](#Advanced%20Testing%20Patterns) 13. [Documentation Links](#Documentation%20Links) --- ## Overview This guide demonstrates a **contract-first, behaviour-driven approach** to API development in Go using: - **OpenAPI specification** as the single source of truth - **Ginkgo + Gomega** for expressive, behaviour-driven tests - **oapi-codegen** for generating server interfaces from OpenAPI - **Testify** for additional assertions and test utilities - **Integration tests** that validate the entire HTTP stack - **Unit tests** for business logic components ### Key Principles 1. **Contract-first**: OpenAPI spec drives development 2. **Outside-in testing**: Start with integration tests, then unit tests 3. **Behaviour-focused**: Tests describe what the API should do, not how 4. **Comprehensive coverage**: Test happy paths, edge cases, and error conditions --- ## Project Structure ```text backend/ ├── cmd/ │ └── server/ │ └── main.go # Application entry point ├── internal/ │ ├── api/ # Generated from OpenAPI │ │ ├── generated.go # oapi-codegen output │ │ └── oapi-codegen.yaml # Generation config │ ├── config/ │ │ └── config.go # Application configuration │ ├── database/ │ │ ├── connection.go # Database setup │ │ └── migrations/ # Database migrations │ ├── handlers/ # HTTP request handlers │ │ ├── activities.go │ │ ├── locations.go │ │ └── middleware.go │ ├── models/ # Domain models │ │ ├── activity.go │ │ └── location.go │ ├── repositories/ # Data access layer │ │ ├── activity_repository.go │ │ └── location_repository.go │ ├── services/ # Business logic │ │ ├── activity_service.go │ │ └── location_service.go │ └── server/ │ └── server.go # HTTP server setup ├── test/ │ ├── integration/ # API integration tests │ │ ├── activities_test.go │ │ ├── locations_test.go │ │ └── suite_test.go # Ginkgo test suite │ ├── unit/ # Unit tests │ │ ├── handlers/ │ │ ├── services/ │ │ └── repositories/ │ ├── fixtures/ # Test data │ │ ├── activities.json │ │ └── locations.json │ └── testutils/ # Test utilities │ ├── database.go # Test database setup │ ├── server.go # Test server utilities │ └── assertions.go # Custom matchers ├── shared/ │ └── api/ │ └── openapi.yaml # API specification ├── go.mod ├── go.sum ├── Makefile └── README.md ``` --- ## Dependencies and Setup ### go.mod Dependencies ```go module github.com/your-org/some-things-to-do/backend go 1.21 require ( github.com/gin-gonic/gin v1.9.1 github.com/oapi-codegen/oapi-codegen/v2 v2.1.0 github.com/onsi/ginkgo/v2 v2.13.2 github.com/onsi/gomega v1.30.0 github.com/stretchr/testify v1.8.4 github.com/google/uuid v1.4.0 github.com/lib/pq v1.10.9 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/testcontainers/testcontainers-go v0.26.0 github.com/joho/godotenv v1.4.0 ) ``` ### Installation Commands ```bash # Install Ginkgo CLI go install github.com/onsi/ginkgo/v2/ginkgo@latest # Install oapi-codegen go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest # Bootstrap Ginkgo test suite cd test/integration ginkgo bootstrap # Generate test files ginkgo generate activities ginkgo generate locations ``` --- ## Contract-First Development ### 1. Generate Server Code from OpenAPI **internal/api/oapi-codegen.yaml:** ```yaml package: api output: generated.go generate: gin-server: true models: true embedded-spec: true ``` **Makefile target:** ```makefile .PHONY: codegen codegen: oapi-codegen -config internal/api/oapi-codegen.yaml ../shared/api/openapi.yaml ``` ### 2. Implement Server Interface **internal/server/server.go:** ```go package server import ( "github.com/gin-gonic/gin" "github.com/your-org/some-things-to-do/backend/internal/api" "github.com/your-org/some-things-to-do/backend/internal/handlers" ) type Server struct { router *gin.Engine handlers *handlers.Handlers } func New(handlers *handlers.Handlers) *Server { router := gin.Default() // Register generated routes api.RegisterHandlers(router, handlers) return &Server{ router: router, handlers: handlers, } } func (s *Server) Start(addr string) error { return s.router.Run(addr) } func (s *Server) Handler() *gin.Engine { return s.router } ``` --- ## Testing Layers ### Integration Tests (API Level) Test the complete HTTP request/response cycle including: - Request parsing and validation - Business logic execution - Response formatting - Error handling - Database interactions ### Unit Tests (Component Level) Test individual components in isolation: - Handlers (with mocked services) - Services (with mocked repositories) - Repositories (with test database) --- ## Integration Testing with Ginkgo ### Test Suite Setup **test/integration/suite_test.go:** ```go package integration_test import ( "context" "database/sql" "fmt" "net/http" "net/http/httptest" "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/your-org/some-things-to-do/backend/internal/config" "github.com/your-org/some-things-to-do/backend/internal/handlers" "github.com/your-org/some-things-to-do/backend/internal/server" "github.com/your-org/some-things-to-do/backend/test/testutils" ) func TestIntegration(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Integration Suite") } var ( testServer *httptest.Server db *sql.DB pgContainer *postgres.PostgresContainer client *http.Client ) var _ = BeforeSuite(func() { var err error // Start PostgreSQL container pgContainer, err = postgres.RunContainer(context.Background(), testcontainers.WithImage("postgres:15-alpine"), postgres.WithDatabase("testdb"), postgres.WithUsername("testuser"), postgres.WithPassword("testpass"), ) Expect(err).ToNot(HaveOccurred()) // Get connection string connStr, err := pgContainer.ConnectionString(context.Background(), "sslmode=disable") Expect(err).ToNot(HaveOccurred()) // Setup database db, err = testutils.SetupTestDB(connStr) Expect(err).ToNot(HaveOccurred()) // Create server cfg := &config.Config{DatabaseURL: connStr} handlers := handlers.New(cfg, db) srv := server.New(handlers) // Start test server testServer = httptest.NewServer(srv.Handler()) client = &http.Client{} }) var _ = AfterSuite(func() { if testServer != nil { testServer.Close() } if db != nil { db.Close() } if pgContainer != nil { pgContainer.Terminate(context.Background()) } }) var _ = BeforeEach(func() { // Clean database before each test testutils.CleanDatabase(db) // Seed with basic test data testutils.SeedTestData(db) }) ``` ### Activities API Tests **test/integration/activities_test.go:** ```go package integration_test import ( "encoding/json" "fmt" "net/http" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/your-org/some-things-to-do/backend/internal/api" "github.com/your-org/some-things-to-do/backend/test/testutils" ) var _ = Describe("Activities API", func() { Describe("GET /locations/{locationId}/activities", func() { Context("when the location exists", func() { It("should return all activities for the location", func() { // Given locationID := "brighton-uk" // When resp, err := client.Get(fmt.Sprintf("%s/locations/%s/activities", testServer.URL, locationID)) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) var activities []api.Activity err = json.NewDecoder(resp.Body).Decode(&activities) Expect(err).ToNot(HaveOccurred()) Expect(activities).ToNot(BeEmpty()) // Verify each activity has required fields for _, activity := range activities { Expect(activity.Id).ToNot(BeEmpty()) Expect(activity.Title).ToNot(BeEmpty()) Expect(activity.Category).ToNot(BeEmpty()) Expect(activity.LocationId).To(Equal(locationID)) } }) Context("when filtering by category", func() { It("should return only activities matching the category", func() { // Given locationID := "brighton-uk" category := "entertainment" // When url := fmt.Sprintf("%s/locations/%s/activities?category=%s", testServer.URL, locationID, category) resp, err := client.Get(url) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) var activities []api.Activity err = json.NewDecoder(resp.Body).Decode(&activities) Expect(err).ToNot(HaveOccurred()) // All returned activities should match the category for _, activity := range activities { Expect(string(activity.Category)).To(Equal(category)) } }) It("should return empty list for non-existent category", func() { // Given locationID := "brighton-uk" category := "non-existent" // When url := fmt.Sprintf("%s/locations/%s/activities?category=%s", testServer.URL, locationID, category) resp, err := client.Get(url) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) var activities []api.Activity err = json.NewDecoder(resp.Body).Decode(&activities) Expect(err).ToNot(HaveOccurred()) Expect(activities).To(BeEmpty()) }) }) }) Context("when the location does not exist", func() { It("should return 404 not found", func() { // Given locationID := "non-existent" // When resp, err := client.Get(fmt.Sprintf("%s/locations/%s/activities", testServer.URL, locationID)) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) var errorResp api.Error err = json.NewDecoder(resp.Body).Decode(&errorResp) Expect(err).ToNot(HaveOccurred()) Expect(errorResp.Code).To(Equal("NOT_FOUND")) }) }) }) Describe("GET /locations/{locationId}/activities/{id}", func() { Context("when the activity exists", func() { It("should return the activity details", func() { // Given locationID := "brighton-uk" activityID := testutils.GetTestActivityID() // Helper to get seeded data ID // When url := fmt.Sprintf("%s/locations/%s/activities/%s", testServer.URL, locationID, activityID) resp, err := client.Get(url) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusOK)) var activity api.Activity err = json.NewDecoder(resp.Body).Decode(&activity) Expect(err).ToNot(HaveOccurred()) Expect(activity.Id).To(Equal(activityID)) Expect(activity.LocationId).To(Equal(locationID)) Expect(activity.Title).ToNot(BeEmpty()) Expect(activity.Description).ToNot(BeNil()) // Verify venue information if present if activity.Venue != nil { Expect(activity.Venue.Name).ToNot(BeNil()) Expect(activity.Venue.Address).ToNot(BeNil()) } }) }) Context("when the activity does not exist", func() { It("should return 404 not found", func() { // Given locationID := "brighton-uk" activityID := "550e8400-e29b-41d4-a716-446655440999" // Non-existent UUID // When url := fmt.Sprintf("%s/locations/%s/activities/%s", testServer.URL, locationID, activityID) resp, err := client.Get(url) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) }) }) Context("when the activity ID is invalid", func() { It("should return 400 bad request", func() { // Given locationID := "brighton-uk" activityID := "invalid-uuid" // When url := fmt.Sprintf("%s/locations/%s/activities/%s", testServer.URL, locationID, activityID) resp, err := client.Get(url) // Then Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) }) }) }) }) ``` --- ## Unit Testing Components ### Handler Unit Tests **test/unit/handlers/activities_test.go:** ```go package handlers_test import ( "encoding/json" "net/http" "net/http/httptest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" "github.com/your-org/some-things-to-do/backend/internal/handlers" "github.com/your-org/some-things-to-do/backend/internal/models" "github.com/your-org/some-things-to-do/backend/test/mocks" ) var _ = Describe("Activities Handler", func() { var ( handler *handlers.ActivityHandler mockService *mocks.ActivityService recorder *httptest.ResponseRecorder ) BeforeEach(func() { mockService = &mocks.ActivityService{} handler = handlers.NewActivityHandler(mockService) recorder = httptest.NewRecorder() }) Describe("GetActivities", func() { Context("when service returns activities successfully", func() { It("should return 200 with activities list", func() { // Given expectedActivities := []models.Activity{ { ID: "123", Title: "Test Activity", Category: "entertainment", LocationID: "brighton-uk", }, } mockService.On("GetByLocation", "brighton-uk", mock.AnythingOfType("*models.ActivityFilter")). Return(expectedActivities, nil) // When req := httptest.NewRequest("GET", "/locations/brighton-uk/activities", nil) handler.GetActivities(recorder, req) // Then Expect(recorder.Code).To(Equal(http.StatusOK)) var activities []models.Activity err := json.Unmarshal(recorder.Body.Bytes(), &activities) Expect(err).ToNot(HaveOccurred()) Expect(activities).To(HaveLen(1)) Expect(activities[0].Title).To(Equal("Test Activity")) mockService.AssertExpectations(GinkgoT()) }) }) Context("when service returns an error", func() { It("should return 500 internal server error", func() { // Given mockService.On("GetByLocation", mock.Anything, mock.Anything). Return(nil, errors.New("database error")) // When req := httptest.NewRequest("GET", "/locations/brighton-uk/activities", nil) handler.GetActivities(recorder, req) // Then Expect(recorder.Code).To(Equal(http.StatusInternalServerError)) mockService.AssertExpectations(GinkgoT()) }) }) }) }) ``` ### Service Unit Tests **test/unit/services/activity_service_test.go:** ```go package services_test import ( "errors" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" "github.com/your-org/some-things-to-do/backend/internal/models" "github.com/your-org/some-things-to-do/backend/internal/services" "github.com/your-org/some-things-to-do/backend/test/mocks" ) var _ = Describe("Activity Service", func() { var ( service *services.ActivityService mockRepo *mocks.ActivityRepository ) BeforeEach(func() { mockRepo = &mocks.ActivityRepository{} service = services.NewActivityService(mockRepo) }) Describe("GetByLocation", func() { Context("when repository returns activities", func() { It("should return the activities", func() { // Given locationID := "brighton-uk" filter := &models.ActivityFilter{} expectedActivities := []models.Activity{ {ID: "123", Title: "Test Activity", LocationID: locationID}, } mockRepo.On("FindByLocation", locationID, filter).Return(expectedActivities, nil) // When activities, err := service.GetByLocation(locationID, filter) // Then Expect(err).ToNot(HaveOccurred()) Expect(activities).To(Equal(expectedActivities)) mockRepo.AssertExpectations(GinkgoT()) }) }) Context("when repository returns error", func() { It("should return the error", func() { // Given locationID := "brighton-uk" filter := &models.ActivityFilter{} expectedError := errors.New("database connection failed") mockRepo.On("FindByLocation", locationID, filter).Return(nil, expectedError) // When activities, err := service.GetByLocation(locationID, filter) // Then Expect(err).To(Equal(expectedError)) Expect(activities).To(BeNil()) mockRepo.AssertExpectations(GinkgoT()) }) }) Context("when filtering by category", func() { It("should pass the filter to repository", func() { // Given locationID := "brighton-uk" filter := &models.ActivityFilter{Category: "entertainment"} mockRepo.On("FindByLocation", locationID, filter).Return([]models.Activity{}, nil) // When _, err := service.GetByLocation(locationID, filter) // Then Expect(err).ToNot(HaveOccurred()) mockRepo.AssertExpectations(GinkgoT()) }) }) }) }) ``` --- ## Test Data Management ### Test Fixtures **test/fixtures/activities.json:** ```json [ { "id": "550e8400-e29b-41d4-a716-446655440101", "title": "Live Jazz at The Mesmerist", "description": "Enjoy an evening of live jazz music", "category": "entertainment", "location_id": "brighton-uk", "venue": { "name": "The Mesmerist", "address": "1-3 Prince Albert St, Brighton BN1 1HE", "area": "The Lanes" }, "tags": ["live music", "jazz", "cocktails"], "created_at": "2024-05-12T10:30:00Z", "updated_at": "2024-05-12T14:45:00Z" } ] ``` ### Test Utilities **test/testutils/database.go:** ```go package testutils import ( "database/sql" "encoding/json" "io/ioutil" "path/filepath" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/your-org/some-things-to-do/backend/internal/models" ) func SetupTestDB(connStr string) (*sql.DB, error) { db, err := sql.Open("postgres", connStr) if err != nil { return nil, err } // Run migrations driver, err := postgres.WithInstance(db, &postgres.Config{}) if err != nil { return nil, err } m, err := migrate.NewWithDatabaseInstance( "file://../../internal/database/migrations", "postgres", driver) if err != nil { return nil, err } if err := m.Up(); err != nil && err != migrate.ErrNoChange { return nil, err } return db, nil } func CleanDatabase(db *sql.DB) error { tables := []string{"activities", "locations"} for _, table := range tables { if _, err := db.Exec("DELETE FROM " + table); err != nil { return err } } return nil } func SeedTestData(db *sql.DB) error { // Seed locations if err := seedLocations(db); err != nil { return err } // Seed activities if err := seedActivities(db); err != nil { return err } return nil } func seedActivities(db *sql.DB) error { data, err := ioutil.ReadFile(filepath.Join("../fixtures/activities.json")) if err != nil { return err } var activities []models.Activity if err := json.Unmarshal(data, &activities); err != nil { return err } for _, activity := range activities { // Insert activity into database // Implementation depends on your database schema } return nil } func GetTestActivityID() string { return "550e8400-e29b-41d4-a716-446655440101" } ``` ### Custom Matchers **test/testutils/assertions.go:** ```go package testutils import ( "fmt" "github.com/onsi/gomega/types" "github.com/your-org/some-things-to-do/backend/internal/api" ) func BeValidActivity() types.GomegaMatcher { return &validActivityMatcher{} } type validActivityMatcher struct{} func (matcher *validActivityMatcher) Match(actual interface{}) (success bool, err error) { activity, ok := actual.(api.Activity) if !ok { return false, fmt.Errorf("BeValidActivity matcher expects an api.Activity") } if activity.Id == "" { return false, nil } if activity.Title == "" { return false, nil } if activity.Category == "" { return false, nil } if activity.LocationId == "" { return false, nil } return true, nil } func (matcher *validActivityMatcher) FailureMessage(actual interface{}) (message string) { return fmt.Sprintf("Expected\n\t%#v\nto be a valid activity", actual) } func (matcher *validActivityMatcher) NegatedFailureMessage(actual interface{}) (message string) { return fmt.Sprintf("Expected\n\t%#v\nnot to be a valid activity", actual) } // Usage in tests: // Expect(activity).To(BeValidActivity()) ``` --- ## Best Practices ### 1. Test Organisation - **Group related tests** using nested `Describe` and `Context` blocks - **Use descriptive test names** that explain the behaviour being tested - **Follow the Given-When-Then pattern** for test structure ### 2. Test Data Management - **Use fixtures** for consistent test data - **Clean database** between tests to ensure isolation - **Seed minimal data** needed for each test scenario ### 3. Mocking Strategy - **Mock external dependencies** (databases, HTTP clients, etc.) - **Use interfaces** to make components easily mockable - **Verify mock interactions** to ensure correct behaviour ### 4. Error Testing - **Test error conditions** as thoroughly as success cases - **Verify error responses** match OpenAPI specification - **Test edge cases** and boundary conditions ### 5. Performance Considerations - **Use test containers** for integration tests with real databases - **Parallel test execution** where safe (Ginkgo supports this) - **Focused tests** during development using `FIt` and `FDescribe` --- ## Running Tests ### Local Development ```bash # Run all tests make test # Run only unit tests ginkgo -r test/unit # Run only integration tests ginkgo -r test/integration # Run tests with coverage ginkgo -r --cover --coverprofile=coverage.out # Run specific test file ginkgo test/integration/activities_test.go # Run tests in watch mode ginkgo watch -r test/ # Run focused tests (FIt, FDescribe) ginkgo --focus="should return activities" test/integration # Generate HTML coverage report go tool cover -html=coverage.out -o coverage.html ``` ### Makefile Targets **Makefile:** ```makefile .PHONY: test test-unit test-integration test-coverage # Run all tests test: ginkgo -r # Run unit tests only test-unit: ginkgo -r test/unit # Run integration tests only test-integration: ginkgo -r test/integration # Run tests with coverage test-coverage: ginkgo -r --cover --coverprofile=coverage.out go tool cover -html=coverage.out -o coverage.html # Run tests in CI test-ci: ginkgo -r --randomize-all --randomize-suites --race --trace --cover --coverprofile=coverage.out --junit-report=test-results.xml # Clean test artifacts test-clean: rm -f coverage.out coverage.html test-results.xml ``` --- ## CI/CD Integration ### GitHub Actions Example **.github/workflows/test.yml:** ```yaml name: Tests on: push: branches: [ main, develop ] pull_request: branches: [ main ] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:15 env: POSTGRES_PASSWORD: testpass POSTGRES_USER: testuser POSTGRES_DB: testdb options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: go-version: 1.21 - name: Install dependencies run: go mod download - name: Install Ginkgo run: go install github.com/onsi/ginkgo/v2/ginkgo@latest - name: Run tests run: make test-ci env: DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb?sslmode=disable - name: Upload coverage reports uses: codecov/codecov-action@v3 with: file: ./coverage.out flags: backend name: backend-coverage - name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: test-results path: test-results.xml ``` ### Quality Gates **sonar-project.properties:** ```properties sonar.projectKey=some-things-to-do-backend sonar.projectName=Some Things To Do - Backend sonar.projectVersion=1.0 sonar.sources=internal,cmd sonar.tests=test sonar.exclusions=**/*_test.go,**/generated.go,**/mock_*.go sonar.go.coverage.reportPaths=coverage.out sonar.go.tests.reportPaths=test-results.xml sonar.coverage.exclusions=**/*_test.go,**/main.go,**/generated.go sonar.test.exclusions=**/*_test.go ``` --- ## Advanced Testing Patterns ### 1. Table-Driven Tests with Ginkgo ```go var _ = Describe("Activity Validation", func() { DescribeTable("validating activity fields", func(activity api.Activity, expectedValid bool, expectedErrors []string) { errors := validateActivity(activity) if expectedValid { Expect(errors).To(BeEmpty()) } else { Expect(errors).ToNot(BeEmpty()) for _, expectedError := range expectedErrors { Expect(errors).To(ContainElement(ContainSubstring(expectedError))) } } }, Entry("valid activity", api.Activity{ Id: "123", Title: "Valid Activity", Category: "entertainment", LocationId: "brighton-uk", }, true, []string{}), Entry("missing title", api.Activity{ Id: "123", Title: "", Category: "entertainment", LocationId: "brighton-uk", }, false, []string{"title is required"}), Entry("invalid category", api.Activity{ Id: "123", Title: "Valid Activity", Category: "invalid", LocationId: "brighton-uk", }, false, []string{"invalid category"}), ) }) ``` ### 2. Shared Behaviours ```go // Shared behaviour for testing pagination var _ = SharedBehaviorsFor("paginated endpoint", func() { It("should respect limit parameter", func() { // Test pagination limit }) It("should respect offset parameter", func() { // Test pagination offset }) It("should return total count in headers", func() { // Test total count header }) }) // Use in multiple test files var _ = Describe("Activities API", func() { BehavesLike("paginated endpoint") // Additional activity-specific tests... }) ``` ### 3. Custom Test Helpers **test/testutils/api_helpers.go:** ```go package testutils import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" . "github.com/onsi/gomega" ) type APITestHelper struct { Server *httptest.Server Client *http.Client } func NewAPITestHelper(server *httptest.Server) *APITestHelper { return &APITestHelper{ Server: server, Client: &http.Client{}, } } func (h *APITestHelper) GET(path string) *http.Response { resp, err := h.Client.Get(h.Server.URL + path) Expect(err).ToNot(HaveOccurred()) return resp } func (h *APITestHelper) POST(path string, body interface{}) *http.Response { jsonBody, err := json.Marshal(body) Expect(err).ToNot(HaveOccurred()) resp, err := h.Client.Post( h.Server.URL+path, "application/json", bytes.NewBuffer(jsonBody), ) Expect(err).ToNot(HaveOccurred()) return resp } func (h *APITestHelper) ExpectJSON(resp *http.Response, target interface{}) { defer resp.Body.Close() err := json.NewDecoder(resp.Body).Decode(target) Expect(err).ToNot(HaveOccurred()) } func (h *APITestHelper) ExpectStatus(resp *http.Response, expectedStatus int) { Expect(resp.StatusCode).To(Equal(expectedStatus), fmt.Sprintf("Expected status %d, got %d", expectedStatus, resp.StatusCode)) } // Usage in tests: // helper := NewAPITestHelper(testServer) // resp := helper.GET("/locations/brighton-uk/activities") // helper.ExpectStatus(resp, 200) ``` ### 4. Contract Testing **test/integration/contract_test.go:** ```go package integration_test import ( "encoding/json" "fmt" "net/http" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/getkin/kin-openapi/openapi3" "github.com/getkin/kin-openapi/openapi3filter" ) var _ = Describe("OpenAPI Contract Validation", func() { var ( swagger *openapi3.T ) BeforeEach(func() { var err error swagger, err = openapi3.NewLoader().LoadFromFile("../../shared/api/openapi.yaml") Expect(err).ToNot(HaveOccurred()) err = swagger.Validate(context.Background()) Expect(err).ToNot(HaveOccurred()) }) It("should return responses that match the OpenAPI specification", func() { // Test various endpoints against the spec testCases := []struct { method string path string }{ {"GET", "/locations"}, {"GET", "/locations/brighton-uk"}, {"GET", "/locations/brighton-uk/activities"}, } for _, tc := range testCases { By(fmt.Sprintf("Testing %s %s", tc.method, tc.path)) resp, err := client.Get(testServer.URL + tc.path) Expect(err).ToNot(HaveOccurred()) // Validate response against OpenAPI spec err = validateResponse(swagger, tc.method, tc.path, resp) Expect(err).ToNot(HaveOccurred()) } }) }) func validateResponse(swagger *openapi3.T, method, path string, resp *http.Response) error { route, pathParams, err := swagger.Paths.FindRoute(method, path) if err != nil { return err } // Create request info for validation requestValidationInput := &openapi3filter.RequestValidationInput{ Request: resp.Request, PathParams: pathParams, Route: route, } // Create response validation input responseValidationInput := &openapi3filter.ResponseValidationInput{ RequestValidationInput: requestValidationInput, Status: resp.StatusCode, Header: resp.Header, } if resp.Body != nil { responseValidationInput.SetBodyBytes(readBody(resp)) } return openapi3filter.ValidateResponse(context.Background(), responseValidationInput) } ``` ### 5. Performance Testing **test/performance/load_test.go:** ```go package performance_test import ( "context" "fmt" "net/http" "sync" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Performance Tests", func() { Describe("Activities endpoint", func() { It("should handle concurrent requests efficiently", func() { const ( numRequests = 100 concurrency = 10 ) // Channel to limit concurrency semaphore := make(chan struct{}, concurrency) var wg sync.WaitGroup results := make(chan time.Duration, numRequests) start := time.Now() for i := 0; i < numRequests; i++ { wg.Add(1) go func() { defer wg.Done() // Acquire semaphore semaphore <- struct{}{} defer func() { <-semaphore }() requestStart := time.Now() resp, err := client.Get(testServer.URL + "/locations/brighton-uk/activities") requestDuration := time.Since(requestStart) Expect(err).ToNot(HaveOccurred()) Expect(resp.StatusCode).To(Equal(200)) resp.Body.Close() results <- requestDuration }() } wg.Wait() close(results) totalDuration := time.Since(start) // Collect response times var responseTimes []time.Duration for duration := range results { responseTimes = append(responseTimes, duration) } // Calculate statistics avgResponseTime := calculateAverage(responseTimes) p95ResponseTime := calculatePercentile(responseTimes, 95) fmt.Printf("Total Duration: %v\n", totalDuration) fmt.Printf("Average Response Time: %v\n", avgResponseTime) fmt.Printf("95th Percentile Response Time: %v\n", p95ResponseTime) // Assert performance requirements Expect(avgResponseTime).To(BeNumerically("<", 100*time.Millisecond)) Expect(p95ResponseTime).To(BeNumerically("<", 200*time.Millisecond)) }) }) }) func calculateAverage(durations []time.Duration) time.Duration { if len(durations) == 0 { return 0 } var total time.Duration for _, d := range durations { total += d } return total / time.Duration(len(durations)) } func calculatePercentile(durations []time.Duration, percentile float64) time.Duration { if len(durations) == 0 { return 0 } // Sort durations sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) index := int(float64(len(durations)) * percentile / 100.0) if index >= len(durations) { index = len(durations) - 1 } return durations[index] } ``` --- ## Documentation Links ### Core Testing Tools - **Ginkgo**: [https://onsi.github.io/ginkgo/](https://onsi.github.io/ginkgo/) - **Gomega**: [https://onsi.github.io/gomega/](https://onsi.github.io/gomega/) - **Testify**: [https://github.com/stretchr/testify](https://github.com/stretchr/testify) ### API Development Tools - **oapi-codegen**: [https://github.com/oapi-codegen/oapi-codegen](https://github.com/oapi-codegen/oapi-codegen) - **Gin**: [https://gin-gonic.com/](https://gin-gonic.com/) - **OpenAPI 3.0**: [https://swagger.io/specification/](https://swagger.io/specification/) ### Testing Infrastructure - **Testcontainers Go**: [https://golang.testcontainers.org/](https://golang.testcontainers.org/) - **golang-migrate**: [https://github.com/golang-migrate/migrate](https://github.com/golang-migrate/migrate) - **kin-openapi**: [https://github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) ### Additional Resources - **Go Testing Best Practices**: [https://go.dev/doc/tutorial/add-a-test](https://go.dev/doc/tutorial/add-a-test) - **API Testing Guide**: [https://martinfowler.com/articles/practical-test-pyramid.html](https://martinfowler.com/articles/practical-test-pyramid.html) - **BDD with Go**: [https://semaphoreci.com/community/tutorials/how-to-use-godog-for-behavior-driven-development-in-go](https://semaphoreci.com/community/tutorials/how-to-use-godog-for-behavior-driven-development-in-go) --- ## Summary This guide provides a comprehensive approach to API testing in Go using Ginkgo and Gomega, emphasising: 1. **Contract-first development** with OpenAPI specifications 2. **Behaviour-driven testing** with clear, readable test descriptions 3. **Multiple testing layers** from unit to integration tests 4. **Robust test infrastructure** with proper setup and teardown 5. **Best practices** for maintainable and reliable test suites By following this approach, you'll build APIs that are well-tested, maintainable, and aligned with your specifications, ensuring a smooth integration with your React frontend.