Testing Qa
Why Testing & QA Matters
Testing and quality assurance tools catch bugs before production, enforce code standards, detect race conditions, and measure test coverage. Go’s built-in testing tools (vet, race detector) combined with production linters (golangci-lint) provide comprehensive quality gates essential for reliable systems.
Core benefits:
- Early bug detection: Find issues before code review
- Consistent style: Automated enforcement of team standards
- Race condition detection: Catch concurrency bugs in tests
- Coverage measurement: Identify untested code paths
Problem: Without systematic QA, bugs reach production, code quality degrades, and concurrency issues cause intermittent failures.
Solution: Layer Go’s built-in tools (vet, race detector, coverage) with production linters for comprehensive quality checks.
Standard Library: go vet
go vet is Go’s built-in static analysis tool that detects suspicious code patterns.
Basic usage:
go vet
# => Analyzes current package
# => Checks for common mistakes
# => Exit code 0 if no issues, 1 if problems found
go vet ./...
# => Analyzes all packages recursively
# => Common in CI/CD pipelinesWhat go vet catches:
// Example 1: Printf format mismatch
package main
import "fmt"
func main() {
name := "Alice"
age := 30
// => name is string, age is int
fmt.Printf("Name: %d, Age: %s\n", name, age)
// => Wrong: %d expects int, got string
// => Wrong: %s expects string, got int
}go vet
# => Output: ./main.go:8:2: Printf format %d has arg name of wrong type string
# ./main.go:8:2: Printf format %s has arg age of wrong type int
# => Caught at compile-time, not runtimeExample 2: Unreachable code:
package main
func process() int {
return 42
// => Returns immediately
println("This never runs")
// => Unreachable code
// => Dead code, waste of maintenance
}go vet
# => Output: ./main.go:5:2: unreachable code
# => Detects code after return statementExample 3: Lost cancel function:
package main
import "context"
func doWork() {
ctx, _ := context.WithCancel(context.Background())
// => Underscore discards cancel function
// => Context leak (never cancelled)
// => Goroutines may leak
// Use ctx without ever calling cancel
_ = ctx
}go vet
# => Output: ./main.go:6:2: the cancel function returned by context.WithCancel should be called, not discarded
# => Detects missing cancel callExample 4: Invalid struct tags:
package main
type User struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
// => Valid JSON tags
Age int `json:age`
// => Invalid: missing quotes
// => Will fail at runtime during JSON encoding
}go vet
# => Output: ./main.go:9:2: struct field tag `json:age` not compatible with reflect.StructTag.Get: bad syntax for struct tag value
# => Catches malformed struct tagsgo vet checks (partial list):
- Printf-like format string verification
- Unreachable code detection
- Context usage (cancel functions)
- Struct tag validation
- Suspicious conversions
- Shadow variable detection
- Tests that don’t call t.Fatal correctly
Limitations of go vet:
- Conservative (few false positives, some false negatives)
- Limited to built-in analyzers
- No style checking (formatting, naming)
Standard Library: Race Detector
Go’s race detector finds data races during test execution.
What is a data race:
package main
import (
"fmt"
"sync"
)
var counter int
// => Shared variable (global)
// => Multiple goroutines may access
func increment() {
counter++
// => Race condition: read + write not atomic
// => Two goroutines can read same value simultaneously
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
// => 1000 goroutines all increment counter
}()
}
wg.Wait()
fmt.Println("Counter:", counter)
// => Expected: 1000
// => Actual: varies (900-1000) due to race
}Running with race detector:
go run -race main.go
# => Runs with race detection enabled
# => Output: WARNING: DATA RACE
# Read at 0x... by goroutine 42:
# main.increment()
# Previous write at 0x... by goroutine 41:
# main.increment()
# => Shows exact lines where race occursTesting with race detector:
go test -race
# => Runs tests with race detection
# => Slower execution (5-10x overhead)
# => Use in CI/CD, not every local test
go test -race ./...
# => Tests all packages with race detection
# => Standard in continuous integrationRace detector example (test):
// File: counter_test.go
package main
import (
"sync"
"testing"
)
func TestConcurrentIncrement(t *testing.T) {
// => Test concurrent access to counter
counter = 0
// => Reset global
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
increment()
// => Race: multiple goroutines increment
}()
}
wg.Wait()
if counter != 100 {
t.Errorf("Expected 100, got %d", counter)
}
}go test -race
# => Output: WARNING: DATA RACE
# Race detected in counter variable
# Test failsFixed version (using mutex):
package main
import "sync"
var (
counter int
mu sync.Mutex
// => Mutex protects counter
)
func increment() {
mu.Lock()
// => Acquires lock (blocks if held)
defer mu.Unlock()
// => Releases lock when function returns
counter++
// => Safe: only one goroutine can execute this
}go test -race
# => No race detected
# => Test passesRace detector limitations:
- Only detects races that execute during test run
- Performance overhead (5-10x slower)
- Memory overhead (5-10x more memory)
- Not exhaustive (missed races possible if untested code paths)
When to use race detector:
- Always in CI/CD for projects with goroutines
- During development when writing concurrent code
- Before releasing concurrent features
- Skip for CPU-bound benchmarks (overhead distorts results)
Standard Library: Code Coverage
Go’s built-in coverage tool measures test coverage percentage.
Basic coverage:
go test -cover
# => Runs tests and reports coverage
# => Output: coverage: 85.7% of statements
# => Quick summary of test coverageDetailed coverage report:
go test -coverprofile=coverage.out
# => Generates coverage profile file
# => coverage.out contains per-line coverage data
go tool cover -html=coverage.out
# => Opens HTML report in browser
# => Shows which lines covered (green) and uncovered (red)Coverage example:
// File: math.go
package math
func Add(a, b int) int {
return a + b
// => Covered if TestAdd runs
}
func Subtract(a, b int) int {
return a - b
// => Uncovered if no test for Subtract
}
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
// => Error path: covered if test checks divide-by-zero
}
return a / b, nil
// => Happy path: covered if test divides valid numbers
}// File: math_test.go
package math
import "testing"
func TestAdd(t *testing.T) {
// => Tests Add function
result := Add(2, 3)
if result != 5 {
t.Errorf("Expected 5, got %d", result)
}
}
func TestDivide(t *testing.T) {
// => Tests Divide happy path only
result, err := Divide(10, 2)
if err != nil || result != 5 {
t.Errorf("Expected 5, got %d with error %v", result, err)
}
}go test -cover
# => Output: coverage: 66.7% of statements
# => Add: covered (1/1 lines)
# => Subtract: uncovered (0/1 lines)
# => Divide: partially covered (1/2 branches - missing error path)Coverage by package:
go test -coverprofile=coverage.out ./...
# => Tests all packages with coverage
go tool cover -func=coverage.out
# => Shows coverage per function
# => Output:
# math.go:3: Add 100.0%
# math.go:7: Subtract 0.0%
# math.go:11: Divide 50.0%
# total: (statements) 66.7%Coverage thresholds (CI/CD):
#!/bin/bash
# File: check-coverage.sh
threshold=80
# => Minimum coverage requirement
coverage=$(go test -coverprofile=coverage.out ./... | grep coverage: | awk '{print $2}' | sed 's/%//')
# => Extracts coverage percentage
if (( $(echo "$coverage < $threshold" | bc -l) )); then
echo "Coverage $coverage% is below threshold $threshold%"
exit 1
fi
echo "Coverage $coverage% meets threshold"Coverage best practices:
- Aim for 70-80% coverage (diminishing returns above)
- Focus on critical paths, not 100%
- Test error paths and edge cases
- Ignore generated code (add
//go:generatecomment)
Coverage gotchas:
// Coverage doesn't measure:
// 1. Logic correctness (can have 100% coverage with wrong logic)
// 2. All execution paths (branches inside conditions)
// 3. Concurrency issues (race conditions)Production Tool: golangci-lint
golangci-lint aggregates 50+ linters into a single, fast tool.
Installation:
# Linux/macOS
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
# macOS (Homebrew)
brew install golangci-lint
# Verify
golangci-lint versionBasic usage:
golangci-lint run
# => Runs with default linters (fast, essential checks)
# => ~10 linters enabled by default
golangci-lint run --enable-all
# => Runs all 50+ linters (slow, comprehensive)
# => Use for initial codebase audit
golangci-lint run --fix
# => Auto-fixes issues where possible
# => Applies formatting, removes unused importsConfiguration (.golangci.yml):
# File: .golangci.yml
run:
timeout: 5m
tests: true
# => Include test files in linting
linters:
enable:
- gofmt # => Format checking
- goimports # => Import organization
- govet # => Built-in vet
- errcheck # => Unchecked error returns
- staticcheck # => Advanced static analysis
- unused # => Unused code detection
- gosimple # => Simplification suggestions
- ineffassign # => Ineffectual assignments
- misspell # => Spelling errors
- revive # => Replacement for golint
linters-settings:
errcheck:
check-blank: true
# => Enforce checking errors assigned to _
revive:
rules:
- name: exported
# => Exported functions must have comments
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck
# => Allow unchecked errors in tests
max-issues-per-linter: 0
max-same-issues: 0
# => Report all issues (no limits)Key linters:
// errcheck: Detects unchecked errors
package main
import "os"
func main() {
os.Remove("file.txt")
// => Error ignored
// => errcheck: Error return value of `os.Remove` is not checked
}
// Fix:
func main() {
if err := os.Remove("file.txt"); err != nil {
// Handle error
}
}// staticcheck: Advanced analysis
package main
func example() {
s := "hello"
s = s[:0]
// => staticcheck: this value of s is never used
// => Ineffectual assignment
}// gosimple: Simplification suggestions
package main
func check(b bool) bool {
if b == true {
return true
}
return false
// => gosimple: should omit comparison to bool constant
}
// Fix:
func check(b bool) bool {
return b
}CI/CD integration:
# Run in CI
golangci-lint run --out-format=github-actions
# => Formats output for GitHub Actions annotations
# => Inline comments on PR
# Exit code
golangci-lint run
echo $?
# => 0 if no issues, 1 if issues found
# => Fails CI build on linting violationsPerformance:
time go vet ./...
# => ~5 seconds
time golangci-lint run
# => ~10 seconds (runs multiple linters in parallel)
# => Faster than running linters individuallyTrade-offs:
| Approach | Pros | Cons |
|---|---|---|
| go vet only | Fast, built-in, no dependencies | Limited checks |
| golangci-lint (default) | Comprehensive, fast, configurable | External dependency (20MB) |
| golangci-lint (all) | Exhaustive checks, finds obscure bugs | Slow, noisy (many false positives) |
When to use:
- go vet: Always (built-in, fast)
- golangci-lint (default): CI/CD pipelines, pre-commit hooks
- golangci-lint –enable-all: Initial codebase audit, refactoring sprints
Fuzzing (Go 1.18+)
Go’s built-in fuzzing generates random inputs to find edge cases.
Basic fuzz test:
// File: parse_test.go
package main
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// => Fuzz test function (starts with Fuzz)
// => f is *testing.F for fuzzing control
testcases := []string{"Hello", "δΈη", " "}
// => Seed inputs for fuzzing engine
for _, tc := range testcases {
f.Add(tc)
// => Adds seed to corpus
// => Fuzzer mutates these inputs
}
f.Fuzz(func(t *testing.T, input string) {
// => Fuzz target function
// => Called with random inputs
if !utf8.ValidString(input) {
return
// => Skip invalid UTF-8 (not interesting)
}
rev := Reverse(input)
// => Calls function under test
doubleRev := Reverse(rev)
// => Reverse of reverse should equal original
if input != doubleRev {
t.Errorf("Reverse(Reverse(%q)) = %q, want %q", input, doubleRev, input)
// => Found a bug!
}
})
}
func Reverse(s string) string {
// => Function under test
b := []byte(s)
// => Bug: doesn't handle Unicode properly
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}Running fuzzing:
go test -fuzz=Fuzz
# => Runs fuzz tests indefinitely until failure or Ctrl+C
# => Generates random inputs
# => Stores failing inputs in testdata/fuzz/
go test -fuzz=FuzzReverse -fuzztime=30s
# => Fuzzes for 30 seconds, then stops
# => Useful in CI/CD (time-limited)Fuzzing output (when bug found):
fuzz: elapsed: 0s, execs: 245 (0/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 3s, execs: 85421 (28473/sec), new interesting: 2 (total: 3)
--- FAIL: FuzzReverse (3.12s)
--- FAIL: FuzzReverse (0.00s)
parse_test.go:24: Reverse(Reverse("π")) = "\xbd\xf0\x9f", want "π"
Failing input written to testdata/fuzz/FuzzReverse/abc123Fuzzing best practices:
- Define property-based assertions (not exact output checks)
- Handle invalid inputs gracefully (return instead of panic)
- Limit fuzz time in CI (30s-1m)
- Commit generated failing cases to testdata/fuzz/
Best Practices
Quality pipeline (run in order):
# 1. Format check
gofmt -l .
# => Lists files not properly formatted
# 2. Import organization
goimports -l .
# => Checks import order
# 3. Static analysis
go vet ./...
# => Built-in checks
# 4. Linting
golangci-lint run
# => Comprehensive linting
# 5. Tests with race detection
go test -race ./...
# => Catches concurrency bugs
# 6. Coverage check
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | grep total
# => Verify coverage threshold
# 7. Fuzzing (optional, CI only)
go test -fuzz=Fuzz -fuzztime=30s ./...
# => Time-limited fuzzingPre-commit hook (.git/hooks/pre-commit):
#!/bin/bash
# Exit on error
set -e
echo "Running quality checks..."
# Format
if [ -n "$(gofmt -l .)" ]; then
echo "Code not formatted. Run: gofmt -w ."
exit 1
fi
# Vet
go vet ./...
# Lint
golangci-lint run
echo "Quality checks passed"CI/CD configuration (GitHub Actions):
# File: .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: "1.23"
- name: Install golangci-lint
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin
- name: Vet
run: go vet ./...
- name: Lint
run: golangci-lint run
- name: Test
run: go test -race -coverprofile=coverage.out ./...
- name: Coverage
run: go tool cover -func=coverage.outSummary
Go QA toolkit:
- go vet: Built-in static analysis (always use)
- Race detector: Finds concurrency bugs (use in tests)
- Coverage: Measures test coverage (aim for 70-80%)
- golangci-lint: Aggregates 50+ linters (CI/CD essential)
- Fuzzing: Generates inputs to find edge cases (Go 1.18+)
Quality gate checklist:
# Required (fast, always run)
go fmt ./...
go vet ./...
golangci-lint run
go test ./...
# Recommended (slower, CI/CD)
go test -race ./...
go test -coverprofile=coverage.out ./...
# Optional (specific scenarios)
go test -fuzz=Fuzz -fuzztime=30s # New code with complex inputsProgressive adoption:
- Start with
go vetandgo test - Add
golangci-lintwith default linters - Enable race detector in CI/CD
- Enforce coverage thresholds
- Add fuzzing for security-critical code
Tool comparison:
| Tool | Speed | Checks | When to Use |
|---|---|---|---|
| go vet | Fast | Basic static analysis | Always (pre-commit, CI) |
| race detector | Slow | Data races | CI/CD, concurrent code |
| golangci-lint | Fast | 50+ linters | CI/CD, pre-commit |
| coverage | Fast | Test coverage | CI/CD, coverage reports |
| fuzzing | Slow | Edge case inputs | Security-critical code, CI |