Test Automation and CI/CD Integration

Integrating tests into your CI/CD pipeline ensures code quality throughout the development lifecycle.

Configuring GitHub Actions for Go Testing

GitHub Actions provides a powerful platform for automating Go tests:

# .github/workflows/go-test.yml
name: Go Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    services:
      # Add PostgreSQL service container
      postgres:
        image: postgres:14
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        # Set health checks to wait until postgres has started
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      
      # Add Redis service container
      redis:
        image: redis:6
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
    - uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: '1.20"
        cache: true

    - name: Install dependencies
      run: go mod download

    - name: Verify dependencies
      run: go mod verify

    - name: Run linters
      uses: golangci/golangci-lint-action@v3
      with:
        version: latest
        args: --timeout=5m

    - name: Run unit tests
      run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...

    - name: Run integration tests
      run: go test -v -tags=integration ./...
      env:
        POSTGRES_HOST: localhost
        POSTGRES_PORT: 5432
        POSTGRES_USER: testuser
        POSTGRES_PASSWORD: testpass
        POSTGRES_DB: testdb
        REDIS_HOST: localhost
        REDIS_PORT: 6379

    - name: Upload coverage to Codecov
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.txt
        flags: unittests
        fail_ci_if_error: true

  benchmark:
    name: Performance Benchmarks
    runs-on: ubuntu-latest
    needs: test
    steps:
    - uses: actions/checkout@v3

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: '1.20"
        cache: true

    - name: Install dependencies
      run: go mod download

    - name: Run benchmarks
      run: |
        go test -bench=. -benchmem ./... > benchmark_results.txt
        cat benchmark_results.txt

    - name: Store benchmark result
      uses: actions/upload-artifact@v3
      with:
        name: benchmark-results
        path: benchmark_results.txt

This GitHub Actions workflow demonstrates:

  1. Multi-service testing: Setting up PostgreSQL and Redis for integration tests
  2. Test separation: Running unit and integration tests separately
  3. Race detection: Using -race to find concurrency issues
  4. Coverage reporting: Generating and uploading coverage reports
  5. Performance tracking: Running and storing benchmark results

Makefile for Local Test Automation

A well-structured Makefile simplifies local testing:

# Makefile

# Variables
GOCMD=go
GOBUILD=$(GOCMD) build
GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
GOMOD=$(GOCMD) mod
GOLINT=golangci-lint
BINARY_NAME=myapp
COVERAGE_FILE=coverage.txt

# Build targets
.PHONY: all build clean test coverage lint integration benchmark docker-test

all: lint test build

build:
	$(GOBUILD) -o $(BINARY_NAME) -v ./cmd/main.go

clean:
	$(GOCLEAN)
	rm -f $(BINARY_NAME)
	rm -f $(COVERAGE_FILE)

# Dependency management
deps:
	$(GOMOD) download
	$(GOMOD) tidy

# Testing targets
test: deps
	$(GOTEST) -v -race -coverprofile=$(COVERAGE_FILE) -covermode=atomic ./...

test-short:
	$(GOTEST) -v -short ./...

integration: deps
	$(GOTEST) -v -tags=integration ./...

benchmark:
	$(GOTEST) -bench=. -benchmem ./...

benchmark-compare:
	$(GOTEST) -bench=. -benchmem ./... > old.txt
	@echo "Make your changes, then run: make benchmark-compare-after"

benchmark-compare-after:
	$(GOTEST) -bench=. -benchmem ./... > new.txt
	benchstat old.txt new.txt

coverage: test
	go tool cover -html=$(COVERAGE_FILE)

# Code quality
lint:
	$(GOLINT) run

# Docker-based testing
docker-test:
	docker-compose -f docker-compose.test.yml up --build --abort-on-container-exit

# Generate mocks
generate-mocks:
	mockery --all --keeptree --outpkg=mocks --output=./internal/mocks

# Database migrations
migrate-up:
	migrate -path ./migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" up

migrate-down:
	migrate -path ./migrations -database "postgres://user:pass@localhost:5432/dbname?sslmode=disable" down

This Makefile demonstrates:

  1. Comprehensive targets: Covering build, test, and quality assurance
  2. Test variations: Supporting different test types and modes
  3. Benchmark comparison: Facilitating before/after performance analysis
  4. Docker integration: Running tests in containerized environments
  5. Development utilities: Including mock generation and database migrations

Continuous Testing with Test Containers

For more complex integration testing in CI/CD environments:

package integration

import (
	"context"
	"database/sql"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

// TestMain sets up the test environment for all integration tests
func TestMain(m *testing.M) {
	// Skip integration tests in short mode
	if testing.Short() {
		fmt.Println("Skipping integration tests in short mode")
		os.Exit(0)
	}

	// Check if we're running in CI environment
	inCI := os.Getenv("CI") == "true"

	var cleanup func()
	var err error

	// Set up test environment
	if inCI {
		// In CI, use the services provided by the CI environment
		err = setupCIEnvironment()
	} else {
		// Locally, use test containers
		cleanup, err = setupLocalTestContainers()
	}

	if err != nil {
		fmt.Printf("Failed to set up test environment: %v\n", err)
		os.Exit(1)
	}

	// Run tests
	code := m.Run()

	// Clean up
	if cleanup != nil {
		cleanup()
	}

	os.Exit(code)
}

// setupCIEnvironment configures the test environment for CI
func setupCIEnvironment() error {
	// In CI, services are already running, just set up connections
	dbHost := os.Getenv("POSTGRES_HOST")
	dbPort := os.Getenv("POSTGRES_PORT")
	dbUser := os.Getenv("POSTGRES_USER")
	dbPass := os.Getenv("POSTGRES_PASSWORD")
	dbName := os.Getenv("POSTGRES_DB")

	// Construct connection string
	dbURI := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
		dbUser, dbPass, dbHost, dbPort, dbName)

	// Set environment variable for tests to use
	os.Setenv("DATABASE_URL", dbURI)

	// Verify connection
	db, err := sql.Open("postgres", dbURI)
	if err != nil {
		return fmt.Errorf("failed to connect to database: %w", err)
	}
	defer db.Close()

	// Check connection
	if err := db.Ping(); err != nil {
		return fmt.Errorf("failed to ping database: %w", err)
	}

	fmt.Println("Successfully connected to CI database")
	return nil
}

