Testing in Go for backend APIs: unit tests, integration and mocks without losing your mind

Testing in Go: unit tests, table-driven tests, httptest, mocks with interfaces and integration. A pragmatic approach for real backend.

Cover for Testing in Go for backend APIs: unit tests, integration and mocks without losing your mind

The story of testing in Go is refreshingly simple: no JUnit, no pytest, no external test runner. Just go test and the testing package from the standard library. You don’t need to install anything, configure anything, or argue with your team about which testing framework to use.

Coming from Java or Python, this seems too austere. And it is, deliberately. Go bets on testing tools being so simple that you have no excuse not to write tests. And it works: most Go projects you find on GitHub have tests from the first commit. Not because their authors are more disciplined, but because the barrier to entry is almost non-existent.

Now, there’s a catch. The fact that it’s easy to start testing doesn’t mean it’s easy to test well. I’ve seen (and written) tests in Go that were more complex than the code they were testing. Tests that broke with every refactor. Tests that always passed, even when the code had bugs. Tests that took 5 minutes to run because they brought up half the infrastructure.

This article is about pragmatic testing for backend APIs. I’m not going to cover 100% of the testing package API. I’m going to cover what you really need to test a REST API with Go in production without your tests turning into a second application.


Anatomy of a test in Go

A test in Go is a function that meets three rules:

  1. It’s in a file ending in _test.go.
  2. Its name starts with Test.
  3. It receives a single parameter *testing.T.

That’s it. No annotations, no decorators, no base classes.

// math.go
package calc

func Add(a, b int) int {
    return a + b
}
// math_test.go
package calc

import "testing"

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}

You run it with the go command:

go test ./...

And you get:

ok      calc    0.001s

No additional output if everything passes. If something fails:

--- FAIL: TestAdd (0.00s)
    math_test.go:8: Add(2, 3) = 6; want 5
FAIL

There are three things worth noting:

  • No asserts. Go has no assertEquals or assertThat in the standard library. You use if and call t.Errorf or t.Fatalf. This seems primitive, but has one advantage: error messages are yours and say exactly what you want them to say.
  • t.Errorf doesn’t stop the test. If you want the test to stop immediately on failure, use t.Fatalf. Errorf records the failure and keeps running. This is useful when you want to see all failures at once.
  • The _test.go file is only compiled when running tests. It doesn’t go into your production binary. You can put helpers, mocks and test data there without worrying.

The test package: same or different

You can declare the test package in two ways:

// Same package: access to unexported functions
package calc
// External package: only access to the public API
package calc_test

Using package calc_test forces you to test only the exported stuff, which is exactly what you should do most of the time. If you need to test internal functions, use the same package, but think twice: if you can’t test something through its public API, maybe the API is poorly designed.


Table-driven tests: the pattern that defines Go

If there’s one testing pattern that defines Go, it’s this one. Table-driven tests eliminate duplication by grouping test cases in a slice and running them in a loop.

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed signs", -1, 5, 4},
    }

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

This is not an optional convention. It IS the standard in Go. You’ll find it in the standard library, in community projects, in every code review. If you write tests in Go and don’t use table-driven tests, someone will point it out to you.

The advantages are clear:

  • Adding a new case is one line. Not a new function, not a new method. One line in the slice.
  • Each case has a name. When it fails, you know exactly which one.
  • The verification code is written once. If you change how you verify, you change it in one place.
  • It’s easy to cover edge cases. Psychologically, adding one more case to a table is much easier than writing another test function.

A common mistake is abusing them. If each case needs different setup, different verification logic and different teardown, a table isn’t the right pattern. Use separate test functions.


Subtests with t.Run

You already saw t.Run in the previous example. It deserves its own section because it’s more powerful than it seems.

func TestUserService(t *testing.T) {
    t.Run("Create", func(t *testing.T) {
        t.Run("with valid data", func(t *testing.T) {
            // ...
        })
        t.Run("with duplicate email", func(t *testing.T) {
            // ...
        })
    })

    t.Run("Delete", func(t *testing.T) {
        t.Run("existing user", func(t *testing.T) {
            // ...
        })
        t.Run("nonexistent user", func(t *testing.T) {
            // ...
        })
    })
}

The output is hierarchical:

--- FAIL: TestUserService/Create/with_duplicate_email (0.00s)

You can run a specific subtest:

go test -run TestUserService/Create/with_duplicate_email

This is extremely useful when you have a long test that fails and you want to iterate quickly on the specific case without running all 200 cases in the table.

