# 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.