This document explains the testing strategy and practices for the go-wf project.
The project uses a comprehensive testing approach with two types of tests:
- Unit Tests - Fast, isolated tests with mocked dependencies
- Integration Tests - Tests with real dependencies using testcontainers
- Minimum: 80%
- Target: 85%+
- Goal: Comprehensive coverage of all critical paths
Run fast unit tests without external dependencies:
task testThis will:
- Run all tests without build tags
- Generate coverage report
- Create HTML coverage report at
output/coverage.html
Run integration tests with Docker containers:
task test:integrationRequirements:
- Docker must be running
- Sufficient resources (4GB+ RAM recommended)
- Network connectivity for pulling images
Run complete test suite:
task test:allThis runs both unit and integration tests with combined coverage report.
View coverage:
# Open in browser
open output/coverage-all.html
# Or view in terminal
go tool cover -func=output/coverage-all.outpackage/
├── package.go # Implementation
├── package_test.go # Unit tests (no build tag)
├── integration_test.go # Integration tests (//go:build integration)
├── helpers_test.go # Test utilities
└── testdata/ # Test fixtures
└── sample.json
No build tag required - runs by default:
package mypackage
import "testing"
func TestMyFunction(t *testing.T) {
// Unit test
}Use //go:build integration tag:
//go:build integration
package mypackage_test
import (
"testing"
"context"
"github.com/testcontainers/testcontainers-go"
)
func TestIntegration_MyFeature(t *testing.T) {
// Integration test with containers
}Use table-driven tests for comprehensive coverage:
func TestMyFunction(t *testing.T) {
tests := []struct {
name string
input Input
want Output
wantErr bool
}{
{
name: "valid input",
input: Input{Value: "test"},
want: Output{Result: "expected"},
wantErr: false,
},
{
name: "invalid input",
input: Input{Value: ""},
want: Output{},
wantErr: true,
},
{
name: "edge case - nil",
input: Input{},
want: Output{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MyFunction(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("MyFunction() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MyFunction() = %v, want %v", got, tt.want)
}
})
}
}Use testcontainers for real dependency testing:
//go:build integration
package mypackage_test
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
func TestIntegration_DatabaseOperations(t *testing.T) {
ctx := context.Background()
// Setup container
req := testcontainers.ContainerRequest{
Image: "postgres:16-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "test",
"POSTGRES_USER": "test",
"POSTGRES_DB": "test",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
container, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
t.Fatal(err)
}
// Cleanup
t.Cleanup(func() {
if err := container.Terminate(ctx); err != nil {
t.Logf("failed to terminate container: %s", err)
}
})
// Get connection details
host, err := container.Host(ctx)
if err != nil {
t.Fatal(err)
}
port, err := container.MappedPort(ctx, "5432")
if err != nil {
t.Fatal(err)
}
// Run tests with real database
// ...
}Create reusable test utilities:
// helpers_test.go
package mypackage
import "testing"
func setupTestDB(t *testing.T) *DB {
t.Helper()
db := NewDB(":memory:")
t.Cleanup(func() {
db.Close()
})
return db
}
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}- Use descriptive test names
- Format:
TestFunctionName_Scenario - Table test cases: describe the scenario
func TestNewClient_ValidConfig(t *testing.T) { }
func TestNewClient_InvalidConfig(t *testing.T) { }- Tests should not depend on each other
- Use
t.Cleanup()for resource cleanup - Don't share state between tests
Always test error cases:
tests := []struct {
name string
wantErr bool
errMsg string
}{
{
name: "success",
wantErr: false,
},
{
name: "invalid input",
wantErr: true,
errMsg: "invalid input",
},
}Test boundary conditions:
- Empty strings
- Nil values
- Zero values
- Maximum values
- Negative numbers
- Concurrent access
Run with coverage to identify gaps:
task test:all
open output/coverage-all.htmlFocus on:
- Critical business logic
- Error handling paths
- Edge cases
- Public API surface
Define interfaces for dependencies:
type Storage interface {
Get(key string) (string, error)
Set(key, value string) error
}
type Client struct {
storage Storage
}Create mock implementations:
type mockStorage struct {
data map[string]string
err error
}
func (m *mockStorage) Get(key string) (string, error) {
if m.err != nil {
return "", m.err
}
return m.data[key], nil
}
func (m *mockStorage) Set(key, value string) error {
if m.err != nil {
return m.err
}
m.data[key] = value
return nil
}Use in tests:
func TestClient_Get(t *testing.T) {
mock := &mockStorage{
data: map[string]string{"key": "value"},
}
client := &Client{storage: mock}
got, err := client.Get("key")
if err != nil {
t.Fatal(err)
}
if got != "value" {
t.Errorf("got %v, want %v", got, "value")
}
}Store test fixtures in testdata/:
package/
└── testdata/
├── valid.json
├── invalid.json
└── sample.yaml
Load in tests:
func TestParseConfig(t *testing.T) {
data, err := os.ReadFile("testdata/valid.json")
if err != nil {
t.Fatal(err)
}
cfg, err := ParseConfig(data)
// Test cfg
}Tests run automatically on:
- Pull Requests - All tests must pass
- Main branch pushes - Full test suite + release
- Local development - Run before committing
CI configuration: .github/workflows/release.yml
# Clean build cache
go clean -testcache
# Run with verbose output
go test -v ./...
# Run specific test
go test -v -run TestMyFunction ./package# Check Docker is running
docker info
# Check available resources
docker system df
# Clean up containers
docker system prune -a# Ensure output directory exists
mkdir -p output
# Run with explicit coverage flags
go test -coverprofile=output/coverage.out ./...✅ Write table-driven tests
✅ Use testcontainers for integration tests
✅ Aim for 85%+ coverage
✅ Test error cases and edge conditions
✅ Keep tests independent and fast
✅ Run task check before committing