Another important use: parallel subtests. If your subtests are independent of each other, you can run them in parallel:

for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        result := SlowOperation(tt.input)
        if result != tt.expected {
            t.Errorf("got %v; want %v", result, tt.expected)
        }
    })
}

t.Parallel() marks the subtest for concurrent execution. Go handles the scheduling. But be careful: if your tests share state (a database, a file, a global variable), running them in parallel will produce flaky tests that will keep you up at night.


httptest: testing HTTP handlers without a server

This is where testing in Go really starts to stand out. The net/http/httptest package lets you test HTTP handlers without spinning up a real server. No ports, no network connections, no race conditions from occupied ports.

Suppose a handler that returns a list of tasks:

type TaskHandler struct {
    service TaskService
}

func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.service.GetAll(r.Context())
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(tasks)
}

The test:

func TestListTasks(t *testing.T) {
    // Set up the service mock
    svc := &mockTaskService{
        tasks: []Task{
            {ID: 1, Title: "Buy milk"},
            {ID: 2, Title: "Deploy v2"},
        },
    }
    handler := &TaskHandler{service: svc}

    // Create request and recorder
    req := httptest.NewRequest("GET", "/tasks", nil)
    rec := httptest.NewRecorder()

    // Execute the handler directly
    handler.ListTasks(rec, req)

    // Verify
    res := rec.Result()
    if res.StatusCode != http.StatusOK {
        t.Fatalf("status = %d; want 200", res.StatusCode)
    }

    contentType := res.Header.Get("Content-Type")
    if contentType != "application/json" {
        t.Errorf("Content-Type = %s; want application/json", contentType)
    }

    var tasks []Task
    if err := json.NewDecoder(res.Body).Decode(&tasks); err != nil {
        t.Fatalf("failed to decode body: %v", err)
    }
    if len(tasks) != 2 {
        t.Errorf("got %d tasks; want 2", len(tasks))
    }
}

The two key pieces are:

  • httptest.NewRequest: creates a *http.Request without opening any connection. You can configure headers, body, query params, everything.
  • httptest.NewRecorder: implements http.ResponseWriter and captures everything the handler writes: status code, headers, body.

This is much faster than spinning up a real server. And more deterministic: no network timeouts, no occupied ports, no TCP delays.

When you do need a test server

Sometimes you need to test middleware, routing or the full HTTP stack. For that, httptest.NewServer:

func TestFullStack(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte(`{"status":"ok"}`))
    })

    server := httptest.NewServer(mux)
    defer server.Close()

    resp, err := http.Get(server.URL + "/health")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("status = %d; want 200", resp.StatusCode)
    }
}

httptest.NewServer starts a real HTTP server on a random port. It’s slower than the recorder, but gives you a real URL to make requests against with http.Client. Use it when you need to test real network behaviour, not for unit tests of handlers.


Mocking with interfaces: no frameworks needed

In Java you need Mockito. In Python you have unittest.mock. In Go, you need… interfaces.

The pattern is simple: your code depends on an interface, not a concrete implementation. In the test, you pass a fake implementation you control.

// service.go
type TaskRepository interface {
    GetAll(ctx context.Context) ([]Task, error)
    GetByID(ctx context.Context, id int) (Task, error)
    Create(ctx context.Context, t Task) (Task, error)
    Delete(ctx context.Context, id int) error
}

type TaskService struct {
    repo TaskRepository
}

func NewTaskService(repo TaskRepository) *TaskService {
    return &TaskService{repo: repo}
}

func (s *TaskService) GetAll(ctx context.Context) ([]Task, error) {
    return s.repo.GetAll(ctx)
}
// service_test.go
type mockRepo struct {
    tasks []Task
    err   error
}

func (m *mockRepo) GetAll(ctx context.Context) ([]Task, error) {
    return m.tasks, m.err
}

func (m *mockRepo) GetByID(ctx context.Context, id int) (Task, error) {
    for _, t := range m.tasks {
        if t.ID == id {
            return t, nil
        }
    }
    return Task{}, fmt.Errorf("not found")
}

func (m *mockRepo) Create(ctx context.Context, t Task) (Task, error) {
    t.ID = len(m.tasks) + 1
    m.tasks = append(m.tasks, t)
    return t, m.err
}

func (m *mockRepo) Delete(ctx context.Context, id int) error {
    return m.err
}

And the test:

func TestGetAll_ReturnsTasksFromRepo(t *testing.T) {
    repo := &mockRepo{
        tasks: []Task{
            {ID: 1, Title: "Task 1"},
            {ID: 2, Title: "Task 2"},
        },
    }
    svc := NewTaskService(repo)

    tasks, err := svc.GetAll(context.Background())
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if len(tasks) != 2 {
        t.Errorf("got %d tasks; want 2", len(tasks))
    }
}

func TestGetAll_PropagatesRepoError(t *testing.T) {
    repo := &mockRepo{
        err: fmt.Errorf("database connection lost"),
    }
    svc := NewTaskService(repo)

    _, err := svc.GetAll(context.Background())
    if err == nil {
        t.Fatal("expected error, got nil")
    }
}

No magic. No reflection. No generated code. You define an interface, create a struct that implements it with the values you need, and pass it. It’s more verbose than Mockito, but infinitely easier to understand and debug.

Small interfaces, easy mocks

This pattern works great when interfaces are small. A 2-3 method interface is trivial to mock by hand. A 15-method interface is a nightmare.

If you find yourself writing massive mocks where you only implement 1 of the 12 methods and the rest are panic("not implemented"), the interface is too large. Split it. Go favours small interfaces: io.Reader, io.Writer, fmt.Stringer have a single method.

// Instead of this
type UserService interface {
    Create(ctx context.Context, u User) error
    GetByID(ctx context.Context, id int) (User, error)
    GetByEmail(ctx context.Context, email string) (User, error)
    Update(ctx context.Context, u User) error
    Delete(ctx context.Context, id int) error
    List(ctx context.Context, filter Filter) ([]User, error)
    Count(ctx context.Context) (int, error)
    Activate(ctx context.Context, id int) error
    Deactivate(ctx context.Context, id int) error
}

// If your handler only needs to list and get by ID:
type UserReader interface {
    GetByID(ctx context.Context, id int) (User, error)
    List(ctx context.Context, filter Filter) ([]User, error)
}

Your handler depends on UserReader, not UserService. Your mock implements 2 methods instead of 9. Everything is simpler.


When to use testify

Go’s standard library has no asserts. That’s a deliberate design decision, but after writing your hundredth if got != want { t.Errorf(...) }, you start to understand why testify exists.

import (
    "testing"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestWithTestify(t *testing.T) {
    result, err := DoSomething()

    // require stops the test if it fails (like t.Fatal)
    require.NoError(t, err)
    require.NotNil(t, result)

    // assert records the failure but continues (like t.Error)
    assert.Equal(t, "expected", result.Name)
    assert.Len(t, result.Items, 3)
    assert.Contains(t, result.Tags, "important")
}

Testify reduces boilerplate and error messages are more descriptive automatically. But it has a cost: it’s an external dependency. In a project with 10 tests it’s not worth it. In an API with 500 tests, it probably is.

My criteria:

  • Use the standard library for small projects, public libraries, and when you want zero external dependencies.
  • Use assert and require from testify when the if/t.Errorf boilerplate slows you down.
  • Avoid suite from testify. It tries to replicate xUnit class-based setup/teardown and goes against Go’s philosophy. A TestMain or a helper function does the same thing without the complexity.

An intermediate pattern that works well: writing your own helpers without depending on testify.

func assertEqual[T comparable](t *testing.T, got, want T) {
    t.Helper()
    if got != want {
        t.Errorf("got %v; want %v", got, want)
    }
}

t.Helper() is key: it makes the error point to the line that called assertEqual when the test fails, not the line inside the helper function. Without t.Helper(), all your errors would point to the same line in the helper, which is useless for debugging.


Integration tests: build tags and testcontainers

Unit tests are fine for business logic, but if your API talks to PostgreSQL, Kafka, Redis or any external system, you need integration tests. And you need to be able to separate them from unit tests.

Build tags to separate tests

The standard way to separate tests in Go is with build tags:

//go:build integration

package repository

import (
    "context"
    "testing"
)

func TestPostgresRepo_Create(t *testing.T) {
    db := setupTestDB(t)
    repo := NewPostgresRepo(db)

    task, err := repo.Create(context.Background(), Task{Title: "Test"})
    if err != nil {
        t.Fatalf("Create failed: %v", err)
    }
    if task.ID == 0 {
        t.Error("expected non-zero ID")
    }
}

Tests with //go:build integration don’t run with go test ./.... You have to request them explicitly:

# Unit only (default)
go test ./...

# Integration only
go test -tags=integration ./...

# Everything
go test -tags=integration ./...

This is fundamental for CI. Your unit tests run in seconds and execute on every push. Integration tests need infrastructure (database, message queue) and run in a separate pipeline or with a previous setup step.

Testcontainers: disposable infrastructure

Testcontainers starts Docker containers from your tests. Real database, real data, no mocks.

//go:build integration

package repository

import (
    "context"
    "testing"

    "github.com/testcontainers/testcontainers-go"
    "github.com/testcontainers/testcontainers-go/modules/postgres"
    "github.com/testcontainers/testcontainers-go/wait"
)

func setupPostgres(t *testing.T) string {
    t.Helper()
    ctx := context.Background()

    container, err := postgres.Run(ctx,
        "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForLog("database system is ready to accept connections").
                WithOccurrence(2),
        ),
    )
    if err != nil {
        t.Fatalf("failed to start postgres: %v", err)
    }

    t.Cleanup(func() {
        if err := container.Terminate(ctx); err != nil {
            t.Logf("failed to terminate container: %v", err)
        }
    })

    connStr, err := container.ConnectionString(ctx, "sslmode=disable")
    if err != nil {
        t.Fatalf("failed to get connection string: %v", err)
    }

    return connStr
}

func TestPostgresRepo_Integration(t *testing.T) {
    connStr := setupPostgres(t)
    db := connectDB(t, connStr)
    runMigrations(t, db)

    repo := NewPostgresRepo(db)

    t.Run("Create and GetByID", func(t *testing.T) {
        created, err := repo.Create(context.Background(), Task{
            Title: "Integration test task",
        })
        if err != nil {
            t.Fatalf("Create: %v", err)
        }

        got, err := repo.GetByID(context.Background(), created.ID)
        if err != nil {
            t.Fatalf("GetByID: %v", err)
        }

        if got.Title != "Integration test task" {
            t.Errorf("title = %q; want %q", got.Title, "Integration test task")
        }
    })
}

t.Cleanup is your friend here. It registers a function that runs when the test (or subtest) finishes, regardless of whether it passes or fails. It’s the equivalent of defer but for the test lifecycle. Use it to clean up containers, close connections, delete temporary files.

The pattern I recommend for a backend API:

  1. Repositories: Integration tests with testcontainers against a real database.
  2. Services: Unit tests with repository mocks.
  3. Handlers: Unit tests with httptest and service mocks.
  4. End-to-end: One or two tests that bring up the full stack (optional but recommended).

Test coverage: go test -cover

Go has built-in test coverage. You don’t need to install anything:

# Show coverage percentage
go test -cover ./...

# Generate coverage profile
go test -coverprofile=coverage.out ./...

# Visualise in the browser
go tool cover -html=coverage.out

The HTML visualisation is very useful: it shows you line by line what’s covered (green) and what isn’t (red). It’s the fastest way to identify untested branches.

# Coverage by function
go tool cover -func=coverage.out
calc/math.go:3:     Add         100.0%
calc/math.go:7:     Divide      75.0%
total:              (statements) 87.5%

Now, a controversial point: the coverage percentage is not a quality metric. I’ve seen projects with 95% coverage where the tests weren’t verifying anything (they just ran the code without checking results). And projects with 60% coverage where every test was solid.

My pragmatic approach:

  • Aim to cover critical paths. Happy paths and errors that a real user can trigger.
  • Don’t chase 100%. The effort to go from 85% to 100% rarely pays off.
  • Use coverage as an exploratory tool, not as a CI gate. Seeing what’s red helps you decide what to test, but a number shouldn’t block a merge.

Benchmarks with testing.B

Go has built-in benchmarks in the same testing package. If you need to measure performance, you don’t need external tools.

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

Execution:

go test -bench=. -benchmem ./...
BenchmarkAdd-8    1000000000    0.2900 ns/op    0 B/op    0 allocs/op

b.N is the number of iterations Go decides to run to obtain a stable measurement. You don’t set it yourself. -benchmem adds allocation information, which is usually more useful than raw time for optimising Go code.

A more realistic benchmark for an API:

func BenchmarkJSONEncoding(b *testing.B) {
    tasks := make([]Task, 100)
    for i := range tasks {
        tasks[i] = Task{ID: i, Title: fmt.Sprintf("Task %d", i)}
    }

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        json.NewEncoder(&buf).Encode(tasks)
    }
}

b.ResetTimer() discards the setup time. Always use it when you have initialisation before the benchmark loop.

Benchmarks are useful for comparing implementations, detecting performance regressions and understanding allocation costs. But don’t write them for everything. If your endpoint takes 200ms because it’s waiting for the database, a benchmark showing that your JSON serialisation takes 2 microseconds doesn’t provide relevant information.


