Saltar a contenido

10 - Tests

What this session is

About an hour. You'll learn how to write tests for your Go code, run them, and read their output. This is the page that changes you from "I wrote some code, it seemed to work" to "I wrote some code and I have proof it does what I think." It's also the page that makes contributing to OSS projects possible - every real project has tests, and you'll spend more time running them than writing them.

Why tests

When you change code, you might break something that used to work. The change you made looks fine. The thing that broke is in a file you haven't opened in three weeks. Without tests, you find out when a user does.

A test is a small program that calls your code with known inputs and checks that the outputs are what you expect. You run the tests after every change. If they all pass, you keep going. If one fails, you know exactly what broke.

This sounds obvious. Beginner programmers skip it for years because it feels like extra work. It is not extra work. It is the work that prevents three hours of debugging next week.

Go's testing setup

Go has testing built in. No installs, no frameworks, no configuration.

Convention:

  • Test code lives in files ending in _test.go.
  • Each test is a function named TestXxx, taking one parameter t *testing.T.
  • You run tests with go test.

Set up:

mkdir mathutils && cd mathutils
go mod init mathutils

go mod init mathutils creates a go.mod file declaring this folder as a Go module. We'll explain modules properly in page 11. For now, just run the command - it lets go test work.

Create a file math.go:

package mathutils

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

func IsEven(n int) bool {
    return n%2 == 0
}

Notice this file starts with package mathutils, not package main. There's no func main(). That's because this is a library package - code meant to be called from other code, not run directly.

Now create a file math_test.go (note the _test):

package mathutils

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

func TestIsEven(t *testing.T) {
    if !IsEven(4) {
        t.Error("IsEven(4) should be true")
    }
    if IsEven(7) {
        t.Error("IsEven(7) should be false")
    }
}

Run:

go test

You should see:

PASS
ok      mathutils    0.001s

Type go test -v for a verbose version showing each test by name. Useful when you have many tests.

The mechanics

  • func TestAdd(t *testing.T) - the name must start with Test (capital T) followed by a capital letter or digit. The parameter must be *testing.T exactly.
  • t.Errorf(...) - reports a failure with a formatted message, but the test keeps running. Useful when one test has multiple checks.
  • t.Error(...) - reports a failure with a plain message. Same: keeps running.
  • t.Fatalf(...) / t.Fatal(...) - report a failure and stop this test immediately. Use when subsequent checks would crash (e.g., dereferencing a nil result).
  • t.Logf(...) - prints output (only shown with -v or on failure). Useful for "diagnostic" messages.

The "got/want" idiom is the standard shape:

got := SomeFunction(input)
want := expectedValue
if got != want {
    t.Errorf("SomeFunction(%v) = %v; want %v", input, got, want)
}

You'll see this exact shape in 99% of Go codebases. Mirror it.

Making a test fail (do this)

Open math.go, change Add to return a - b. Save. Run go test.

You'll see:

--- FAIL: TestAdd (0.00s)
    math_test.go:9: Add(2, 3) = -1; want 5
FAIL
exit status 1
FAIL    mathutils       0.001s

The test caught the bug. Notice how informative the error is - it tells you which test, which file/line, what input, what actual result, what expected result. That's the value.

Change Add back. Re-run. It passes again.

Table-driven tests: the idiomatic pattern

When you have many cases for the same function, don't write separate Test* functions. Write table-driven tests:

func TestIsEven(t *testing.T) {
    cases := []struct {
        input int
        want  bool
    }{
        {0, true},
        {1, false},
        {2, true},
        {-4, true},
        {-7, false},
        {1000, true},
    }

    for _, c := range cases {
        got := IsEven(c.input)
        if got != c.want {
            t.Errorf("IsEven(%d) = %v; want %v", c.input, got, c.want)
        }
    }
}

What's happening:

  • cases := []struct{ input int; want bool }{ ... } - a slice of anonymous structs, each with the inputs and expected outputs for one test case. (Yes, you can declare struct types inline.)
  • The loop runs each case as a sub-test.

This is the single most common test shape in Go code. When you read OSS Go projects, the test files will be ~80% table-driven. Recognize the pattern.

Subtests with t.Run

When you want each case to be reportable separately:

for _, c := range cases {
    c := c   // capture loop variable (subtle Go quirk pre-1.22)
    t.Run(fmt.Sprintf("input=%d", c.input), func(t *testing.T) {
        got := IsEven(c.input)
        if got != c.want {
            t.Errorf("got %v; want %v", got, c.want)
        }
    })
}

go test -v now shows each sub-test by name. go test -run TestIsEven/input=4 runs just one case. This is the production-grade form.

(The c := c line is a quirk: pre-Go-1.22, the loop variable was shared across iterations. The local re-declaration captured a fresh copy for each goroutine/subtest. In Go 1.22+, the loop semantics changed and you don't need this. Real code still has it, for backward compat. Recognize it.)

Test helpers

If you find yourself writing the same assertion in many tests, extract a helper:

func mustEqual(t *testing.T, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("got %d; want %d", got, want)
    }
}

t.Helper() tells the testing framework: "when reporting a failure from this function, don't point at this function - point at the line that called it." Without it, all your failures point at mustEqual, which isn't useful.

Running tests

Command What it does
go test Run tests in the current package.
go test -v Same, but verbose (show each test).
go test ./... Run tests in current package and all sub-packages.
go test -run TestAdd Run only tests matching the regex "TestAdd".
go test -count=1 Disable test caching (force re-run).
go test -race Run with the data-race detector. Always do this for code with goroutines.
go test -cover Report what percentage of code your tests cover.

go test -race is the one you should reach for any time you have goroutines. It catches data races that would otherwise be invisible until production.

Exercise

Set up a new package and test it.

  1. Make a folder ~/code/go-learning/wordtools. cd into it. Run go mod init wordtools.

  2. Create words.go:

    package wordtools
    
    import "strings"
    
    func WordCount(s string) int {
        return len(strings.Fields(s))
    }
    
    func IsPalindrome(s string) bool {
        s = strings.ToLower(s)
        for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
            if s[i] != s[j] {
                return false
            }
        }
        return true
    }
    

  3. Create words_test.go. Write table-driven tests for both:

  4. WordCount: "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  5. IsPalindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true (note: lowercased first).

  6. Run go test -v. All tests should pass.

  7. Break each function on purpose, watch the relevant test fail, fix it, watch it pass.

What you might wonder

"Where do tests usually go in real projects?" Right next to the code they test. foo.go and foo_test.go in the same folder, same package. This is universal in Go. You'll see it in every project.

"Should I write the test first or the code first?" Either works. Some people swear by "test-first" (TDD). The honest answer: any tests are infinitely better than no tests. Start by writing the code, then writing a test. After a few months, try writing the test first sometimes; see which feels better.

"How much test coverage should I aim for?" There's no magic number. 80% is a common target but it's a bad goal - you can hit 80% with tests that don't actually catch bugs. Better: every bug fix gets a test that would have caught the bug, and every important code path has at least one test.

"What about mocking?" Mocking means replacing real dependencies (databases, network) with fake ones during a test. Go has tools for it (gomock, hand-written fakes); the idiomatic approach in Go is "use interfaces sparingly, prefer real or in-memory implementations." We'll meet interfaces in the next page; mocking is out of scope here.

Done

You can now: - Set up a Go module with go mod init. - Write tests in _test.go files with func TestXxx(t *testing.T). - Use t.Errorf, t.Fatalf, t.Helper correctly. - Write table-driven tests - the Go idiomatic shape. - Run tests with go test, with -v for detail and -race for goroutine safety. - Read a failing test's output and find the bug.

You can now verify your own code. More importantly, you can now read the test files in any real Go project and understand what they're checking. That's most of what makes a real codebase legible.

Next page: packages and modules - how Go code is organized into reusable pieces, and how you bring in code other people wrote.

Next: Packages and modules11-packages-and-modules.md

Comments