Go Standard Library — Packages, IO, JSON and Testing

This post covers four day-to-day essentials for writing Go programs: organizing code into packages and modules, streaming data through the io.Reader and io.Writer interfaces, serializing and deserializing JSON, and writing tests. These are the building blocks you will reach for in nearly every Go project — packages keep code organized, the I/O interfaces let you compose data pipelines, JSON handles serialization, and the testing package gives you a lightweight framework for verifying everything works.

Packages and Modules

Go code is organized into packages. Every .go file starts with a package declaration, and all files in the same directory must use the same package name. A module is a collection of packages versioned together, defined by a go.mod file at the root. When you import a package, Go resolves it through the module system.

Importing Packages

Go’s standard library provides a rich set of packages out of the box. You import them by path, and each import gives you access to the package’s exported identifiers. Here is a program that uses several standard library packages to demonstrate the breadth of what is available without any third-party dependencies:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
    "fmt"
    "math"
    "sort"
    "strconv"
    "strings"
)

func main() {
    fmt.Println("=== Standard Library Packages ===")

    fmt.Println(strings.ToUpper("hello"))
    fmt.Println(strings.Contains("go is great", "great"))
    fmt.Println(strings.Join([]string{"a", "b", "c"}, "-"))

    fmt.Println(math.Sqrt(144))
    fmt.Println(math.Pi)

    nums := []int{5, 2, 8, 1, 9}
    sort.Ints(nums)
    fmt.Println(nums)

    n, _ := strconv.Atoi("42")
    fmt.Printf("converted string to int: %d\n", n)
}

Each import path corresponds to a directory in the standard library or in your module. The strings package provides string manipulation, math provides mathematical constants and functions, sort provides sorting algorithms for slices, and strconv handles conversions between strings and other types. You only pay for what you import — the Go compiler rejects unused imports.

Exported vs Unexported

Go uses a simple naming convention for visibility. Any identifier (variable, function, type, field) that starts with an uppercase letter is exported — accessible from other packages. Anything starting with a lowercase letter is unexported — visible only within its own package.

Consider a calculator package:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package calculator

import (
    "errors"
    "fmt"
)

var Version = "1.0.0"       // exported — other packages can read this
var maxValue = 1_000_000    // unexported — only visible inside calculator

type Result struct {
    Value   int       // exported field
    Label   string    // exported field
    logNote string    // unexported field — hidden from other packages
}

func Add(a, b int) int      { return a + b }       // exported
func Multiply(a, b int) int { return a * b }        // exported

func validate(n int) error {                         // unexported
    if n < 0 {
        return errors.New("negative numbers not allowed")
    }
    if n > maxValue {
        return fmt.Errorf("value %d exceeds max %d", n, maxValue)
    }
    return nil
}

func SafeAdd(a, b int) (int, error) {
    if err := validate(a); err != nil {
        return 0, fmt.Errorf("invalid first argument: %w", err)
    }
    if err := validate(b); err != nil {
        return 0, fmt.Errorf("invalid second argument: %w", err)
    }
    return a + b, nil
}

From another package, you can call calculator.Add, calculator.SafeAdd, and read calculator.Version. You cannot call calculator.validate or access calculator.maxValue — the compiler will reject it. Likewise, if you create a calculator.Result from outside the package, you can set Value and Label but not logNote. This visibility rule is Go’s entire encapsulation mechanism — there are no public/private keywords.

A constructor function is the idiomatic way to initialize structs with unexported fields:

1
2
3
4
5
6
7
8
9
10
11
12
func NewResult(value int, label string) Result {
    return Result{
        Value:   value,
        Label:   label,
        logNote: fmt.Sprintf("computed %s = %d", label, value),
    }
}

func (r Result) String() string {
    return fmt.Sprintf("Result{Value: %d, Label: %q, logNote: %q}",
        r.Value, r.Label, r.logNote)
}

Calling calculator.NewResult(42, "sum") returns a Result with all fields properly set, including the unexported logNote. The String() method can read logNote because it is defined within the same package.

The init() Function

Each package can define one or more init() functions. These run automatically when the package is loaded — after all package-level variables are initialized, but before main() starts. You do not call init() yourself; Go handles the timing.

1
2
3
4
5
package calculator

func init() {
    fmt.Println("  [calculator.init] calculator package initialized!")
}

When any other package imports calculator, Go runs this init() function as a side effect. The order follows the import dependency graph: if package A imports package B, then B’s init() runs before A’s. Within a single package, init() functions run in the order they appear in source files (sorted by filename).

