for a quick reference of the language fundamentals.
Key benefits of Go testing:
Built-in support: No external frameworks required
Fast execution: Concurrent test execution by default
Simple syntax: Minimal boilerplate code
Rich tooling: Coverage reports, benchmarks, and profiling
CI/CD friendly: Easy integration with automated pipelines
Project Structure for Go Tests
Go tests live alongside your production code with a clear naming convention:
myproject/
├── go.mod
├── main.go
├── calculator.go
├── calculator_test.go
├── utils/
│ ├── helper.go
│ └── helper_test.go
└── models/
├── user.go
└── user_test.go
Key conventions:
- Test files end with
_test.go
- Tests are in the same package as the code (or use
_testsuffix for black-box testing) - Each source file can have a corresponding test file
Package Testing Approaches
White-box testing (same package):
package calculator
import "testing"
// Can access unexported functions and variables
Black-box testing (external package):
package calculator_test
import (
"testing"
"myproject/calculator"
)
// Can only access exported functions (recommended for public APIs)
Basic Test Structure
Every test function follows this pattern:
package calculator
import "testing"
// Test function must start with "Test"
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
Testing.T methods:
t.Error()/t.Errorf(): Mark test as failed but continue
t.Fatal()/t.Fatalf(): Mark test as failed and stop immediately
t.Log()/t.Logf(): Log output (only shown with-vflag)
t.Skip()/t.Skipf(): Skip the test
t.Parallel(): Run test in parallel with other parallel tests
t.Log is for human-readable test diagnostics. In running services, log/slog and JSON-friendly records are usually a better match for aggregation and incident debugging. See , you can also create type-safe test helpers that work across different data types:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 5, 3, "-", 2, false},
{"multiplication", 4, 3, "*", 12, false},
{"division", 10, 2, "/", 5, false},
{"division by zero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
Advantages:
- Single test function for multiple scenarios
- Easy to add new test cases
- Clear documentation of expected behavior
- Better test organization and maintainability
Running Tests
Basic Commands
# Run tests in current directory
go test
# Run tests with verbose output
go test -v
# Run tests in all subdirectories
go test ./...
# Run specific test
go test -run TestAdd
# Run tests matching pattern
go test -run TestCalculate/addition
# Run tests in parallel (default is GOMAXPROCS)
go test -parallel 4
# Run tests with timeout
go test -timeout 30s
Test Coverage
# Run tests with coverage
go test -cover
# Generate coverage profile
go test -coverprofile=coverage.out
# View coverage in browser
go tool cover -html=coverage.out
# Show coverage by function
go tool cover -func=coverage.out
# Set coverage mode (set, count, atomic)
go test -covermode=count -coverprofile=coverage.out
Useful Flags
-short: Run tests marked withif testing.Short()checks
-race: Enable race detector (finds concurrent access issues)
-cpu: Specify GOMAXPROCS values
-count n: Run each test n times
-failfast: Stop on first test failure
Test Helpers and Setup/Teardown
Helper Functions
Mark helper functions with t.Helper() to improve error reporting:
func assertEqual(t *testing.T, got, want int) {
t.Helper() // This line is reported as the caller
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
func TestMath(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5) // Error line points here
}
Setup and Teardown
func TestMain(m *testing.M) {
// Setup code here
setup()
// Run tests
code := m.Run()
// Teardown code here
teardown()
os.Exit(code)
}
Test Fixtures
func setupTestCase(t *testing.T) func(t *testing.T) {
t.Log("setup test case")
return func(t *testing.T) {
t.Log("teardown test case")
}
}
func TestSomething(t *testing.T) {
teardown := setupTestCase(t)
defer teardown(t)
// Test code here
}
Mocking and Dependency Injection
Interface-Based Mocking
When testing code that interacts with databases, using interfaces makes it easy to create mock implementations. If you're working with PostgreSQL in Go, see our , consider creating interface wrappers to make your code more testable.
Benchmark Tests
Go includes built-in support for benchmarks:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// Run benchmarks
// go test -bench=. -benchmem
Output shows iterations per second and memory allocations.
Best Practices
Write table-driven tests: Use the slice of structs pattern for multiple test cases
Use t.Run for subtests: Better organization and can run subtests selectively
Test exported functions first: Focus on public API behavior
Keep tests simple: Each test should verify one thing
Use meaningful test names: Describe what is being tested and expected outcome
Don't test implementation details: Test behavior, not internals
Use interfaces for dependencies: Makes mocking easier
Aim for high coverage, but quality over quantity: 100% coverage doesn't mean bug-free
Run tests with -race flag: Catch concurrency issues early
Use TestMain for expensive setup: Avoid repeating setup in each test
Example: Complete Test Suite
package user
import (
"errors"
"testing"
)
type User struct {
ID int
Name string
Email string
}
func ValidateUser(u *User) error {
if u.Name == "" {
return errors.New("name cannot be empty")
}
if u.Email == "" {
return errors.New("email cannot be empty")
}
return nil
}
// Test file: user_test.go
func TestValidateUser(t *testing.T) {
tests := []struct {
name string
user *User
wantErr bool
errMsg string
}{
{
name: "valid user",
user: &User{ID: 1, Name: "Alice", Email: "[email protected]"},
wantErr: false,
},
{
name: "empty name",
user: &User{ID: 1, Name: "", Email: "[email protected]"},
wantErr: true,
errMsg: "name cannot be empty",
},
{
name: "empty email",
user: &User{ID: 1, Name: "Alice", Email: ""},
wantErr: true,
errMsg: "email cannot be empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateUser(tt.user)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateUser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && err.Error() != tt.errMsg {
t.Errorf("ValidateUser() error message = %v, want %v", err.Error(), tt.errMsg)
}
})
}
}
Useful Links
Conclusion
Go's testing framework provides everything needed for comprehensive unit testing with minimal setup. By following Go idioms like table-driven tests, using interfaces for mocking, and leveraging built-in tools, you can create maintainable, reliable test suites that grow with your codebase.
These testing practices apply to all types of Go applications, from web services to for related guides on Go project structure, dependency injection, API design, and integration patterns.
SOCIAL SHARE CARD GENERATOR