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.