The init() function is useful for registering drivers, validating configuration, or setting up package-level state. But use it sparingly — implicit initialization can make code harder to reason about.

Package Organization

As a project grows, you split code into multiple packages. A common pattern is a registry package that other packages register with during init(). This decouples the registry from knowing about specific implementations.

Here is a registry package that stores encoders by name:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package registry

import "fmt"

var drivers = map[string]Encoder{}

type Encoder interface {
    Encode(v any) ([]byte, error)
}

func Register(name string, enc Encoder) {
    drivers[name] = enc
    fmt.Printf("    [registry] registered %q driver\n", name)
}

func List() []string {
    names := make([]string, 0, len(drivers))
    for name := range drivers {
        names = append(names, name)
    }
    return names
}

func Get(name string) (Encoder, bool) {
    enc, ok := drivers[name]
    return enc, ok
}

Individual driver packages implement the Encoder interface and register themselves via init():

1
2
3
4
5
6
7
8
9
10
11
12
package json

import (
    "encoding/json"
    "go-learn/ex15_packages/registry"
)

type encoder struct{}

func (encoder) Encode(v any) ([]byte, error) { return json.Marshal(v) }

func init() { registry.Register("json", encoder{}) }
1
2
3
4
5
6
7
8
9
10
11
12
package xml

import (
    "encoding/xml"
    "go-learn/ex15_packages/registry"
)

type encoder struct{}

func (encoder) Encode(v any) ([]byte, error) { return xml.Marshal(v) }

func init() { registry.Register("xml", encoder{}) }

Each driver package has zero coupling to the other drivers. The registry does not import the driver packages. The only thing that ties them together is the Encoder interface.

Blank Imports

The driver packages above register themselves through init(), but nothing in the main program directly calls any function from them. Normally, Go would reject an unused import. The blank import syntax — _ "package/path" — tells the compiler “import this package for its side effects only”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
    "fmt"
    "go-learn/ex15_packages/calculator"
    "go-learn/ex15_packages/registry"

    _ "go-learn/ex15_packages/drivers/json"
    _ "go-learn/ex15_packages/drivers/xml"
)

func main() {
    fmt.Println("Version:", calculator.Version)
    fmt.Println("2 + 3 =", calculator.Add(2, 3))

    result := calculator.NewResult(42, "answer")
    fmt.Println(result)

    drivers := registry.List()
    fmt.Println("registered drivers:", drivers)

    if enc, ok := registry.Get("json"); ok {
        data, _ := enc.Encode(map[string]int{"x": 1, "y": 2})
        fmt.Println("json output:", string(data))
    }
}

When Go loads this program, the import graph triggers init() functions in dependency order: first the registry package, then the json and xml driver packages (which call registry.Register), then the calculator package, and finally main. By the time main() runs, both drivers are registered and ready to use.

This pattern — a registry interface plus blank-imported driver packages — is used throughout Go’s standard library. The database/sql package works exactly this way: you import a driver like _ "github.com/lib/pq" and the driver registers itself with sql.Register during init(). The image package uses the same pattern for image format decoders.

io.Reader and io.Writer

Go’s io package defines two small interfaces that underpin almost all I/O in the language. io.Reader reads bytes from a source; io.Writer writes bytes to a destination. Because they are interfaces, any type that implements the right method signature qualifies — files, network connections, in-memory buffers, compressors, encryptors, and anything else you can imagine. This composability is what makes Go’s I/O system so powerful.

The Reader Interface

io.Reader has a single method:

1
2
3
type Reader interface {
    Read(p []byte) (n int, err error)
}

The caller provides a byte slice p, and the reader fills it with up to len(p) bytes. It returns the number of bytes read and an error. When the data source is exhausted, it returns io.EOF. The key insight is that Read may return fewer bytes than requested — you typically call it in a loop.

Here is how to read from a strings.Reader in small chunks:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
reader := strings.NewReader("Hello, Go io.Reader!")
buf := make([]byte, 5)

for {
    n, err := reader.Read(buf)
    if n > 0 {
        fmt.Printf("  read %d bytes: %q\n", n, buf[:n])
    }
    if err == io.EOF {
        fmt.Println("  reached EOF")
        break
    }
    if err != nil {
        fmt.Println("  error:", err)
        break
    }
}

The 5-byte buffer means each Read call gets at most 5 bytes. The string “Hello, Go io.Reader!” is 20 bytes, so this loop will make four full reads and one final read that returns io.EOF. Notice that we check n > 0 before checking err — a Read can return both data and io.EOF on the same call.