// setupLocalTestContainers creates and configures test containers
func setupLocalTestContainers() (func(), error) {
	ctx := context.Background()
	var containers []testcontainers.Container
	var err error

	// Start PostgreSQL container
	pgContainer, pgURI, err := startPostgresContainer(ctx)
	if err != nil {
		return nil, err
	}
	containers = append(containers, pgContainer)
	os.Setenv("DATABASE_URL", pgURI)

	// Start Redis container
	redisContainer, redisURI, err := startRedisContainer(ctx)
	if err != nil {
		// Clean up PostgreSQL container
		for _, c := range containers {
			c.Terminate(ctx)
		}
		return nil, err
	}
	containers = append(containers, redisContainer)
	os.Setenv("REDIS_URL", redisURI)

	// Return cleanup function
	cleanup := func() {
		for _, c := range containers {
			c.Terminate(ctx)
		}
	}

	fmt.Println("Test containers started successfully")
	return cleanup, nil
}

// startPostgresContainer starts a PostgreSQL container
func startPostgresContainer(ctx context.Context) (testcontainers.Container, string, error) {
	req := testcontainers.ContainerRequest{
		Image:        "postgres:14-alpine",
		ExposedPorts: []string{"5432/tcp"},
		Env: map[string]string{
			"POSTGRES_USER":     "testuser",
			"POSTGRES_PASSWORD": "testpass",
			"POSTGRES_DB":       "testdb",
		},
		WaitingFor: wait.ForLog("database system is ready to accept connections"),
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, "", fmt.Errorf("failed to start postgres container: %w", err)
	}

	// Get host and port
	host, err := container.Host(ctx)
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to get container host: %w", err)
	}

	port, err := container.MappedPort(ctx, "5432")
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to get container port: %w", err)
	}

	// Construct connection URI
	uri := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
		"testuser", "testpass", host, port.Port(), "testdb")

	// Verify connection
	db, err := sql.Open("postgres", uri)
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to connect to database: %w", err)
	}
	defer db.Close()

	// Wait for database to be ready
	for i := 0; i < 10; i++ {
		err = db.Ping()
		if err == nil {
			break
		}
		time.Sleep(time.Second)
	}
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to ping database: %w", err)
	}

	return container, uri, nil
}

// startRedisContainer starts a Redis container
func startRedisContainer(ctx context.Context) (testcontainers.Container, string, error) {
	req := testcontainers.ContainerRequest{
		Image:        "redis:6-alpine",
		ExposedPorts: []string{"6379/tcp"},
		WaitingFor:   wait.ForLog("Ready to accept connections"),
	}

	container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})
	if err != nil {
		return nil, "", fmt.Errorf("failed to start redis container: %w", err)
	}

	// Get host and port
	host, err := container.Host(ctx)
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to get container host: %w", err)
	}

	port, err := container.MappedPort(ctx, "6379")
	if err != nil {
		container.Terminate(ctx)
		return nil, "", fmt.Errorf("failed to get container port: %w", err)
	}

	// Construct connection URI
	uri := fmt.Sprintf("redis://%s:%s", host, port.Port())

	return container, uri, nil
}

This test container setup demonstrates:

  1. Environment detection: Adapting to CI or local environments
  2. Container orchestration: Managing multiple service containers
  3. Connection verification: Ensuring services are ready before testing
  4. Resource cleanup: Properly terminating containers after tests
  5. Configuration sharing: Setting environment variables for tests to use

Test Matrix with Multiple Go Versions

Testing across multiple Go versions ensures compatibility:

# .github/workflows/go-matrix.yml
name: Go Matrix Tests

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    name: Test Go ${{ matrix.go-version }} on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        go-version: ['1.18', '1.19', '1.20']
        os: [ubuntu-latest, macos-latest, windows-latest]

    steps:
    - uses: actions/checkout@v3

    - name: Set up Go ${{ matrix.go-version }}
      uses: actions/setup-go@v3
      with:
        go-version: ${{ matrix.go-version }}
        cache: true

    - name: Install dependencies
      run: go mod download

    - name: Run tests
      run: go test -v ./...

  compatibility:
    name: API Compatibility Check
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
      with:
        fetch-depth: 0

    - name: Set up Go
      uses: actions/setup-go@v3
      with:
        go-version: '1.20"
        cache: true

    - name: Check API compatibility
      uses: smola/go-compat-check@v1
      with:
        go-version: '1.18"

This matrix testing approach demonstrates:

  1. Version matrix: Testing across multiple Go versions
  2. Platform matrix: Testing on different operating systems
  3. Compatibility checking: Ensuring API backward compatibility
  4. Parallel execution: Running tests concurrently for efficiency
  5. Conditional testing: Adapting tests to different environments