Testing in Go
This guide is for developers with .NET (C#) experience. It covers the testing package, table-driven tests, testify, mocking, and benchmarks. Go testing is built into the standard toolchain and needs no test framework or attributes like xUnit. Once you understand how the approach differs from C#, you can write thorough tests with very few tools.
1. What This Document Covers
- _test.go and go test basics
- Test file naming rules,
TestXxxfunctions, and when to uset.Errorversust.Fatal
- Test file naming rules,
- Table-driven tests and subtests
- The Go equivalent of
[Theory]/[InlineData], plust.Runandt.Parallel
- The Go equivalent of
- Preferring fakes over mocks
- Define a small interface on the consumer side and swap in a hand-written fake
- httptest and Testcontainers
- Testing HTTP handlers and running integration tests against a real database
- Benchmarks and fuzzing
- Measuring performance with
testing.Band fuzz testing withtesting.F
- Measuring performance with
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
| xUnit / NUnit | testing package (go test) |
[Theory] / [InlineData] | Table-driven tests + t.Run |
| NSubstitute / Moq | Hand-written fakes of interfaces / gomock / mockery |
| Shouldly / FluentAssertions | testify / go-cmp |
| BenchmarkDotNet | testing.B (go test -bench) |
Other correspondences are as follows.
| .NET (C#) | Go |
|---|---|
Constructor / IDisposable for cleanup | t.Cleanup |
[Fact(Skip = "...")] | t.Skip |
Test project (*.Tests.csproj) | _test.go files in the same directory |
dotnet test --filter | go test -run |
| Coverlet | go test -cover |
| Parallel execution control (xUnit collections) | t.Parallel |
3. Common Pitfalls for C# Developers
3.1 Test discovery is determined by naming conventions
Go has no attributes such as [Fact] or [Test]. Tests are recognized by the following rules alone.
- The file name ends with
_test.go - The function name starts with
Testand has the signaturefunc TestXxx(t *testing.T) - The file is placed in the same directory as the code under test
Test code lives in the same directory as production code. This differs from the C# convention of using a separate project.
Writing package foo gives you a white-box test that can access unexported internals. Writing package foo_test gives you a black-box test that uses only the public API. Both can coexist in the same directory.
3.2 There are no assertions in the standard library
The standard library has nothing equivalent to Assert.Equal. The basic approach is to compare values with if and report failures with t.Errorf.
t.Error/t.Errorf: Records a failure and continues the testt.Fatal/t.Fatalf: Records a failure and stops the test immediately
Use Fatal when later steps cannot make sense if a precondition fails. For example, if err is not nil and you then access a field of the return value, it will panic, so stop first with Fatal.
By convention, failure messages are written in a got / want format.
Add(1, 2) = 4, want 3
3.3 The replacement for [Theory] is the table-driven test
In Go, the standard approach is to list test cases in a slice and call t.Run inside a loop. Adding a case takes just one more line, and you can run an individual case with something like go test -run TestAdd/negative.
3.4 Prefer fakes over mocking frameworks
In C#, it is common to write Setup / Verify with Moq or NSubstitute. In Go, the typical flow is as follows.
- In the package that uses the dependency, define a small interface containing only the methods you need
- In the test, hand-write a simple fake struct that satisfies that interface
- Verify results and state rather than verifying call counts (
Verify)
A fake is just ordinary Go code, so it holds up well under refactoring and is easy to follow in a debugger. Consider gomock or mockery only when the interface is large and writing fakes by hand becomes a burden.
3.5 Cleanup uses t.Cleanup
In xUnit, you handle setup and cleanup with a constructor and Dispose. In Go, you register a function with t.Cleanup. Registered functions run in LIFO (last in, first out) order, and this also works in subtests. Using t.TempDir() for temporary directories and t.Setenv for environment variables automates cleanup.
When you extract shared verification logic into a separate function, call t.Helper() at the start. This makes the line number reported on failure point to the caller.
3.6 Parallel execution and loop variables
A test that calls t.Parallel() runs concurrently with other parallel tests. In Go 1.21 and earlier, loop variables were shared across iterations, so closures in subtests could end up referencing the same variable. Since Go 1.22, each iteration gets its own variable, and this problem is gone. If you target an older version of Go, you need tt := tt.
For code that involves concurrency, use go test -race to detect data races. We recommend always enabling it in CI.
3.7 Test helper libraries are not required
testify is widely used, but it is not mandatory. Many teams write tests with only the standard testing package. For comparing structs, go-cmp's cmp.Diff, which prints easy-to-read diffs, is more convenient than reflect.DeepEqual.
4. Code Examples
4.1 Table-driven tests
In C# (xUnit), you write this with [Theory].
public class CalculatorTests
{
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_ReturnsSum(int a, int b, int expected)
{
Assert.Equal(expected, Calculator.Add(a, b));
}
}
In Go, you write it with a table-driven test and subtests.
package calc
import "testing"
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{name: "positive", a: 1, b: 2, want: 3},
{name: "zero", a: 0, b: 0, want: 0},
{name: "negative", a: -1, b: 1, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
The commands to run them are as follows.
# Run the tests in the current package
go test
# Run all packages recursively (verbose output)
go test ./... -v
# Filter by name (specify subtests with a slash)
go test -run 'TestAdd/negative'
# Data race detection and coverage
go test -race -cover ./...
4.2 testify and go-cmp
testify's assert continues after a failure, while require ends the test at the point of failure. It gives you readability similar to Shouldly or FluentAssertions in C#.
package user_test
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseUser(t *testing.T) {
got, err := ParseUser(`{"id":1,"name":"alice"}`)
require.NoError(t, err) // On failure, the test ends here
assert.Equal(t, 1, got.ID)
assert.Equal(t, "alice", got.Name)
}
func TestBuildUser(t *testing.T) {
want := &User{ID: 1, Name: "alice"}
got := BuildUser(1, "alice")
// The standard library + go-cmp combination also gives readable diffs
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("BuildUser() mismatch (-want +got):\n%s", diff)
}
}
4.3 Using fakes instead of mocks
Here is a C# (NSubstitute) example.
var repo = Substitute.For<IUserRepository>();
repo.FindByIdAsync(1, Arg.Any<CancellationToken>())
.Returns(new User { Id = 1, Name = "Alice" });
var service = new UserService(repo);
var name = await service.GetDisplayNameAsync(1, CancellationToken.None);
Assert.Equal("Alice", name);
In Go, you define an interface with only the needed methods on the consumer side (the package that contains UserService).
package user
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("user not found")
type User struct {
ID int
Name string
}
// Declared on the consumer side, with only the methods it needs
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
}
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) DisplayName(ctx context.Context, id int) (string, error) {
u, err := s.repo.FindByID(ctx, id)
if err != nil {
return "", fmt.Errorf("find user %d: %w", id, err)
}
return u.Name, nil
}
In the test, you hand-write an in-memory fake.
package user
import (
"context"
"errors"
"testing"
)
// A simple fake that just satisfies the interface
type fakeUserRepo struct {
users map[int]*User
}
func (f *fakeUserRepo) FindByID(_ context.Context, id int) (*User, error) {
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
func TestUserService_DisplayName(t *testing.T) {
repo := &fakeUserRepo{
users: map[int]*User{1: {ID: 1, Name: "Alice"}},
}
svc := NewUserService(repo)
t.Run("found", func(t *testing.T) {
got, err := svc.DisplayName(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "Alice" {
t.Errorf("DisplayName() = %q, want %q", got, "Alice")
}
})
t.Run("not found", func(t *testing.T) {
_, err := svc.DisplayName(context.Background(), 999)
if !errors.Is(err, ErrNotFound) {
t.Errorf("error = %v, want ErrNotFound", err)
}
})
}
Use errors.Is / errors.As to check errors. They work even when the error has been wrapped with %w.
4.4 Testing HTTP handlers with httptest
net/http/httptest is part of the standard library. It serves a purpose similar to ASP.NET Core's WebApplicationFactory, but is far more lightweight.
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func TestHealthHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Errorf("unexpected body: %s", rec.Body.String())
}
}
To test a client that calls an external API, start a test server with httptest.NewServer.
func TestClient_Fetch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
// Pass srv.URL to the client as its base URL
got, err := NewClient(srv.URL).Fetch()
if err != nil {
t.Fatal(err)
}
if got != "hello" {
t.Errorf("Fetch() = %q, want %q", got, "hello")
}
}
4.5 Integration tests with Testcontainers
For integration tests against a real database, you can use Testcontainers (testcontainers-go), just as in .NET.
package repository_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func TestUserRepository_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
ctx := context.Background()
ctr, err := postgres.Run(ctx, "postgres:16-alpine")
testcontainers.CleanupContainer(t, ctr)
require.NoError(t, err)
dsn, err := ctr.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
// Connect using dsn, run migrations, then test the repository
_ = dsn
}
Combining testing.Short() with go test -short lets you skip slow integration tests during development. Starting containers requires a container runtime such as Docker.
4.6 Benchmarks
BenchmarkDotNet requires attributes and a runner, but in Go you just write a function whose name starts with Benchmark.
package calc
import "testing"
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
From Go 1.24, you can also use b.Loop().
func BenchmarkAddLoop(b *testing.B) {
for b.Loop() {
Add(1, 2)
}
}
Specify the target with -bench when running. You can skip regular tests with -run '^$'.
# Run all benchmarks, including memory allocation stats
go test -run '^$' -bench . -benchmem ./...
The output shows the time per iteration (ns/op), bytes allocated (B/op), and the number of allocations (allocs/op). benchstat is useful for comparing results before and after a change.
4.7 Fuzzing
Since Go 1.18, fuzz testing is available as a standard feature. It lets you search for inputs that break your code by using automatically generated inputs.
package strutil
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
// Seed corpus
f.Add("hello")
f.Add("日本語")
f.Fuzz(func(t *testing.T, s string) {
if !utf8.ValidString(s) {
t.Skip()
}
// Verify the property that reversing twice restores the original
if got := Reverse(Reverse(s)); got != s {
t.Errorf("Reverse(Reverse(%q)) = %q", s, got)
}
})
}
# Run fuzzing for 30 seconds
go test -fuzz FuzzReverse -fuzztime 30s
Inputs that cause failures are saved under testdata/fuzz/. After that, they are re-run as regression tests by a normal go test.
5. Summary
- Go tests work with just
_test.go,TestXxx(t *testing.T), andgo test, with no attributes or test runner configuration. - The equivalent of
[Theory]is the table-driven test. Making each case a subtask witht.Runmakes individual execution and parallelization easier. - By default, assertions are written with
ifandt.Errorf. Use testify when you want better readability, and go-cmp for comparing struct diffs. - Prefer small consumer-defined interfaces and hand-written fakes over mocking frameworks. Verify results and state, not call counts.
- Use
httptestfor HTTP, and Testcontainers for integration tests against a real DB. Heavy tests can be toggled withtesting.Short(). - Make
go test -race -cover ./...the baseline in CI, and verify performance withtesting.Band input robustness with fuzzing.