The Writer Interface

io.Writer is the mirror of io.Reader:

1
2
3
type Writer interface {
    Write(p []byte) (n int, err error)
}

You pass in a byte slice, and the writer consumes it. Two of the most common writers are os.Stdout (standard output) and bytes.Buffer (an in-memory buffer that grows as needed):

1
2
3
4
5
6
7
var buf bytes.Buffer
buf.Write([]byte("Hello "))
buf.Write([]byte("Buffer!"))
fmt.Println("buffer contents:", buf.String())
fmt.Println("buffer length:", buf.Len())

fmt.Fprintln(os.Stdout, "this goes to stdout via io.Writer")

bytes.Buffer implements io.Writer, so you can pass it anywhere a writer is expected. fmt.Fprintln takes any io.Writer as its first argument — passing os.Stdout writes to the terminal, but you could just as easily pass a file, a network connection, or a buffer.

io.Copy

io.Copy connects a reader to a writer. It reads from the source and writes to the destination until it hits io.EOF or an error:

1
2
3
4
src := strings.NewReader("streaming data from reader to writer")
dst := &bytes.Buffer{}
n, err := io.Copy(dst, src)
fmt.Printf("copied %d bytes: %q\n", n, dst.String())

There is also io.CopyN for copying a specific number of bytes:

1
2
3
4
src2 := strings.NewReader("only first 10 bytes please")
dst2 := &bytes.Buffer{}
n2, _ := io.CopyN(dst2, src2, 10)
fmt.Printf("copied %d bytes: %q\n", n2, dst2.String())

io.Copy is the idiomatic way to transfer data between any reader and writer. It handles the read loop, buffering, and EOF detection for you.

Composing Readers and Writers

The io package provides several functions that wrap readers and writers to add behavior. This is composition over inheritance — you build complex I/O pipelines by layering simple wrappers.

io.TeeReader creates a reader that writes everything it reads to a writer, like the Unix tee command:

1
2
3
4
5
6
7
original := strings.NewReader("data to tee")
var log bytes.Buffer
tee := io.TeeReader(original, &log)

result, _ := io.ReadAll(tee)
fmt.Printf("read: %q\n", string(result))
fmt.Printf("log captured: %q\n", log.String())

io.MultiReader concatenates multiple readers into one. Reads drain each reader in order:

1
2
3
4
5
6
7
r1 := strings.NewReader("Hello ")
r2 := strings.NewReader("from ")
r3 := strings.NewReader("multiple readers!")
multi := io.MultiReader(r1, r2, r3)

combined, _ := io.ReadAll(multi)
fmt.Printf("combined: %q\n", string(combined))

io.LimitReader wraps a reader and stops after a fixed number of bytes:

1
2
3
4
unlimited := strings.NewReader("this is a very long string that we want to limit")
limited := io.LimitReader(unlimited, 20)
data, _ := io.ReadAll(limited)
fmt.Printf("limited read: %q\n", string(data))

These building blocks compose freely. You could create a TeeReader that wraps a LimitReader that wraps a MultiReader — each layer adds one piece of behavior, and the io.Reader interface is the glue that holds them together.

Custom Reader

Because io.Reader is just an interface with one method, you can create your own readers. Here is a CountingReader that wraps any reader and tracks how many bytes have been read:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type CountingReader struct {
    reader    io.Reader
    BytesRead int
}

func NewCountingReader(r io.Reader) *CountingReader {
    return &CountingReader{reader: r}
}

func (cr *CountingReader) Read(p []byte) (int, error) {
    n, err := cr.reader.Read(p)
    cr.BytesRead += n
    return n, err
}

The implementation delegates to the wrapped reader and adds up the byte count. Because CountingReader satisfies io.Reader, it can be used anywhere a reader is expected:

1
2
3
4
source := strings.NewReader("count these bytes")
counter := NewCountingReader(source)
io.ReadAll(counter)
fmt.Printf("total bytes read: %d\n", counter.BytesRead)

Here is a more involved example — a RepeatReader that produces the same text a given number of times:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type RepeatReader struct {
    text      string
    remaining int
    current   *strings.Reader
}

func NewRepeatReader(text string, times int) *RepeatReader {
    return &RepeatReader{text: text, remaining: times}
}

