Testing a Go REST API with Fiber and Testcontainers on Ubuntu

Series Production Go Web Service with Fiber Part 5/6 All parts

Every change to bookmarkd across the last four parts of this series has been verified by hand with curl. That works while the API is small and you remember every endpoint’s behavior, but it stops scaling the moment a change in the store or a middleware silently breaks something three routes away. This part adds real automated tests: fast unit tests against a fake store, route-level tests through Fiber’s actual routing and middleware stack, and integration tests that run queries against a real, disposable PostgreSQL instance.

This continues from Structured Logging, Metrics, and Graceful Shutdown in Go on Ubuntu. Nothing here changes application behavior; it locks in the behavior built across Parts 1 through 4 so the next part, containerizing and deploying the service, can be done with confidence that a passing test suite means the API still works.

This tutorial is for developers who have bookmarkd running with authentication, logging, and metrics from the earlier parts and have not yet written a single test for it. By the end, go test ./... will exercise the handlers, the routing and middleware, and the real PostgreSQL queries, and a GitHub Actions workflow will run all of it on every push.

Conceptual Overview

Go’s testing philosophy leans on the standard library’s testing package and table-driven tests: instead of writing a separate test function for every case, you define a slice of structs describing inputs and expected outputs, then loop over it with t.Run so each case shows up as its own named subtest in the output.

Fiber v3 ships a built-in test helper, app.Test(req), which runs a real *http.Request through the actual Fiber app, including every registered middleware and route, and returns the resulting *http.Response, all without opening a real network listener. This sits between a pure unit test (which calls a handler function directly) and a full end-to-end test (which requires a running process and a real port): it exercises real routing and middleware, but stays fast because nothing touches the network.

testcontainers-go solves a different problem: testing code that talks to PostgreSQL without a shared, stateful test database that different test runs can corrupt for each other. It starts a real PostgreSQL Docker container before a test runs, gives you its randomly assigned connection details, and tears the container down afterward, so every test run gets a clean, isolated database.

Prerequisites

Before starting, make sure you have:

  • The bookmarkd project from Part 4, on api-01 at 10.20.0.51.
  • Go 1.25 installed.
  • Docker installed and the current user added to the docker group, since testcontainers-go needs to start and stop containers. See Getting Started with Docker and Docker Compose on Ubuntu if Docker is not installed yet.

Step 1: Unit Test the Handlers with a Fake Store

Because every handler in bookmarkd depends on the store.Store interface rather than a concrete type, testing them does not require a database at all. Create internal/store/fake.go inside the module (not a _test.go file, since it is reused across multiple test files):

package store

import "context"

type FakeStore struct {
	Bookmarks map[string]Bookmark
	ListErr   error
}

func NewFakeStore() *FakeStore {
	return &FakeStore{Bookmarks: make(map[string]Bookmark)}
}

func (f *FakeStore) List(ctx context.Context, userID string) ([]Bookmark, error) {
	if f.ListErr != nil {
		return nil, f.ListErr
	}
	out := make([]Bookmark, 0)
	for _, b := range f.Bookmarks {
		out = append(out, b)
	}
	return out, nil
}

func (f *FakeStore) Get(ctx context.Context, userID, id string) (Bookmark, error) {
	b, ok := f.Bookmarks[id]
	if !ok {
		return Bookmark{}, ErrNotFound
	}
	return b, nil
}

func (f *FakeStore) Create(ctx context.Context, userID string, b Bookmark) (Bookmark, error) {
	b.ID = "fake-id-1"
	f.Bookmarks[b.ID] = b
	return b, nil
}

func (f *FakeStore) Delete(ctx context.Context, userID, id string) error {
	if _, ok := f.Bookmarks[id]; !ok {
		return ErrNotFound
	}
	delete(f.Bookmarks, id)
	return nil
}

The ListErr field is a common pattern for fakes: a test can set it before calling List to force an error path without needing a real failure condition, which is exactly what the table-driven test below does.

Create internal/handler/bookmark_test.go:

package handler

import (
	"context"
	"testing"

	"github.com/facsiaginsa/bookmarkd/internal/store"
)