Common mistakes that ruin your tests

After years of writing and reviewing tests in Go, these are the patterns I’ve seen cause the most pain:

Testing implementation details

// BAD: test coupled to internal implementation
func TestService_CallsRepoExactlyOnce(t *testing.T) {
    repo := &countingMockRepo{}
    svc := NewService(repo)
    svc.GetUser(context.Background(), 1)

    if repo.callCount != 1 {
        t.Errorf("repo called %d times; want 1", repo.callCount)
    }
}

This test breaks if you add caching, if you change from a Get to a BatchGet, or if you simply refactor. Test the result, not the path.

// GOOD: tests observable behaviour
func TestService_ReturnsUser(t *testing.T) {
    repo := &mockRepo{user: User{ID: 1, Name: "Alice"}}
    svc := NewService(repo)

    user, err := svc.GetUser(context.Background(), 1)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if user.Name != "Alice" {
        t.Errorf("name = %q; want %q", user.Name, "Alice")
    }
}

Over-mocking

When you mock three layers of abstraction to test a function that adds two numbers, something has gone very wrong. If your test needs to configure 15 mocks to work, it’s not a unit test: it’s a demonstration that your code has too many dependencies.

Simple rule: if the mock is more complex than the real code, use the real code.

Tests that ignore errors

// BAD: if Create returns an error, the test passes anyway
func TestCreateTask(t *testing.T) {
    task, _ := svc.Create(ctx, Task{Title: "Test"})
    if task.Title != "Test" {
        t.Error("wrong title")
    }
}

If Create returns an error and an empty Task, task.Title is "", the test fails, but for the wrong reason. Always check errors in tests.

Tests that depend on order

// BAD: if TestA doesn't run first, TestB fails
func TestA_CreateUser(t *testing.T) {
    svc.Create(ctx, User{Email: "test@test.com"})
}

func TestB_GetUser(t *testing.T) {
    user, _ := svc.GetByEmail(ctx, "test@test.com")
    // fails if TestA didn't run first
}

Each test should work in isolation. If you need data, create it inside the test or in a shared setup with TestMain.


A pragmatic testing strategy for backend APIs

After all of the above, this is the strategy I use and recommend for backend APIs in Go. It’s not the only possible one, but it has worked consistently in real projects.

The pyramid that works

  1. Base: unit tests for services and business logic. Fast, no external dependencies, with repository mocks. This is where the bulk of your tests live. Table-driven tests to cover variants. You verify business rules, validations, data transformations.

  2. Middle: unit tests for HTTP handlers. With httptest.NewRecorder and service mocks. You verify status codes, headers, response format, HTTP error handling. You don’t verify business logic here.

  3. Middle-upper: integration tests for repositories. With testcontainers or a test database. You verify that your SQL queries work, that types map correctly, that constraints are respected.

  4. Top: one or two E2E tests that spin up the full API and make real requests. Smoke tests that verify everything fits together.

In practice

project/
├── handler/
│   ├── task_handler.go
│   └── task_handler_test.go      # httptest, mock service
├── service/
│   ├── task_service.go
│   └── task_service_test.go      # table-driven, mock repo
├── repository/
│   ├── task_repo.go
│   └── task_repo_test.go         # //go:build integration
└── e2e/
    └── api_test.go               # //go:build e2e

Your Makefile or CI pipeline:

test:
	go test ./...

test-integration:
	go test -tags=integration ./...

test-all:
	go test -tags="integration e2e" ./...

coverage:
	go test -coverprofile=coverage.out ./...
	go tool cover -html=coverage.out -o coverage.html

What to test and what not to

Test:

  • Business rules and validations.
  • Error handling (happy path AND sad paths).
  • Serialisation/deserialisation if it’s critical.
  • Complex SQL queries (integration).
  • Middleware that affects security (authentication, authorisation).

Don’t test:

  • Trivial getters and setters.
  • Generated code.
  • One-line wrappers around the standard library.
  • That Go works correctly (don’t test that json.Marshal serialises JSON).

The key question

Before writing a test, ask yourself: “If this test fails, will I know what broke and how to fix it?”

If the answer is yes, it’s a good test. If the answer is “I’ll know that something changed, but not what or why”, the test is coupled to implementation details and will give you more work than it saves you.

Go gives you simple tools. testing.T, httptest, interfaces, build tags. You don’t need more to test a backend API solidly. The difficulty isn’t in the tools, it’s in deciding what deserves a test and what doesn’t. And that, like almost everything in software engineering, is a matter of judgment, not framework.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved