Go - Concurrency Patterns and Testing
Concept: Logging should not hinder the primary function of a server.
Why it matters: A logger that blocks under backpressure turns a disk-full incident into a total service outage — logging must never be on the critical path.
package main
import (
"fmt"
"time"
)
// non-blocking logger: server work never waits for logging
type AsyncLogger struct {
ch chan string
done chan struct{}
}
func NewAsyncLogger(buf int) *AsyncLogger {
l := &AsyncLogger{
ch: make(chan string, buf),
done: make(chan struct{}),
}
go l.drain()
return l
}
func (l *AsyncLogger) drain() {
defer close(l.done)
for msg := range l.ch {
fmt.Println(msg) // I/O happens in drain goroutine — never blocks caller
}
}
// Log never blocks the caller — it only deposits or drops
func (l *AsyncLogger) Log(msg string) {
select {
case l.ch <- msg:
default: // buffer full: drop the log, keep the server running
}
}
func (l *AsyncLogger) Close() { close(l.ch); <-l.done }
func main() {
log := NewAsyncLogger(100)
for i := 0; i < 5; i++ {
log.Log(fmt.Sprintf("request %d handled", i))
time.Sleep(1 * time.Millisecond)
}
log.Close()
}
Gotcha: Sizing the logger buffer too small — under burst traffic the buffer fills, drops occur; size it to absorb the expected burst, not the average rate.
Concept: Standard library loggers can lead to deadlocks when the output stream blocks in high-concurrency situations.
Why it matters: log.Printf acquires an internal mutex and writes synchronously — if the write destination blocks (full disk), every goroutine that logs is parked behind the same mutex.
package main
import (
"bytes"
"fmt"
"log"
"sync"
)
// demonstrate: stdlib logger serializes all writes through a mutex
// if the writer blocks, all logging goroutines queue behind it
type SlowWriter struct {
mu sync.Mutex
buf bytes.Buffer
}
func (w *SlowWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
// simulate slow disk write — every logging goroutine blocks here
return w.buf.Write(p)
}
func main() {
slow := &SlowWriter{}
logger := log.New(slow, "", 0)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
logger.Printf("event %d", n) // goroutine blocks if writer is slow
}(i)
}
wg.Wait()
fmt.Printf("logged %d bytes\n", slow.buf.Len())
// fix: use AsyncLogger from previous concept — decouple writing from logging
}
Gotcha: Wrapping log.Printf in a goroutine to avoid blocking — you now have unbounded goroutine creation if the writer is consistently slow.
Concept: Detecting and recovering from failures such as a full disk is crucial in production.
Why it matters: Infrastructure failures (disk full, network partition) are inevitable — code that detects and signals them allows graceful degradation instead of silent hang.
package main
import (
"errors"
"fmt"
"time"
)
var ErrDiskFull = errors.New("disk full")
// writer that detects and signals failure rather than blocking
type FailDetectWriter struct {
writes int
failAt int
failures chan error
}
func NewFailDetectWriter(failAt int) *FailDetectWriter {
return &FailDetectWriter{
failAt: failAt,
failures: make(chan error, 10),
}
}
func (w *FailDetectWriter) Write(data string) error {
w.writes++
if w.writes >= w.failAt {
err := fmt.Errorf("write %d: %w", w.writes, ErrDiskFull)
select {
case w.failures <- err: // signal failure — non-blocking
default:
}
return err
}
fmt.Printf("wrote: %s\n", data)
return nil
}
func (w *FailDetectWriter) Failures() <-chan error { return w.failures }
func main() {
w := NewFailDetectWriter(3)
// monitor failures in a separate goroutine
go func() {
for err := range w.Failures() {
if errors.Is(err, ErrDiskFull) {
fmt.Println("ALERT: disk full — switch to fallback writer")
}
}
}()
for i := 0; i < 5; i++ {
if err := w.Write(fmt.Sprintf("log line %d", i)); err != nil {
fmt.Println("write failed:", err)
}
time.Sleep(10 * time.Millisecond)
}
}
Gotcha: Silently discarding write errors in a logger — the first disk-full write error tells you to switch to a fallback (stderr, remote) before the buffer fills.
Concept: Concurrency patterns — specifically the drop pattern — offer effective solutions for managing resource limitations.
Why it matters: The drop pattern is the correct response to sustained overload — it sheds load proportionally, keeping the service responsive for requests it does accept.
package main
import (
"fmt"
"sync/atomic"
"time"
)
// drop pattern with metrics: count drops to inform capacity planning
type LoadShedder struct {
queue chan func()
dropped int64
accepted int64
}
func NewLoadShedder(capacity int) *LoadShedder {
ls := &LoadShedder{queue: make(chan func(), capacity)}
go ls.process()
return ls
}
func (ls *LoadShedder) process() {
for fn := range ls.queue {
fn()
}
}
func (ls *LoadShedder) Submit(fn func()) bool {
select {
case ls.queue <- fn:
atomic.AddInt64(&ls.accepted, 1)
return true
default:
atomic.AddInt64(&ls.dropped, 1)
return false // drop — non-blocking
}
}
func (ls *LoadShedder) Stats() (accepted, dropped int64) {
return atomic.LoadInt64(&ls.accepted), atomic.LoadInt64(&ls.dropped)
}
func main() {
ls := NewLoadShedder(3)
for i := 0; i < 10; i++ {
taskID := i
ls.Submit(func() {
time.Sleep(20 * time.Millisecond)
fmt.Printf("task %d done\n", taskID)
})
}
time.Sleep(300 * time.Millisecond)
accepted, dropped := ls.Stats()
fmt.Printf("accepted=%d dropped=%d\n", accepted, dropped)
}
Gotcha: Dropping without backpressure signaling to the client — the caller should receive an explicit 429/503 response, not a silent success that never completes.
Concept: Go has a built-in testing tool called go test — makes it easy to write and run tests.
Why it matters: The built-in test runner requires no configuration — go test ./... runs all tests in a project, enabling consistent CI pipelines with zero setup.
// calc/calc.go
package calc
func Add(a, b int) int { return a + b }
func Subtract(a, b int) int { return a - b }
func Multiply(a, b int) int { return a * b }
// calc/calc_test.go
package calc
import "testing"
// go test ./calc — run tests in this package
// go test ./... — run all tests in project
// go test -v ./calc — verbose: show each test name and result
// go test -run TestAdd — run only tests matching "TestAdd"
// go test -count=3 ./calc — run each test 3 times (catch flaky tests)
func TestAdd(t *testing.T) {
got := Add(2, 3)
if got != 5 {
t.Errorf("Add(2,3) = %d, want 5", got)
}
}
func TestSubtract(t *testing.T) {
got := Subtract(10, 4)
if got != 6 {
t.Errorf("Subtract(10,4) = %d, want 6", got)
}
}
func TestMultiply(t *testing.T) {
got := Multiply(3, 4)
if got != 12 {
t.Errorf("Multiply(3,4) = %d, want 12", got)
}
}
Gotcha: Running go test in the wrong directory — always use ./... to run all packages recursively; go test alone only tests the current package.
Concept: It's crucial to have consistent testing practices across the team.
Why it matters: Consistent test structure means any team member can read, run, and modify any test — inconsistency creates silos where only the author understands the tests.
// payment/payment.go
package payment
import "errors"
var ErrInsufficientFunds = errors.New("insufficient funds")
func Charge(balance, amount float64) (float64, error) {
if amount > balance {
return balance, ErrInsufficientFunds
}
return balance - amount, nil
}
// payment/payment_test.go — consistent team convention applied throughout
package payment
import (
"errors"
"testing"
)
// convention: TestFunctionName_Scenario_ExpectedOutcome
// every test: arrange → act → assert, in that order, no variation
func TestCharge_SufficientFunds_DeductsAmount(t *testing.T) {
// arrange
balance, amount := 100.0, 30.0
// act
got, err := Charge(balance, amount)
// assert
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 70.0 {
t.Errorf("got %.2f, want %.2f", got, 70.0)
}
}
func TestCharge_InsufficientFunds_ReturnsError(t *testing.T) {
_, err := Charge(10.0, 50.0)
if !errors.Is(err, ErrInsufficientFunds) {
t.Errorf("got %v, want ErrInsufficientFunds", err)
}
}
Gotcha: Using t.Log for assertions — t.Log doesn't fail the test; use t.Error, t.Errorf, t.Fatal, or t.Fatalf to actually mark the test as failed.
Concept: Test files must be named _test.go — the go test tool uses this naming convention to identify test files.
Why it matters: The _test.go suffix means test code is never compiled into the production binary — zero overhead, clean separation, and no accidental test imports.
// server/server.go — production code
package server
import "fmt"
type Server struct{ port int }
func New(port int) *Server { return &Server{port: port} }
func (s *Server) Addr() string { return fmt.Sprintf(":%d", s.port) }
// server/server_test.go — ONLY compiled during go test
package server
import "testing"
// this file is excluded from: go build, go run, production binaries
// included in: go test, go test -c (test binary)
func TestNew_SetsPort(t *testing.T) {
s := New(8080)
got := s.Addr()
want := ":8080"
if got != want {
t.Errorf("Addr() = %q, want %q", got, want)
}
}
// server/server_external_test.go — external test package: tests the public API only
// package server_test ← external: can only use exported identifiers
Gotcha: Putting helper functions in a file not ending in _test.go — they get compiled into the production binary unnecessarily; all test helpers must be in _test.go files.
Concept: Test functions must start with Test and have the signature func TestFunctionName(t *testing.T).
Why it matters: The naming convention is enforced by the test runner — functions not matching Test* are ignored as tests, silently skipping your assertions.
// parser/parser_test.go
package parser
import "testing"
// correct: starts with "Test", parameter is *testing.T
func TestParseInt_ValidInput(t *testing.T) {
// test body
_ = t
}
// ignored by go test — missing "Test" prefix
func parseIntHelper(t *testing.T) { _ = t }
// ignored by go test — wrong parameter type
// func TestWrongSig() {} // would be compiled but not run as a test
// benchmark: starts with "Benchmark", parameter is *testing.B
func BenchmarkParseInt(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = i
}
}
// example: starts with "Example", no parameter
func ExampleParseInt() {
// Output:
// (empty — matches nothing, but documents usage)
}
Gotcha: Naming a test Testparse (lowercase p) — Go convention requires the first letter after Test to be uppercase; Testparse will run, but it won't match -run TestParse filters.
Concept: The testing.T type provides methods for logging, signaling errors, and failing tests.
Why it matters: Choosing between t.Error and t.Fatal determines whether subsequent assertions run — Fatal stops immediately, Error continues to accumulate failures.
package api
import "testing"
func TestTMethods(t *testing.T) {
// t.Log: records message — only shown with -v or on failure
t.Log("starting test")
// t.Error: marks test as failed, but continues execution
// use when multiple independent assertions should all be checked
got := 3
if got != 3 {
t.Error("got != 3") // doesn't stop — continues to next check
}
// t.Errorf: formatted version of t.Error
want := 3
if got != want {
t.Errorf("got %d, want %d", got, want) // formatted, continues
}
// t.Fatal: marks failed AND stops the test function immediately
// use when subsequent assertions depend on this one being true
result, err := func() (int, error) { return 42, nil }()
if err != nil {
t.Fatalf("setup failed: %v", err) // stop — no point continuing
}
// t.Helper: marks this function as a helper — error points to caller line
assertEqual := func(got, want int) {
t.Helper() // without this, error points here, not the call site
if got != want {
t.Errorf("got %d, want %d", got, want)
}
}
assertEqual(result, 42)
}
Gotcha: Using t.Fatal in a goroutine spawned during a test — t.Fatal calls runtime.Goexit() which only exits the current goroutine; the test may continue running.
Concept: Given, When, Should methodology — provides a structured approach to writing test cases.
Why it matters: Given/When/Should makes the test's preconditions, action, and expectation explicit — any reader instantly understands what scenario is being validated.
// inventory/inventory.go
package inventory
import "errors"
var ErrOutOfStock = errors.New("out of stock")
type Inventory struct{ stock map[string]int }
func New() *Inventory { return &Inventory{stock: make(map[string]int)} }
func (inv *Inventory) Add(sku string, qty int) { inv.stock[sku] += qty }
func (inv *Inventory) Reserve(sku string, qty int) error {
if inv.stock[sku] < qty {
return ErrOutOfStock
}
inv.stock[sku] -= qty
return nil
}
// inventory/inventory_test.go
package inventory
import (
"errors"
"testing"
)
// Given_When_Should: each part of the name is a sentence fragment
func TestReserve_GivenSufficientStock_ShouldDeductQuantity(t *testing.T) {
// Given: inventory with 10 units
inv := New()
inv.Add("SKU-001", 10)
// When: 3 units are reserved
err := inv.Reserve("SKU-001", 3)
// Should: no error, 7 units remain
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := inv.stock["SKU-001"]; got != 7 {
t.Errorf("remaining stock = %d, want 7", got)
}
}
func TestReserve_GivenInsufficientStock_ShouldReturnError(t *testing.T) {
inv := New()
inv.Add("SKU-001", 2)
err := inv.Reserve("SKU-001", 5)
if !errors.Is(err, ErrOutOfStock) {
t.Errorf("got %v, want ErrOutOfStock", err)
}
}
Gotcha: Writing test names like TestReserve1, TestReserve2 — the name must communicate the scenario; a failing test should tell you what broke without reading the code.
Concept: Error handling in tests is crucial — tests fail when errors occur, providing accurate feedback.
Why it matters: A test that swallows errors passes even when the code is broken — every error path in test code must fail the test explicitly.
package files
import (
"os"
"testing"
)
func TestReadTempFile(t *testing.T) {
// create test fixture
f, err := os.CreateTemp("", "test-*.txt")
if err != nil {
t.Fatalf("setup: create temp file: %v", err) // fatal: can't continue without file
}
defer os.Remove(f.Name()) // cleanup: always run even if test fails
// write test data
if _, err := f.Write([]byte("test data")); err != nil {
t.Fatalf("setup: write: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("setup: close: %v", err)
}
// act
data, err := os.ReadFile(f.Name())
if err != nil {
t.Fatalf("ReadFile: %v", err) // fatal: result is meaningless without data
}
// assert
if got := string(data); got != "test data" {
t.Errorf("got %q, want %q", got, "test data")
}
}
Gotcha: Using _ = someFunc() in test setup — if setup fails silently, the test asserts against zero values and may accidentally pass with wrong data.
Concept: Verbose output (go test -v) provides detailed information about test execution.
Why it matters: Without -v, only failing tests print output — with -v, every test name and t.Log message is shown, enabling diagnosis of ordering or timing issues.
package verbose
import (
"testing"
"time"
)
func TestWithLogging(t *testing.T) {
// t.Log output: only shown without -v when test FAILS
// shown always with: go test -v ./...
t.Log("arranging test data")
data := []int{1, 2, 3}
t.Logf("input data: %v", data)
start := time.Now()
sum := 0
for _, v := range data {
sum += v
}
t.Logf("computed sum=%d in %v", sum, time.Since(start))
if sum != 6 {
t.Errorf("sum = %d, want 6", sum)
}
t.Log("test complete")
// run with: go test -v ./verbose to see all Log output
}
Gotcha: Using fmt.Println instead of t.Log in tests — fmt.Println always prints to stdout, cluttering CI output even for passing tests; t.Log is controlled by the test runner.
Concept: Filtering tests allows running specific tests or subsets — focusing on particular areas of code.
Why it matters: Running the full test suite on every change is slow — filtering lets you iterate on one function's tests in milliseconds while the full suite runs in CI.
package filter
import "testing"
// filter by name: go test -run TestUser ./...
// filter by subtest: go test -run TestUser/create ./...
// filter by regex: go test -run "TestUser|TestOrder" ./...
// filter by file pattern is not supported — use package paths instead
func TestUser_Create(t *testing.T) {
t.Log("testing user creation")
}
func TestUser_Update(t *testing.T) {
t.Log("testing user update")
}
func TestOrder_Create(t *testing.T) {
t.Log("testing order creation")
}
func TestPayment_Process(t *testing.T) {
t.Log("testing payment")
}
// go test -run TestUser → runs TestUser_Create and TestUser_Update
// go test -run TestUser_Create → runs only TestUser_Create
// go test -run "User|Order" → runs all User and Order tests
// go test -run . → runs all tests (same as no -run flag)
Gotcha: Using -run TestFoo expecting exact match — -run uses regex; TestFoo matches TestFooBar too; use TestFoo$ for exact match.
Concept: Table-driven tests — a single test function testing multiple input-output scenarios efficiently.
Why it matters: Table-driven tests eliminate copy-paste between similar test cases — adding a new edge case is one line in the table, not a new function.
package validator
import (
"strings"
"testing"
)
func IsValidEmail(email string) bool {
return strings.Contains(email, "@") && strings.Contains(email, ".")
}
func TestIsValidEmail(t *testing.T) {
// table: each row is an independent scenario
tests := []struct {
name string
email string
want bool
}{
{"valid email", "user@example.com", true},
{"missing at", "userexample.com", false},
{"missing dot", "user@examplecom", false},
{"empty string", "", false},
{"only at", "@", false},
{"subdomain valid", "u@a.b.com", true},
}
for _, tc := range tests {
tc := tc // capture — required before Go 1.22
t.Run(tc.name, func(t *testing.T) {
got := IsValidEmail(tc.email)
if got != tc.want {
t.Errorf("IsValidEmail(%q) = %v, want %v", tc.email, got, tc.want)
}
})
}
}
Gotcha: Not using t.Run inside table-driven tests — without subtests, a single failure stops all rows; with t.Run, all rows run independently.
Concept: The table is often a slice of structs or anonymous structs, each representing a test case with input and expected output.
Why it matters: Anonymous struct tables are self-documenting — the field names describe exactly what each column represents without any external documentation.
package converter
import (
"fmt"
"testing"
)
func CelsiusToFahrenheit(c float64) float64 { return c*9/5 + 32 }
func TestCelsiusToFahrenheit(t *testing.T) {
// anonymous struct: fields serve as column headers — self-documenting
tests := []struct {
celsius float64
fahrenheit float64
label string
}{
{celsius: 0, fahrenheit: 32, label: "freezing"},
{celsius: 100, fahrenheit: 212, label: "boiling"},
{celsius: 37, fahrenheit: 98.6, label: "body temp"},
{celsius: -40, fahrenheit: -40, label: "equal point"},
}
for _, tc := range tests {
t.Run(tc.label, func(t *testing.T) {
got := CelsiusToFahrenheit(tc.celsius)
if fmt.Sprintf("%.1f", got) != fmt.Sprintf("%.1f", tc.fahrenheit) {
t.Errorf("%.1f°C = %.1f°F, want %.1f°F",
tc.celsius, got, tc.fahrenheit)
}
})
}
}
Gotcha: Using positional struct initialization in test tables {0, 32, "freezing"} — adding a field shifts all values; always use named fields in test tables.
Concept: Using table tests reduces code duplication and makes it easier to add or modify test cases.
Why it matters: The cost of adding a new test case drops from "write a new function" to "add one line to the table" — teams with table tests have higher test coverage because the barrier is low.
package parser
import (
"strconv"
"testing"
)
// the canonical Go idiom — every standard library uses this exact pattern
func TestAtoi(t *testing.T) {
tests := []struct {
input string
want int
wantErr bool
}{
{"0", 0, false},
{"42", 42, false},
{"-7", -7, false},
{"2147483647", 2147483647, false}, // max int32
{"abc", 0, true},
{"", 0, true},
{"9999999999999999999", 0, true}, // overflow
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := strconv.Atoi(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("Atoi(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
if !tc.wantErr && got != tc.want {
t.Errorf("Atoi(%q) = %d, want %d", tc.input, got, tc.want)
}
})
}
// adding a new case: one line — no new function, no copy-paste
}
Gotcha: Putting shared setup code outside the table loop — if one test case modifies shared state, subsequent cases see the modified state; isolate state inside each t.Run.
Concept: Mocking is essential for isolating the unit under test and controlling its dependencies.
Why it matters: A test that hits a real database is not a unit test — it's slow, environment-dependent, and fails for infrastructure reasons unrelated to the code.
package notify
import (
"fmt"
"testing"
)
// real implementation — hits SMTP server
type SMTPMailer struct{ host string }
func (s SMTPMailer) Send(to, subject, body string) error {
return fmt.Errorf("real SMTP not available in test") // fails in CI
}
// production interface — defined by the consumer
type Mailer interface {
Send(to, subject, body string) error
}
type NotificationService struct{ mailer Mailer }
func (ns *NotificationService) Welcome(email string) error {
return ns.mailer.Send(email, "Welcome!", "Thanks for joining.")
}
// mock mailer: deterministic, fast, no network
type MockMailer struct {
SendCalls []string
ErrToReturn error
}
func (m *MockMailer) Send(to, subject, body string) error {
m.SendCalls = append(m.SendCalls, to)
return m.ErrToReturn
}
func TestWelcome_SendsEmail(t *testing.T) {
mock := &MockMailer{}
svc := &NotificationService{mailer: mock}
err := svc.Welcome("user@example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(mock.SendCalls) != 1 || mock.SendCalls[0] != "user@example.com" {
t.Errorf("expected send to user@example.com, got %v", mock.SendCalls)
}
}
Gotcha: Asserting on mock call count without asserting on arguments — the mock was called, but with the wrong recipient; always verify both.
Concept: Testing error cases which can be difficult to achieve with live servers — mocking allows simulating error conditions.
Why it matters: A real server can't be made to reliably return a 503 on demand — mocks let you test every error branch deterministically.
package client
import (
"errors"
"fmt"
"testing"
)
var ErrServiceUnavailable = errors.New("service unavailable")
type DataFetcher interface {
Fetch(id int) (string, error)
}
type DataService struct{ fetcher DataFetcher }
func (ds *DataService) Get(id int) (string, error) {
data, err := ds.fetcher.Fetch(id)
if err != nil {
return "", fmt.Errorf("DataService.Get: %w", err)
}
return data, nil
}
// error-injecting mock: returns configurable errors
type MockFetcher struct{ err error }
func (m MockFetcher) Fetch(id int) (string, error) {
if m.err != nil {
return "", m.err
}
return fmt.Sprintf("data-%d", id), nil
}
func TestGet_FetcherError_ReturnsWrappedError(t *testing.T) {
// inject the error that would be impossible to reproduce with a real server
mock := MockFetcher{err: ErrServiceUnavailable}
svc := &DataService{fetcher: mock}
_, err := svc.Get(1)
if !errors.Is(err, ErrServiceUnavailable) {
t.Errorf("got %v, want ErrServiceUnavailable in chain", err)
}
}
Gotcha: Only testing the happy path because error injection is hard without mocks — every error branch in production code needs a corresponding test case.
Concept: The httptest package provides tools for creating mock servers and handlers.
Why it matters: httptest creates a real HTTP server on a random port in-process — your HTTP client code runs against it exactly as in production, with no network dependency.
package httpclient
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func fetchBody(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return string(body), err
}
func TestFetchBody_ReturnsServerResponse(t *testing.T) {
// httptest.NewServer: real HTTP server, in-process, random port
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("hello from mock"))
}))
defer ts.Close() // always close to release the port
// pass ts.URL instead of a real URL — same code path, no network
got, err := fetchBody(ts.URL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "hello from mock" {
t.Errorf("got %q, want %q", got, "hello from mock")
}
}
Gotcha: Not calling ts.Close() — the test server holds a port and goroutines; leaking it causes port exhaustion in tests with many cases.
Concept: Mock servers can simulate different HTTP status codes and responses.
Why it matters: Real servers can't be made to return 500 on demand — httptest lets you cover every status code branch without infrastructure.
package apiclient
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
type Client struct{ baseURL string }
func (c *Client) GetUser(id int) (int, error) {
resp, err := http.Get(fmt.Sprintf("%s/users/%d", c.baseURL, id))
if err != nil {
return 0, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return resp.StatusCode, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return resp.StatusCode, nil
}
func TestGetUser_StatusCodes(t *testing.T) {
cases := []struct {
name string
statusCode int
wantErr bool
}{
{"ok", http.StatusOK, false},
{"not found", http.StatusNotFound, true},
{"server error", http.StatusInternalServerError, true},
{"service unavailable", http.StatusServiceUnavailable, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
// new server per test case — isolated, deterministic
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tc.statusCode) // simulate the target status code
}))
defer ts.Close()
c := &Client{baseURL: ts.URL}
_, err := c.GetUser(1)
if (err != nil) != tc.wantErr {
t.Errorf("wantErr=%v got err=%v", tc.wantErr, err)
}
})
}
}
Gotcha: Reusing a single httptest.NewServer across all table rows with a shared handler — if you change the handler per row, you need per-row servers or a request counter.
Concept: httptest.NewRequest creates a mock HTTP request; httptest.NewRecorder acts as a mock HTTP response writer.
Why it matters: Testing a handler with NewRequest+NewRecorder is faster than httptest.NewServer — no port, no goroutine, no network; pure in-memory function call.
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// handler under test
func GetUserHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(User{ID: 1, Name: "Harish"})
}
func TestGetUserHandler(t *testing.T) {
// create request and recorder — no real HTTP involved
req := httptest.NewRequest(http.MethodGet, "/users/1", nil)
rec := httptest.NewRecorder()
// call handler directly — same code path as production
GetUserHandler(rec, req)
// inspect the recorded response
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
var user User
if err := json.NewDecoder(rec.Body).Decode(&user); err != nil {
t.Fatalf("decode body: %v", err)
}
if user.Name != "Harish" {
t.Errorf("name = %q, want %q", user.Name, "Harish")
}
}
Gotcha: Using rec.Body.String() for JSON comparison — whitespace and field order may vary; decode into a struct and compare fields individually.
Concept: Example tests serve as both documentation and executable tests based on standard output.
Why it matters: Example functions appear in godoc, are run by go test, and fail if the output doesn't match — they are the only tests that serve as living documentation.
package format
import "fmt"
func FormatCurrency(amount float64, symbol string) string {
return fmt.Sprintf("%s%.2f", symbol, amount)
}
// ExampleFormatCurrency: shown in godoc AND run by go test
// The "Output:" comment is the assertion — must match stdout exactly
func ExampleFormatCurrency() {
fmt.Println(FormatCurrency(9.9, "$"))
fmt.Println(FormatCurrency(1234.5, "£"))
fmt.Println(FormatCurrency(0, "€"))
// Output:
// $9.90
// £1234.50
// €0.00
}
// Unordered output: for non-deterministic output (e.g., map iteration)
func ExampleFormatCurrency_unordered() {
results := []string{
FormatCurrency(10, "¥"),
FormatCurrency(20, "¥"),
}
for _, r := range results {
fmt.Println(r)
}
// Unordered output:
// ¥10.00
// ¥20.00
}
Gotcha: Adding trailing spaces in the // Output: comment — the comparison is exact; a single trailing space causes the example test to fail.
Concept: Subtests — t.Run defines and runs a subtest; t.Parallel runs subtests concurrently.
Why it matters: Subtests give each table row a name in test output, allow parallel execution, and enable filtering with -run — essential for large test suites.
package sort
import (
"sort"
"testing"
)
func TestSort(t *testing.T) {
tests := []struct {
name string
input []int
want []int
}{
{"already sorted", []int{1, 2, 3}, []int{1, 2, 3}},
{"reverse order", []int{3, 2, 1}, []int{1, 2, 3}},
{"duplicates", []int{3, 1, 2, 1}, []int{1, 1, 2, 3}},
{"single element", []int{5}, []int{5}},
{"empty", []int{}, []int{}},
}
for _, tc := range tests {
tc := tc // capture — required before Go 1.22
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // subtests run concurrently — faster suite
got := make([]int, len(tc.input))
copy(got, tc.input)
sort.Ints(got)
if len(got) != len(tc.want) {
t.Fatalf("len = %d, want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("got[%d] = %d, want %d", i, got[i], tc.want[i])
}
}
})
}
// filter: go test -run TestSort/duplicates
}
Gotcha: Using t.Parallel() in subtests that share mutable state — parallel subtests run concurrently; any shared state must be protected or each subtest must have its own copy.
Concept: Subtest names enable selective filtering of tests on the command line.
Why it matters: Descriptive subtest names turn go test -run into a precise scalpel — you can run exactly one scenario without modifying any code.
package pricing
import "testing"
func calculatePrice(qty int, unitPrice float64) float64 {
if qty >= 10 {
return float64(qty) * unitPrice * 0.9
}
return float64(qty) * unitPrice
}
func TestCalculatePrice(t *testing.T) {
tests := []struct {
name string
qty int
unitPrice float64
want float64
}{
{"single_unit", 1, 10.0, 10.0},
{"below_threshold", 9, 10.0, 90.0},
{"at_threshold", 10, 10.0, 90.0}, // 10 * 10 * 0.9
{"above_threshold", 20, 10.0, 180.0}, // 20 * 10 * 0.9
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := calculatePrice(tc.qty, tc.unitPrice)
if got != tc.want {
t.Errorf("calculatePrice(%d, %.1f) = %.1f, want %.1f",
tc.qty, tc.unitPrice, got, tc.want)
}
})
}
// go test -run TestCalculatePrice/at_threshold — run exactly one case
// go test -run TestCalculatePrice/.*threshold — run all threshold cases
}
Gotcha: Using spaces in subtest names — t.Run replaces spaces with underscores in the -run filter; use underscores in names for predictable filtering. [REF]
Concept: Code coverage — go test -cover displays percentage; -coverprofile generates a profile; go tool cover -html opens visual report.
Why it matters: Coverage reports show which branches were never tested — they don't prove correctness, but they reveal code that has never been exercised by any test.
// math/math.go
package math
func Abs(n int) int {
if n < 0 { // branch A: negative
return -n
}
return n // branch B: non-negative
}
func Max(a, b int) int {
if a > b { // branch C: a larger
return a
}
return b // branch D: b larger or equal
}
// math/math_test.go
package math
import "testing"
// partial coverage: branch B of Abs and branch D of Max not tested
func TestAbs_Negative(t *testing.T) {
if got := Abs(-5); got != 5 {
t.Errorf("Abs(-5) = %d, want 5", got)
}
// Abs(3) not tested — branch B uncovered
}
func TestMax_FirstLarger(t *testing.T) {
if got := Max(5, 3); got != 5 {
t.Errorf("Max(5,3) = %d, want 5", got)
}
// Max(3,5) not tested — branch D uncovered
}
// go test -cover ./math → coverage percentage
// go test -coverprofile=coverage.out ./math → generate profile
// go tool cover -html=coverage.out → visual report (red = untested)
// go test -covermode=atomic -race ./... → thread-safe coverage with race detection
Gotcha: Aiming for 100% coverage by testing trivial getters — coverage percentage is a proxy metric; test behavior and edge cases, not every line.
Concept: Aiming for high coverage especially in critical paths is important — 100% may not always be practical.
Why it matters: Prioritizing coverage of error paths, boundary conditions, and business logic over scaffolding code gives the most value per test written.
package transfer
import (
"errors"
"fmt"
"testing"
)
var (
ErrInsufficientFunds = errors.New("insufficient funds")
ErrNegativeAmount = errors.New("negative amount")
)
func Transfer(from, to *float64, amount float64) error {
if amount < 0 {
return ErrNegativeAmount // critical: must test
}
if *from < amount {
return ErrInsufficientFunds // critical: must test
}
*from -= amount
*to += amount
return nil // critical: must test
}
// cover all critical paths — not just the happy path
func TestTransfer_AllPaths(t *testing.T) {
t.Run("success", func(t *testing.T) {
from, to := 100.0, 0.0
if err := Transfer(&from, &to, 50.0); err != nil {
t.Fatal(err)
}
if from != 50.0 || to != 50.0 {
t.Errorf("from=%.1f to=%.1f", from, to)
}
})
t.Run("insufficient_funds", func(t *testing.T) {
from, to := 10.0, 0.0
err := Transfer(&from, &to, 50.0)
if !errors.Is(err, ErrInsufficientFunds) {
t.Errorf("got %v, want ErrInsufficientFunds", err)
}
})
t.Run("negative_amount", func(t *testing.T) {
from, to := 100.0, 0.0
err := Transfer(&from, &to, -10.0)
if !errors.Is(err, ErrNegativeAmount) {
t.Errorf("got %v, want ErrNegativeAmount", err)
}
})
fmt.Println("all critical Transfer paths covered")
}
Gotcha: Skipping error path tests because "errors are rare" — the rarest paths are the ones that crash production; they need the most testing, not the least.