func TestBookmarkHandler_Get(t *testing.T) {
	tests := []struct {
		name       string
		seed       map[string]store.Bookmark
		requestID  string
		wantErr    bool
		wantStatus int
	}{
		{
			name:       "existing bookmark",
			seed:       map[string]store.Bookmark{"abc": {ID: "abc", Title: "Go Docs"}},
			requestID:  "abc",
			wantErr:    false,
			wantStatus: 200,
		},
		{
			name:       "missing bookmark",
			seed:       map[string]store.Bookmark{},
			requestID:  "missing",
			wantErr:    true,
			wantStatus: 404,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fake := store.NewFakeStore()
			fake.Bookmarks = tt.seed

			b, err := fake.Get(context.Background(), "user-1", tt.requestID)

			if tt.wantErr && err == nil {
				t.Fatalf("expected an error, got none")
			}
			if !tt.wantErr && err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if !tt.wantErr && b.ID != tt.requestID {
				t.Fatalf("got bookmark ID %q, want %q", b.ID, tt.requestID)
			}
		})
	}
}

Run it:

cd ~/bookmarkd
go test ./internal/handler/...
ok      github.com/facsiaginsa/bookmarkd/internal/handler   0.004s

Step 2: Route-Level Tests with app.Test

The test above only exercises the fake store directly, not Fiber’s routing, JSON encoding, or the Authenticate middleware. Create internal/handler/routes_test.go to test the whole stack:

package handler

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/gofiber/fiber/v3"

	"github.com/facsiaginsa/bookmarkd/internal/store"
)

func newTestApp(fake *store.FakeStore) *fiber.App {
	app := fiber.New()
	h := &BookmarkHandler{Store: fake}

	v1 := app.Group("/v1")
	bookmarks := v1.Group("/bookmarks")
	bookmarks.Get("/", h.List)
	bookmarks.Get("/:id", h.Get)
	bookmarks.Post("/", h.Create)

	return app
}

func TestRoutes_CreateAndList(t *testing.T) {
	fake := store.NewFakeStore()
	app := newTestApp(fake)

	createReq := httptest.NewRequest(http.MethodPost, "/v1/bookmarks/", nil)
	createReq.Header.Set("Content-Type", "application/json")
	createReq.Body = http.NoBody

	body := `{"title":"Fiber Docs","url":"https://docs.gofiber.io"}`
	createReq = httptest.NewRequest(http.MethodPost, "/v1/bookmarks/", stringsReader(body))
	createReq.Header.Set("Content-Type", "application/json")

	resp, err := app.Test(createReq)
	if err != nil {
		t.Fatalf("request failed: %v", err)
	}
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("got status %d, want 201", resp.StatusCode)
	}

	listReq := httptest.NewRequest(http.MethodGet, "/v1/bookmarks/", nil)
	listResp, err := app.Test(listReq)
	if err != nil {
		t.Fatalf("request failed: %v", err)
	}

	var bookmarks []store.Bookmark
	if err := json.NewDecoder(listResp.Body).Decode(&bookmarks); err != nil {
		t.Fatalf("failed to decode response: %v", err)
	}
	if len(bookmarks) != 1 {
		t.Fatalf("got %d bookmarks, want 1", len(bookmarks))
	}
}

Add a tiny helper for turning a string into an io.Reader, since httptest.NewRequest needs one, in the same file:

import "strings"

func stringsReader(s string) *strings.Reader {
	return strings.NewReader(s)
}

app.Test(req) runs createReq through the exact same router, group prefixes, and (in the full application) middleware chain that a real deployment uses, which is what catches a route registered under the wrong prefix or a middleware ordering bug that a pure handler test never would.

Step 3: Integration Test Against Real PostgreSQL with testcontainers-go

Add the dependencies:

go get github.com/testcontainers/testcontainers-go
go get github.com/testcontainers/testcontainers-go/modules/postgres

Create internal/store/postgres_test.go:

package store_test

import (
	"context"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"

	"github.com/facsiaginsa/bookmarkd/internal/store"
)

func TestPostgresStore_CreateAndGet(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()

	container, err := tcpostgres.Run(ctx, "postgres:16-alpine",
		tcpostgres.WithDatabase("bookmarkd_test"),
		tcpostgres.WithUsername("test"),
		tcpostgres.WithPassword("test"),
	)
	if err != nil {
		t.Fatalf("failed to start postgres container: %v", err)
	}
	defer container.Terminate(ctx)

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

	pool, err := pgxpool.New(ctx, connStr)
	if err != nil {
		t.Fatalf("failed to connect: %v", err)
	}
	defer pool.Close()

	if err := runMigrations(ctx, connStr); err != nil {
		t.Fatalf("failed to run migrations: %v", err)
	}

	s := store.NewPostgresStore(pool)

	created, err := s.Create(ctx, "user-1", store.Bookmark{Title: "Go Docs", URL: "https://go.dev"})
	if err != nil {
		t.Fatalf("create failed: %v", err)
	}

	got, err := s.Get(ctx, "user-1", created.ID)
	if err != nil {
		t.Fatalf("get failed: %v", err)
	}
	if got.Title != "Go Docs" {
		t.Fatalf("got title %q, want %q", got.Title, "Go Docs")
	}
}

runMigrations is a small helper, omitted here for brevity, that shells out to goose or calls it as a library against the container’s connection string, applying every file under db/migrations before the test runs; without it the container starts with an empty database and every query fails against a table that does not exist.

The first run of this test downloads the postgres:16-alpine image, so it is noticeably slower than the unit tests; subsequent runs reuse the cached image and only pay the cost of starting a fresh container.

Run everything together, with race detection and coverage:

go test -race -cover ./...
ok      github.com/facsiaginsa/bookmarkd/internal/handler   0.021s  coverage: 71.4% of statements
ok      github.com/facsiaginsa/bookmarkd/internal/store     4.812s  coverage: 58.9% of statements

-race catches data races, exactly the kind of bug the sync.RWMutex in the in-memory store from Part 1 was written to prevent; running with it regularly is cheap insurance against a mistake introduced later.

Step 4: Run Tests in GitHub Actions

If you have already set up Set Up a Self-Hosted GitHub Actions Runner on Ubuntu, this workflow will run on your own infrastructure with Docker already available. Create .github/workflows/test.yml:

name: test

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: "1.25"

      - name: Run tests
        run: go test -race -cover ./...

GitHub’s hosted ubuntu-latest runners already have Docker available, which testcontainers-go needs to start the PostgreSQL container, so no extra setup step is required there. On a self-hosted runner, confirm the runner’s user is in the docker group the same way it needs to be on your development machine.

Common Mistakes and Troubleshooting

testcontainers-go fails with a permission error talking to the Docker socket. The user running the tests is not in the docker group. Add it with sudo usermod -aG docker $USER and start a new shell session for the change to take effect.

Integration tests hang for a long time before failing. This is usually Docker being unable to pull the postgres:16-alpine image, often because of no network access in a sandboxed CI environment. Pre-pull the image with docker pull postgres:16-alpine before running tests, or configure a local image cache.

app.Test returns a 404 for a route that clearly exists in main.go. Confirm the test builds its own *fiber.App with the same route groups and prefixes as production, as newTestApp does above; a test app that forgets to register the /v1 prefix will silently 404 on every request.

Unit tests pass locally but fail in CI with a different result. Table-driven tests that share mutable state between cases (reusing the same FakeStore instance across two t.Run blocks, for example) can produce order-dependent results. Create a fresh fake store inside each subtest, as done above.

Best Practices

  • Keep unit tests fast and dependency-free. Anything that can be tested against FakeStore should be, since these tests run in milliseconds and do not need Docker at all.
  • Reserve testcontainers for behavior only the real database can verify: actual SQL correctness, constraint violations, and query performance characteristics that a fake can never faithfully reproduce.
  • Run -race in CI, not just locally. Data races are often timing-dependent and can pass thousands of times locally before showing up under different load in production.
  • Test the error paths, not just the happy path. The ListErr field on FakeStore exists specifically so a test can force a store failure and confirm the handler returns a proper 500 instead of panicking.
  • Treat a passing test suite as a prerequisite for deploying, not a suggestion. The GitHub Actions workflow above blocks nothing by itself; pair it with branch protection rules that require the test job to pass before a pull request can merge.

Conclusion

bookmarkd now has three layers of automated tests: fast unit tests against a fake store, route-level tests exercising Fiber’s real routing through app.Test, and integration tests that run actual queries against a disposable PostgreSQL container. All of it runs automatically on every push through GitHub Actions, so a regression in any layer gets caught before it reaches production.

Part 6, the final part of this series, packages bookmarkd into a minimal Docker image with a multi-stage build, runs it alongside PostgreSQL with Docker Compose, and puts Nginx in front of it with TLS, turning everything built across this series into a service you can actually deploy.

All tutorials →

Latest Tutorials

Support this site