374 lines
9.1 KiB
Markdown
374 lines
9.1 KiB
Markdown
# Go Testing Guidelines
|
|
|
|
This document provides comprehensive testing standards and best practices for writing maintainable, effective tests in Go.
|
|
|
|
## Testing Philosophy
|
|
|
|
Write comprehensive, maintainable tests that ensure code quality and prevent regressions. Focus on testing behavior, not implementation details.
|
|
|
|
## Test Coverage
|
|
|
|
- Write tests that cover both happy paths and edge cases
|
|
- Test error conditions and boundary values
|
|
- Aim for meaningful coverage, not just high percentages
|
|
- Focus on testing behavior, not implementation details
|
|
|
|
## Test Organization
|
|
|
|
### Table-Driven Tests
|
|
|
|
**Use table-driven tests** for better organization and readability. This pattern allows you to:
|
|
- Group related test cases in a single test function with subtests
|
|
- Name test cases descriptively to document expected behavior
|
|
- Easily add new test cases without duplicating test logic
|
|
- Run specific test cases using `-run` flag
|
|
|
|
**Example structure**:
|
|
|
|
```go
|
|
func TestFunctionName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input InputType
|
|
want OutputType
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "successful case with valid input",
|
|
input: validInput,
|
|
want: expectedOutput,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "error case with invalid input",
|
|
input: invalidInput,
|
|
want: zeroValue,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := FunctionName(tt.input)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("FunctionName() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test Naming
|
|
|
|
- Test function names should be clear and descriptive: `TestFunctionName`
|
|
- Subtest names (in table-driven tests) should describe the scenario being tested
|
|
- Use descriptive names that explain what is being tested and expected outcome
|
|
|
|
## Assertions
|
|
|
|
### Using Assertion Libraries
|
|
|
|
**Use assertion libraries** (like `testify/assert`) for clarity and better error messages:
|
|
|
|
```go
|
|
import "github.com/stretchr/testify/assert"
|
|
|
|
assert.NoError(t, err, "operation should not error")
|
|
assert.Equal(t, expected, actual, "values should match")
|
|
assert.True(t, condition, "condition should be true")
|
|
assert.Len(t, slice, 3, "should have exactly 3 elements")
|
|
assert.NotNil(t, obj, "object should not be nil")
|
|
assert.Contains(t, slice, item, "slice should contain item")
|
|
```
|
|
|
|
### Without Assertion Libraries
|
|
|
|
If not using an assertion library, follow these patterns:
|
|
|
|
```go
|
|
// Check for unexpected errors
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Compare values
|
|
if got != want {
|
|
t.Errorf("got %v, want %v", got, want)
|
|
}
|
|
|
|
// Check error expectations
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
|
|
// Verify conditions
|
|
if !condition {
|
|
t.Error("expected condition to be true")
|
|
}
|
|
```
|
|
|
|
### Best Practices for Assertions
|
|
|
|
- Provide descriptive assertion messages when failures need context
|
|
- Prefer explicit assertion methods over manual comparisons
|
|
- Use dedicated methods for error checking (`NoError`, `Error`)
|
|
- Use `t.Fatalf()` for fatal errors that prevent further test execution
|
|
- Use `t.Errorf()` for non-fatal errors to see all failures in a test
|
|
|
|
## Test Types and Organization
|
|
|
|
### Unit Tests
|
|
|
|
Test individual functions and methods in isolation:
|
|
|
|
- **Use mocks/stubs** for external dependencies
|
|
- **Focus on single units** of functionality
|
|
- **Fast execution** (milliseconds)
|
|
- **No external service dependencies**
|
|
|
|
**Example**: Testing a service function with mocked repository:
|
|
|
|
```go
|
|
func TestUserService_CreateUser(t *testing.T) {
|
|
mockRepo := &MockUserRepository{}
|
|
service := NewUserService(mockRepo)
|
|
|
|
// Test the service logic in isolation
|
|
user, err := service.CreateUser(context.Background(), userData)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, user)
|
|
}
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
Test multiple components working together:
|
|
|
|
- **May use real database connections** or external services
|
|
- **Test actual integration points** and workflows
|
|
- **Slower execution** (seconds)
|
|
- **Often use test containers** or local services
|
|
|
|
**Example**: Testing database integration:
|
|
|
|
```go
|
|
//go:build integration
|
|
// +build integration
|
|
|
|
func TestUserRepository_Integration(t *testing.T) {
|
|
db := setupTestDatabase(t)
|
|
defer db.Close()
|
|
|
|
repo := NewUserRepository(db)
|
|
|
|
// Test actual database operations
|
|
user, err := repo.Create(context.Background(), userData)
|
|
assert.NoError(t, err)
|
|
|
|
retrieved, err := repo.GetByID(context.Background(), user.ID)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, user.Email, retrieved.Email)
|
|
}
|
|
```
|
|
|
|
### Separating Test Types with Build Tags
|
|
|
|
**Consider using build tags** to separate test types:
|
|
|
|
```go
|
|
//go:build integration
|
|
// +build integration
|
|
|
|
package mypackage_test
|
|
```
|
|
|
|
Run different test types separately:
|
|
- Unit tests: `go test ./...`
|
|
- Integration tests: `go test -tags=integration ./...`
|
|
- All tests: `go test -tags=integration ./...`
|
|
|
|
## Mocking and Test Doubles
|
|
|
|
### Creating Mocks
|
|
|
|
For interfaces, create mock implementations:
|
|
|
|
```go
|
|
type MockUserRepository struct {
|
|
CreateFunc func(ctx context.Context, user *User) error
|
|
GetFunc func(ctx context.Context, id string) (*User, error)
|
|
}
|
|
|
|
func (m *MockUserRepository) Create(ctx context.Context, user *User) error {
|
|
if m.CreateFunc != nil {
|
|
return m.CreateFunc(ctx, user)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *MockUserRepository) Get(ctx context.Context, id string) (*User, error) {
|
|
if m.GetFunc != nil {
|
|
return m.GetFunc(ctx, id)
|
|
}
|
|
return nil, nil
|
|
}
|
|
```
|
|
|
|
### Using Mocking Libraries
|
|
|
|
Consider using mocking libraries for complex interfaces:
|
|
- `github.com/stretchr/testify/mock` - Popular mocking framework
|
|
- `github.com/golang/mock/gomock` - Official Go mocking library
|
|
|
|
## Test Setup and Teardown
|
|
|
|
### Test Helpers
|
|
|
|
Create helper functions for common setup:
|
|
|
|
```go
|
|
func setupTest(t *testing.T) (*Service, func()) {
|
|
t.Helper()
|
|
|
|
// Setup
|
|
service := NewService(/* dependencies */)
|
|
|
|
// Return cleanup function
|
|
cleanup := func() {
|
|
// Teardown code
|
|
}
|
|
|
|
return service, cleanup
|
|
}
|
|
|
|
func TestSomething(t *testing.T) {
|
|
service, cleanup := setupTest(t)
|
|
defer cleanup()
|
|
|
|
// Test code
|
|
}
|
|
```
|
|
|
|
### Table Test Setup
|
|
|
|
For table-driven tests, use setup functions when needed:
|
|
|
|
```go
|
|
func TestWithSetup(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(t *testing.T) *Service
|
|
// other fields
|
|
}{
|
|
{
|
|
name: "test case 1",
|
|
setup: func(t *testing.T) *Service {
|
|
return NewService(/* specific config */)
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
service := tt.setup(t)
|
|
// test code
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Best Practices
|
|
|
|
1. **Test behavior, not implementation**: Tests should verify outcomes, not internal mechanics
|
|
2. **Keep tests independent**: Each test should run in isolation without depending on others
|
|
3. **Use meaningful test data**: Test values should be realistic and representative
|
|
4. **Test edge cases**: Include boundary values, empty inputs, nil values
|
|
5. **Test error paths**: Verify error handling and error messages
|
|
6. **Keep tests maintainable**: Refactor tests as you refactor code
|
|
7. **Use t.Helper()**: Mark helper functions with `t.Helper()` for better error reporting
|
|
8. **Run tests frequently**: Run tests during development, not just before commits
|
|
9. **Keep tests fast**: Slow tests discourage frequent running
|
|
10. **Document complex test scenarios**: Add comments explaining non-obvious test setups
|
|
|
|
## Common Testing Patterns
|
|
|
|
### Testing with Context
|
|
|
|
```go
|
|
func TestWithContext(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := service.DoSomething(ctx)
|
|
assert.NoError(t, err)
|
|
}
|
|
```
|
|
|
|
### Testing Concurrent Code
|
|
|
|
```go
|
|
func TestConcurrency(t *testing.T) {
|
|
var wg sync.WaitGroup
|
|
errors := make(chan error, 10)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
if err := service.DoWork(); err != nil {
|
|
errors <- err
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errors)
|
|
|
|
for err := range errors {
|
|
t.Errorf("concurrent operation failed: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Testing Time-Dependent Code
|
|
|
|
Use time interfaces or dependency injection:
|
|
|
|
```go
|
|
type Clock interface {
|
|
Now() time.Time
|
|
}
|
|
|
|
// In tests, use a fake clock
|
|
type FakeClock struct {
|
|
current time.Time
|
|
}
|
|
|
|
func (f *FakeClock) Now() time.Time {
|
|
return f.current
|
|
}
|
|
```
|
|
|
|
## Test Coverage Analysis
|
|
|
|
Run coverage analysis to identify untested code:
|
|
|
|
```bash
|
|
# Generate coverage report
|
|
go test -coverprofile=coverage.out ./...
|
|
|
|
# View coverage in browser
|
|
go tool cover -html=coverage.out
|
|
|
|
# Show coverage percentage
|
|
go test -cover ./...
|
|
```
|
|
|
|
Focus coverage efforts on:
|
|
- Critical business logic
|
|
- Complex algorithms
|
|
- Error handling paths
|
|
- Edge cases and boundary conditions
|