Skip to content
Muhammet Şafak
tr
Languages 4 min read

Writing Tests in Go: Table-Driven Tests

A hands-on introduction to Go's standard testing package and the table-driven test pattern, embracing the language's minimal testing philosophy.


When I first started working with Go, testing was one of the first things that caught me off guard. No test framework, no assert library, no expectation syntax. Just the testing package from the standard library — and that’s it.

My initial reaction was “is that all there is?” But a few months in, I think the complete opposite: this simplicity is a deliberate choice, and more often than not, it’s the right one.

What a test file looks like in Go

In Go, tests live in files with the _test.go suffix. The package name is typically foo_test (for black-box testing) or the same package (for white-box testing). The go test ./... command automatically discovers and runs these files.

// stringutil/reverse.go
package stringutil

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

A simple test:

// stringutil/reverse_test.go
package stringutil_test

import (
    "testing"
    "github.com/username/project/stringutil"
)

func TestReverse(t *testing.T) {
    got := stringutil.Reverse("hello")
    want := "olleh"
    if got != want {
        t.Errorf("Reverse(%q) = %q; want %q", "hello", got, want)
    }
}

t.Errorf marks the test as failed but continues execution. t.Fatalf, on the other hand, stops right there. The distinction matters: if you have multiple assertions and want to see all the results, use Errorf; if there’s no point in continuing after the first failure, use Fatalf.

Table-driven tests

The table-driven test is a pattern widely adopted by the Go community and commonly seen throughout the Go standard library source code. The idea: define multiple input/output pairs as a slice of structs, then iterate over them.

func TestReverse_Table(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  string
    }{
        {"tek harf", "a", "a"},
        {"basit kelime", "hello", "olleh"},
        {"palindrom", "racecar", "racecar"},
        {"boş string", "", ""},
        {"unicode", "Merhaba", "abahreM"},
    }

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

With t.Run, each test case runs as an independent subtest. You can even run a single subtest in isolation: go test -run TestReverse_Table/palindrom.

What makes this pattern valuable?

Readability. All cases are listed in one place. Adding a new case is a single line in the table.

Isolation. Each subtest is independent; a failure in one doesn’t affect the others.

No repetition. The test logic (call Reverse, compare, report error) is written once; the table provides the variants.

More complex scenarios

You’re not limited to input/output pairs — error scenarios fit naturally in the table too:

func TestParseDate_Table(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    string
        wantErr bool
    }{
        {"geçerli tarih", "2024-05-05", "2024-05-05", false},
        {"geçersiz format", "05/05/2024", "", true},
        {"boş girdi", "", "", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseDate(tt.input)
            if (err != nil) != tt.wantErr {
                t.Errorf("ParseDate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
                return
            }
            if !tt.wantErr && got.Format("2006-01-02") != tt.want {
                t.Errorf("ParseDate(%q) = %v; want %v", tt.input, got, tt.want)
            }
        })
    }
}

The wantErr bool field explicitly marks cases where an error is expected. Both the happy path and the error path are managed within the same table.

What about testify?

github.com/stretchr/testify is a popular alternative in the Go community. Helpers like assert.Equal and assert.NoError reduce boilerplate, and the error messages are more descriptive.

Personally, I prefer the plain testing package for smaller libraries and standard tooling. Writing your own error messages is a discipline in itself — it forces you to clearly articulate what you expected and what you actually got. Checking whether the standard library is sufficient before reaching for a dependency aligns with Go’s broader philosophy.

Running tests

# all tests
go test ./...

# specific package
go test ./stringutil/...

# verbose output
go test -v ./...

# single test
go test -run TestReverse ./stringutil/...

# coverage report
go test -cover ./...

The -race flag detects race conditions; it’s a good habit to run it on any code that involves concurrency.

The value of simplicity

If you’re used to the conveniences of PHPUnit, Jest, or RSpec, Go’s approach can feel lacking at first. But this simplicity is a trade-off: no external dependencies, test code that doesn’t change when tooling changes, and complete transparency about what you’re doing. The framework doesn’t guide you; the design decisions are yours.

Table-driven tests emerged from within this simplicity. It’s not a library — it’s a coding habit. And once you internalize it, it starts showing up in other languages too; I now use the same mental model when writing a dataProvider in PHPUnit. Being able to take a test pattern from one language and carry it into another is one of the hidden benefits of working polyglot. Go’s testing tools are minimal, but that minimalism pushes you to think about test design itself and build your own structure — a skill that ready-made conveniences often quietly obscure.

Tags: #Go#Testing
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind