Tests are first-class citizens in Go. There’s no extra framework to install, no config file to write — Go ships with a complete testing tool out of the box. Run go test and your tests run.

Writing tests is one of the most underrated investments in any codebase. Even a small, scrappy test suite catches more bugs than careful reading ever will.

A first test

Tests live next to the code they test, in files ending in _test.go. Let’s build a tiny library and test it.

Project structure:

mathx/
├── go.mod
├── mathx.go
└── mathx_test.go

The code:

package mathx

func Add(a, b int) int {
    return a + b
}

The test:

package mathx

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)
    }
}

Run it from the project root:

go test
PASS
ok  	mathx	0.123s

Three rules for Go test functions:

  1. The file ends in _test.go
  2. The function name starts with Test (capital T)
  3. The function takes a single *testing.T parameter

That’s it — no annotations, no inheritance, no setup files.

Inside *testing.T

The methods on t you’ll use most:

  • t.Errorf(format, args...) — record a failure but keep the test running
  • t.Fatalf(format, args...) — record a failure and stop the test immediately
  • t.Logf(format, args...) — log a message (only shown if the test fails or you use -v)
  • t.Skip(reason) — skip this test (e.g., for environment-dependent ones)
  • t.Run(name, func) — run a subtest

Use Errorf when you want to know all the things that went wrong; use Fatalf when continuing wouldn’t make sense (e.g., a setup step failed).

Table-driven tests

Most Go developers write tests as a table — a slice of test cases, looped over. It’s cleaner than copying-and-pasting test bodies.

package mathx

import "testing"

func TestAdd(t *testing.T) {
    cases := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"two positives", 2, 3, 5},
        {"two negatives", -1, -2, -3},
        {"with zero", 0, 7, 7},
        {"mixed signs", -5, 5, 0},
    }

    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got := Add(tc.a, tc.b)
            if got != tc.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

Run with verbose output:

go test -v
=== RUN   TestAdd
=== RUN   TestAdd/two_positives
=== RUN   TestAdd/two_negatives
=== RUN   TestAdd/with_zero
=== RUN   TestAdd/mixed_signs
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/two_positives (0.00s)
    --- PASS: TestAdd/two_negatives (0.00s)
    --- PASS: TestAdd/with_zero (0.00s)
    --- PASS: TestAdd/mixed_signs (0.00s)
PASS
ok  	mathx	0.123s

t.Run creates a subtest — each case shows up separately, you can run just one (go test -run TestAdd/two_positives), and a failure in one doesn’t stop the others.

Table-driven tests are so common in Go that the style is practically the standard. Get comfortable with them.

Code coverage

Go ships with a coverage tool. Use the -cover flag:

go test -cover
PASS
coverage: 100.0% of statements
ok  	mathx	0.123s

For a visual report, generate an HTML view:

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

This opens a browser tab showing each source file with covered lines in green and uncovered lines in red. Useful for spotting gaps.

Don’t chase 100% coverage as a goal. Cover the logic that matters; skip trivial getters/setters and code that’s hard to mock without convoluting the design.

Benchmarks

Performance tests live in the same _test.go files but start with Benchmark instead of Test. They take *testing.B.

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

Run benchmarks (they aren’t run by go test by default):

go test -bench=.
goos: darwin
goarch: arm64
BenchmarkAdd-10  	1000000000	         0.2873 ns/op
PASS
ok  	mathx	0.456s

Go figures out how many iterations to run for a meaningful measurement (b.N). You write the loop; Go figures out the count.

Examples that double as documentation

Functions named ExampleXxx are run by go test AND show up in your generated documentation:

import "fmt"

func ExampleAdd() {
    sum := Add(2, 3)
    fmt.Println(sum)
    // Output: 5
}

The // Output: 5 comment is special — Go runs the example, captures stdout, and verifies it matches. So your docs literally cannot lie. If Add ever changes to return 6, the example test fails.

Mocking with interfaces

Go has no built-in mocking framework, but it doesn’t need one. Because interfaces are implicit, you can write tiny “fake” types directly in your test file.

package main

type Mailer interface {
    Send(to, body string) error
}

type Notifier struct {
    mailer Mailer
}

func (n *Notifier) Welcome(email string) error {
    return n.mailer.Send(email, "Welcome!")
}
package main

import "testing"

type fakeMailer struct {
    sent []string
}

func (f *fakeMailer) Send(to, body string) error {
    f.sent = append(f.sent, to+": "+body)
    return nil
}

func TestNotifierWelcome(t *testing.T) {
    fake := &fakeMailer{}
    n := &Notifier{mailer: fake}

    if err := n.Welcome("[email protected]"); err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(fake.sent) != 1 {
        t.Errorf("expected 1 sent message, got %d", len(fake.sent))
    }
}

A real fake, written in 6 lines. No mock library, no special syntax.

A few practical commands

go test                      # run all tests in current package
go test ./...                # run all tests in all packages (recursive)
go test -v                   # verbose: print each test name
go test -run TestName        # only run tests matching the regex "TestName"
go test -race                # run with the race detector
go test -count=1             # disable test result caching (useful when reasoning about flakiness)

-race is the killer flag for concurrent code — always run your tests with it before shipping.

What’s next

You can write production-quality Go: well-structured, error-aware, concurrent, and tested. The final two lessons cover features that go beyond fundamentals — reflection and generics.

Toggle theme (T)