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:
- Multi-service testing: Setting up PostgreSQL and Redis for integration tests
- Test separation: Running unit and integration tests separately
- Race detection: Using
-race
to find concurrency issues - Coverage reporting: Generating and uploading coverage reports
- 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:
- Comprehensive targets: Covering build, test, and quality assurance
- Test variations: Supporting different test types and modes
- Benchmark comparison: Facilitating before/after performance analysis
- Docker integration: Running tests in containerized environments
- 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:
- Environment detection: Adapting to CI or local environments
- Container orchestration: Managing multiple service containers
- Connection verification: Ensuring services are ready before testing
- Resource cleanup: Properly terminating containers after tests
- 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:
- Version matrix: Testing across multiple Go versions
- Platform matrix: Testing on different operating systems
- Compatibility checking: Ensuring API backward compatibility
- Parallel execution: Running tests concurrently for efficiency
- Conditional testing: Adapting tests to different environments