func (rr *RepeatReader) Read(p []byte) (int, error) {
    for {
        if rr.current != nil {
            n, err := rr.current.Read(p)
            if n > 0 {
                return n, nil
            }
            if err == io.EOF {
                rr.current = nil
                continue
            }
            return n, err
        }
        if rr.remaining <= 0 {
            return 0, io.EOF
        }
        rr.remaining--
        rr.current = strings.NewReader(rr.text)
    }
}

Each time the current strings.Reader is exhausted, RepeatReader creates a fresh one from the same text — until the repeat count reaches zero, at which point it returns io.EOF. This pattern of lazily creating underlying readers is common in Go I/O code.

1
2
3
4
repeater := NewRepeatReader("Go! ", 3)
data, _ := io.ReadAll(repeater)
fmt.Printf("repeated: %q\n", string(data))
// Output: "Go! Go! Go! "

JSON

Go’s encoding/json package handles serialization (Go values to JSON) and deserialization (JSON to Go values). It uses struct tags to control how struct fields map to JSON keys, and it provides both high-level functions (Marshal/Unmarshal) and a streaming decoder for working with JSON data.

Struct Tags

Struct tags are metadata strings attached to struct fields. The json tag tells the JSON encoder and decoder what key name to use for each field. Without tags, the JSON key matches the Go field name exactly. Unexported fields (lowercase) are always ignored by the JSON package.

1
2
3
4
5
6
7
type Person struct {
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Age       int    `json:"age"`
    Email     string `json:"email"`
    password  string // unexported — json package cannot see this
}

When you marshal a Person, the JSON keys will be first_name, last_name, age, and email. The password field is invisible to the JSON encoder because it starts with a lowercase letter:

1
2
3
4
5
6
7
8
9
10
p := Person{
    FirstName: "Alice",
    LastName:  "Smith",
    Age:       30,
    Email:     "alice@example.com",
    password:  "secret123",
}
data, _ := json.Marshal(p)
fmt.Println(string(data))
// {"first_name":"Alice","last_name":"Smith","age":30,"email":"alice@example.com"}

The password field is excluded entirely. This is a natural consequence of Go’s export rules — the JSON package is in a different package from your struct, so it can only see exported fields.

Marshal — Go to JSON

json.Marshal converts any Go value to a JSON byte slice. It works with structs, slices, maps, and primitive types:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Point struct {
    X int `json:"x"`
    Y int `json:"y"`
}

// Struct
p := Point{X: 1, Y: 2}
data, _ := json.Marshal(p)
fmt.Println(string(data))
// {"x":1,"y":2}

// Slice
points := []Point{{X: 1, Y: 2}, {X: 3, Y: 4}}
data, _ = json.Marshal(points)
fmt.Println(string(data))
// [{"x":1,"y":2},{"x":3,"y":4}]

// Map
m := map[string]int{"width": 100, "height": 200}
data, _ = json.Marshal(m)
fmt.Println(string(data))
// {"height":200,"width":100}

For human-readable output, use json.MarshalIndent:

1
2
3
4
5
6
data, _ = json.MarshalIndent(p, "", "  ")
fmt.Println(string(data))
// {
//   "x": 1,
//   "y": 2
// }

The second argument is a prefix for each line, and the third is the indent string. Using an empty prefix and two spaces is the most common convention.

Unmarshal — JSON to Go

json.Unmarshal takes a JSON byte slice and populates a Go value. You pass a pointer to the target value so the function can modify it:

1
2
3
4
5
6
7
8
9
jsonStr := `{"first_name":"Bob","last_name":"Jones","age":25,"email":"bob@example.com"}`

var person Person
err := json.Unmarshal([]byte(jsonStr), &person)
if err != nil {
    fmt.Println("error:", err)
}
fmt.Printf("%+v\n", person)
// {FirstName:Bob LastName:Jones Age:25 Email:bob@example.com password:}

Two important behaviors of Unmarshal: unknown fields in the JSON are silently ignored, and missing fields get Go’s zero values. If the JSON contains a key phone that has no matching struct field, nothing happens. If the JSON is missing the age key, the struct’s Age field stays at 0.

1
2
3
4
5
partial := `{"first_name":"Charlie"}`
var p2 Person
json.Unmarshal([]byte(partial), &p2)
fmt.Printf("%+v\n", p2)
// {FirstName:Charlie LastName: Age:0 Email: password:}

Tag Options

Struct tags support several options that control marshaling behavior. The most useful ones are omitempty, -, and string.

