Skip to main content

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, TestXxx functions, and when to use t.Error versus t.Fatal
  • Table-driven tests and subtests
    • The Go equivalent of [Theory] / [InlineData], plus t.Run and t.Parallel
  • 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.B and fuzz testing with testing.F

2. .NET Mapping​

.NET (C#)Go
xUnit / NUnittesting package (go test)
[Theory] / [InlineData]Table-driven tests + t.Run
NSubstitute / MoqHand-written fakes of interfaces / gomock / mockery
Shouldly / FluentAssertionstestify / go-cmp
BenchmarkDotNettesting.B (go test -bench)

Other correspondences are as follows.

.NET (C#)Go
Constructor / IDisposable for cleanupt.Cleanup
[Fact(Skip = "...")]t.Skip
Test project (*.Tests.csproj)_test.go files in the same directory
dotnet test --filtergo test -run
Coverletgo 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 Test and has the signature func 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 test
  • t.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.

  1. In the package that uses the dependency, define a small interface containing only the methods you need
  2. In the test, hand-write a simple fake struct that satisfies that interface
  3. 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), and go test, with no attributes or test runner configuration.
  • The equivalent of [Theory] is the table-driven test. Making each case a subtask with t.Run makes individual execution and parallelization easier.
  • By default, assertions are written with if and t.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 httptest for HTTP, and Testcontainers for integration tests against a real DB. Heavy tests can be toggled with testing.Short().
  • Make go test -race -cover ./... the baseline in CI, and verify performance with testing.B and input robustness with fuzzing.

References​