1
2
3
4
5
6
7
8
9
type Config struct {
    Name     string  `json:"name"`
    Debug    bool    `json:"debug,omitempty"`
    Verbose  bool    `json:"verbose,omitempty"`
    Secret   string  `json:"-"`
    Count    int     `json:"count,string"`
    Timeout  *int    `json:"timeout,omitempty"`
    MaxRetry *int    `json:"max_retry,omitempty"`
}

The omitempty option tells the encoder to skip the field if it has its zero value (empty string, 0, false, nil pointer, empty slice, or empty map). This keeps the JSON output compact by omitting fields that carry no information.

The "-" tag tells the encoder to always skip this field, regardless of its value. Use this for sensitive data or internal state that should never appear in JSON output.

The "string" option encodes a numeric or boolean field as a JSON string. This is useful when interacting with APIs that represent numbers as strings.

Pointer fields interact interestingly with omitempty. A nil pointer is omitted, but a pointer to a zero value is included:

1
2
3
4
5
6
7
8
9
10
11
12
13
timeout := 0
maxRetry := 0

c1 := Config{
    Name:     "app",
    Secret:   "password",
    Count:    42,
    Timeout:  &timeout,
    MaxRetry: nil,
}

data, _ := json.MarshalIndent(c1, "", "  ")
fmt.Println(string(data))

In this output, Debug and Verbose are omitted (zero booleans with omitempty), Secret is omitted (tag is "-"), Count appears as "42" (a string, not a number), Timeout appears as 0 (pointer to zero is not nil, so it is included), and MaxRetry is omitted (nil pointer with omitempty). This distinction between nil and zero is the main reason to use pointer fields with omitempty.

Dynamic JSON

Sometimes you do not know the JSON structure at compile time. Go provides several tools for working with dynamic JSON.

The first is map[string]any. Unmarshaling into a map gives you a dynamic key-value structure where the JSON decoder chooses Go types for you (strings become string, numbers become float64, booleans become bool, objects become map[string]any, and arrays become []any):

1
2
3
4
5
6
7
dynamic := `{"name":"test","count":42,"active":true,"tags":["a","b"]}`
var m map[string]any
json.Unmarshal([]byte(dynamic), &m)

for key, val := range m {
    fmt.Printf("  %s: %v (%T)\n", key, val, val)
}

The second tool is json.RawMessage. It lets you defer parsing part of a JSON document. The raw bytes are stored as-is, and you decode them later once you know what type to expect. This is ideal for envelope patterns where a type field determines the schema of a payload field:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
type Envelope struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

type TextMsg struct {
    Body string `json:"body"`
}

type ImageMsg struct {
    URL    string `json:"url"`
    Width  int    `json:"width"`
    Height int    `json:"height"`
}

messages := []string{
    `{"type":"text","payload":{"body":"Hello!"}}`,
    `{"type":"image","payload":{"url":"pic.png","width":800,"height":600}}`,
}

for _, raw := range messages {
    var env Envelope
    json.Unmarshal([]byte(raw), &env)

    switch env.Type {
    case "text":
        var msg TextMsg
        json.Unmarshal(env.Payload, &msg)
        fmt.Printf("  text message: %s\n", msg.Body)
    case "image":
        var msg ImageMsg
        json.Unmarshal(env.Payload, &msg)
        fmt.Printf("  image: %s (%dx%d)\n", msg.URL, msg.Width, msg.Height)
    }
}

The Payload field stays as raw JSON bytes until we inspect Type and know which struct to decode into. Without json.RawMessage, you would need to unmarshal twice or use a map.

The third tool is json.Decoder, which reads JSON values from an io.Reader stream. This is useful for processing multiple JSON objects from a single source (a file, a network connection, or any reader) without loading everything into memory at once:

1
2
3
4
5
6
7
8
9
10
stream := `{"name":"Alice"}{"name":"Bob"}{"name":"Charlie"}`
decoder := json.NewDecoder(strings.NewReader(stream))

for decoder.More() {
    var obj map[string]string
    if err := decoder.Decode(&obj); err != nil {
        break
    }
    fmt.Printf("  decoded: %v\n", obj)
}

decoder.More() returns true as long as there is another JSON value in the stream. Each call to Decode reads exactly one value, advances the stream position, and populates the target. This is more memory-efficient than reading the entire input and splitting it yourself.

Testing

Go has a built-in testing framework in the testing package. Test files live next to the code they test, with a _test.go suffix. Test functions start with Test and take a single *testing.T argument. You run them with go test.

Basic Tests

The simplest test calls a function and checks the result. If the result is wrong, you call t.Errorf with a descriptive message. There is no assertion library — just conditional checks and error reporting:

1
2
3
4
5
6
7
8
9
10
11
12
// math_test.go
package main

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d, want %d", got, want)
    }
}

The got/want naming convention is idiomatic Go. If got does not equal want, t.Errorf logs the failure and the test continues (unlike t.Fatalf, which stops the test immediately). A test with no errors passes.

Table-Driven Tests

When you need to test a function with many inputs, Go developers use table-driven tests. You define a slice of test cases — each with a name, inputs, and expected outputs — and loop over them with t.Run. This pattern is clean, easy to extend, and produces well-labeled output when a test fails:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        want      float64
        wantError bool
    }{
        {name: "positive", a: 10, b: 2, want: 5, wantError: false},
        {name: "negative result", a: -10, b: 2, want: -5, wantError: false},
        {name: "divide by zero", a: 5, b: 0, want: 0, wantError: true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := Divide(tt.a, tt.b)
            if tt.wantError {
                if err == nil {
                    t.Errorf("expected error")
                }
                return
            }
            if err != nil {
                t.Errorf("unexpected error: %v", err)
                return
            }
            if got != tt.want {
                t.Errorf("got %v, want %v", got, tt.want)
            }
        })
    }
}

Each call to t.Run creates a subtest with the given name. When you run go test -v, you see output like TestDivide/positive, TestDivide/negative_result, and TestDivide/divide_by_zero. If the “divide by zero” case fails, the name tells you exactly which case went wrong without you having to count indices.

Edge Cases

Table-driven tests make it natural to include edge cases alongside the happy path. Here is a primality check test that covers zero, one, negative numbers, and a large prime:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestIsPrime(t *testing.T) {
    tests := []struct {
        name string
        n    int
        want bool
    }{
        {"zero", 0, false},
        {"one", 1, false},
        {"two", 2, true},
        {"four", 4, false},
        {"negative", -7, false},
        {"large prime", 97, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := IsPrime(tt.n); got != tt.want {
                t.Errorf("IsPrime(%d) = %v, want %v", tt.n, got, tt.want)
            }
        })
    }
}

The test covers the boundary conditions that are most likely to expose bugs: numbers below 2, the smallest prime, a composite number, and a negative number. Adding a new edge case is just adding one more entry to the slice.

Test Helpers with t.Helper()

When you find yourself repeating the same assertion logic across multiple tests, extract it into a helper function. Calling t.Helper() at the top of the helper tells the testing framework to report failures at the caller’s line number, not inside the helper. Without t.Helper(), every failure would point to the same line inside the helper function, making it hard to tell which test case failed:

1
2
3
4
5
6
7
8
9
10
11
12
13
func assertEqual(t *testing.T, got, want string) {
    t.Helper()
    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }
}

func TestFizzBuzz(t *testing.T) {
    assertEqual(t, FizzBuzz(1), "1")
    assertEqual(t, FizzBuzz(3), "Fizz")
    assertEqual(t, FizzBuzz(5), "Buzz")
    assertEqual(t, FizzBuzz(15), "FizzBuzz")
}

If FizzBuzz(5) returned the wrong value, the failure message would point to the assertEqual(t, FizzBuzz(5), "Buzz") line in TestFizzBuzz, not to the t.Errorf line inside assertEqual. This is the difference t.Helper() makes.

Testing Unicode

Go strings are UTF-8 encoded, and operations that work on bytes can break multi-byte characters. Testing with unicode input catches these issues. Here is a Reverse function tested with ASCII, unicode, and emoji:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TestReverse(t *testing.T) {
    tests := []struct {
        name, input, want string
    }{
        {"ascii", "hello", "olleh"},
        {"empty", "", ""},
        {"unicode", "Hello, 世界", "界世 ,olleH"},
        {"emoji", "Go🚀", "🚀oG"},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Reverse(tt.input); got != tt.want {
                t.Errorf("Reverse(%q) = %q, want %q", tt.input, got, tt.want)
            }
        })
    }
}

The Reverse function must work on runes (Unicode code points), not bytes. If it reversed bytes instead of runes, the multi-byte characters in “Hello, 世界” would be corrupted. Including unicode test cases in the table is a simple way to verify correct behavior:

1
2
3
4
5
6
7
func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

Converting to []rune ensures each element is a full Unicode code point. The swap loop works on runes, not bytes, so multi-byte characters like , , and 🚀 are reversed correctly. The test table makes it trivial to add more unicode edge cases as you discover them.