Integration Testing in Go: Testing Real Boundaries
This tutorial shows you how to write Go integration tests that exercise the boundaries unit tests can’t reach: a real HTTP stack, a real database, the filesystem. You will get a concrete strategy for keeping those slow tests out of your fast feedback loop, and a fully worked end-to-end example. If you can write a basic Go test, you are ready.
Works with Go 1.22+ (all standard-library examples tested on Go 1.24.7). The method-and-path routing landed in Go 1.22.
Most Go integration-testing guides show you one Docker container and stop. They never answer the two questions that actually bite teams: how do I keep integration tests from slowing go test ./... to a crawl, and where exactly is the line between a unit test and an integration test? This article answers both, then builds a real example. If any Go basics feel shaky, keep the complete Go tutorial open in another tab.
What integration tests verify that unit tests cannot
A unit test checks one piece of logic in isolation, with its dependencies replaced by fakes. It is fast, deterministic, and tells you a function is correct. What it cannot tell you is whether your code talks to the outside world correctly, because you replaced the outside world with a mock.
Integration tests cross a real boundary:
- The database. Your SQL actually parses, your migrations actually apply, your
NULLhandling actually works against Postgres, not against a hand-written fake that always returns what you expect. - The HTTP stack. A request travels through your real router, real middleware chain, real JSON encoding, and real status-code mapping, the same path a client takes in production.
- The filesystem, message queues, external processes. Anything where the failure mode lives in the seam between your code and someone else’s.
The distinction is not academic. A repository unit test that mocks the database can pass while the real query has a typo in a column name. The whole point of the integration test is to run the query the mock was pretending to be. Testing against a mock when the boundary is the thing under test is the single most common way integration tests get written wrong.
The core strategy: keep go test ./... fast by default
Integration tests are slower and flakier than unit tests. If they run on every save, developers stop running tests. So the rule is: the default go test ./... runs only fast unit tests. Integration tests run when you ask for them, and in a separate CI stage.
Go gives you two mechanisms for this. Use one consistently across the codebase.
Mechanism 1: testing.Short() and the -short flag
The testing package exposes a -short flag. Call testing.Short() inside a test and skip when it is set. Your fast CI stage runs go test -short ./...; the full stage runs go test ./....
package api
import "testing"
// TestFullSweep is a slow, wide test we skip in the fast feedback loop.
func TestFullSweep(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow sweep in -short mode")
}
// ... work that touches a real dependency ...
}
Run it both ways and watch the skip actually happen:
$ go test -short -v ./api/
=== RUN TestUserAPI_EndToEnd
--- PASS: TestUserAPI_EndToEnd (0.00s)
=== RUN TestFullSweep
--- SKIP: TestFullSweep (0.00s)
PASS
ok itg/api 0.008s
The trade-off: the slow test still compiles into every build, and a developer who forgets -short runs it by accident. The default is “run everything,” and you opt out. For a suite with heavy Docker dependencies, that default is backwards.
Mechanism 2: build tags (//go:build integration)
A build tag excludes the file from compilation entirely unless you pass -tags. This is stronger: the integration test does not exist unless you explicitly ask for it. Put the tag on the very first line, followed by a blank line:
//go:build integration
package api
import "testing"
// TestStoreIntegration only compiles when the integration tag is set.
func TestStoreIntegration(t *testing.T) {
// ... spin up a real dependency, run real queries ...
}
Now the default run never sees it, and -tags integration pulls it in:
$ go test -v ./api/
=== RUN TestUserAPI_EndToEnd
--- PASS: TestUserAPI_EndToEnd (0.00s)
=== RUN TestFullSweep
--- PASS: TestFullSweep (0.00s)
PASS
ok itg/api 0.012s
$ go test -tags integration -v ./api/
=== RUN TestUserAPI_EndToEnd
--- PASS: TestUserAPI_EndToEnd (0.00s)
=== RUN TestFullSweep
--- PASS: TestFullSweep (0.00s)
=== RUN TestStoreIntegration
--- PASS: TestStoreIntegration (0.00s)
PASS
ok itg/api 0.007s
Notice TestStoreIntegration simply is not there in the first run. It never compiled.
Which to pick. Use build tags for tests that need heavy infrastructure (a database, a broker, Docker), because you want them structurally absent from the normal build, and you don’t want their imports (a Postgres driver, testcontainers) dragged into every go build. Use -short for tests that are merely slow but need no special setup. Many codebases use both: -short for the “is it slow” axis, //go:build integration for the “does it need Docker” axis. Pick a convention and document it, because a mix of ad-hoc if os.Getenv("INTEGRATION") == "" checks scattered around is how the boundary gets muddy.
An end-to-end HTTP test with httptest.Server
The most valuable integration test in a Go web service needs no Docker at all. net/http/httptest.Server starts a real HTTP server on a random local port, so you can drive your entire request path: routing, middleware, JSON decoding, handler logic, and error mapping, over a real socket. This is the layer that catches “the middleware swallowed the status code” and “the router never matched that path,” bugs no handler-level unit test sees.
Here is the service under test. It is a small user API built on the standard library, with a storage interface, a middleware chain, and centralized error mapping. This is the same shape as the REST API pillar; see Go middleware patterns for the chaining details.
package api
import (
"encoding/json"
"errors"
"net/http"
"sync"
)
var ErrUserNotFound = errors.New("user not found")
type User struct {
ID string `json:"id"`
Email string `json:"email"`
}
type UserStore interface {
Get(id string) (User, error)
Create(u User) error
}
type Server struct{ store UserStore }
func NewServer(store UserStore) *Server { return &Server{store: store} }
// Routes builds the router and wraps it in middleware, exactly as production does.
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", s.getUser)
mux.HandleFunc("POST /users", s.createUser)
return requestID(recoverPanic(mux))
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
u, err := s.store.Get(r.PathValue("id"))
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, u)
}
// writeError maps domain errors to HTTP status codes in one place.
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
The requestID middleware stamps X-Request-ID on every response, which gives the test a way to prove the middleware chain actually ran and was not bypassed by routing:
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", "req-123")
next.ServeHTTP(w, r)
})
}
Now the test. It creates a user, reads it back, and confirms a missing user maps the domain error to a 404, all through a real server:
package api
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestUserAPI_EndToEnd(t *testing.T) {
srv := httptest.NewServer(NewServer(NewMemoryStore()).Routes())
defer srv.Close()
// 1. Create a user through the real POST path.
body := bytes.NewBufferString(`{"id":"u1","email":"[email protected]"}`)
resp, err := http.Post(srv.URL+"/users", "application/json", body)
if err != nil {
t.Fatalf("POST failed: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: got status %d, want 201", resp.StatusCode)
}
if got := resp.Header.Get("X-Request-ID"); got != "req-123" {
t.Fatalf("middleware did not run: X-Request-ID = %q", got)
}
resp.Body.Close()
// 2. Fetch it back; confirm the JSON round-trips.
resp, err = http.Get(srv.URL + "/users/u1")
if err != nil {
t.Fatalf("GET failed: %v", err)
}
var got User
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
resp.Body.Close()
if got.Email != "[email protected]" {
t.Fatalf("get: email = %q, want [email protected]", got.Email)
}
// 3. A missing user maps the domain error to 404.
resp, err = http.Get(srv.URL + "/users/missing")
if err != nil {
t.Fatalf("GET missing failed: %v", err)
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("missing: got status %d, want 404", resp.StatusCode)
}
payload, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Logf("404 body: %s", bytes.TrimSpace(payload))
}
Run it:
$ go test -v -run TestUserAPI_EndToEnd ./api/
=== RUN TestUserAPI_EndToEnd
api_e2e_test.go:56: 404 body: {"error":"user not found"}
--- PASS: TestUserAPI_EndToEnd (0.00s)
PASS
ok itg/api 0.012s
That single test exercised routing, the middleware chain, JSON encode and decode, and error-to-status mapping over a real HTTP connection. It uses an in-memory store, so it is fast and needs no -tags guard. The database is the only remaining real boundary, and for that you do need infrastructure.
Testing against a real database: the pattern
Everything below crosses into third-party territory (a Postgres driver and Docker), which this sandbox cannot execute. The code is written against current official docs and is idiomatic, but the outputs are described as expected, not observed. Verified library versions: testcontainers-go v0.42.0 and ory/dockertest v4.0.0, both current as of August 2026.
The database integration pattern is always the same three moves:
- Spin up a real Postgres, ideally a throwaway container so the test is hermetic and does not depend on a database someone left running on their laptop.
- Run your migrations against it, so the schema matches production.
- Isolate each test so they do not see each other’s data, by truncating tables or, better, wrapping each test in a transaction you roll back.
Spinning up Postgres with testcontainers-go
testcontainers-go starts a container from your test and tears it down after. The postgres module wraps the boilerplate. This file carries the //go:build integration tag so it never touches the default build:
//go:build integration
package repo
import (
"context"
"database/sql"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
// setupPostgres starts a throwaway Postgres and returns an open *sql.DB.
// Illustrative: not executed in this article's sandbox.
func setupPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("app_test"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
postgres.BasicWaitStrategies(),
)
if err != nil {
t.Fatalf("start postgres: %v", err)
}
// t.Cleanup runs in LIFO order after the test, pass or fail.
t.Cleanup(func() { _ = testcontainers.TerminateContainer(container) })
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("connection string: %v", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
mustMigrate(t, db) // run your schema migrations here
return db
}
t.Cleanup is the key to sane teardown. It registers cleanup that runs when the test finishes, in reverse order, whether the test passes or panics. No defer juggling, and helpers can register their own cleanup without returning a close function to the caller.
The ory/dockertest library is the main alternative and works the same way: it boots a container, gives you a connection string, and offers a retry helper to wait for the database to accept connections. testcontainers-go has become the more common default, but dockertest is lighter and still widely used.
Structuring the repository test with a per-test transaction
Now the actual test. The cleanest isolation is a transaction per test that you always roll back: every test starts from the same schema, writes freely, and leaves no trace. The repository takes a database/sql handle type (*sql.Tx and *sql.DB both satisfy a small interface), so you can hand it the transaction:
//go:build integration
package repo
import (
"context"
"testing"
)
func TestUserRepo_CreateAndGet(t *testing.T) {
db := setupPostgres(t) // container starts once per test here
tx, err := db.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
// Always roll back: the test's writes never persist.
t.Cleanup(func() { _ = tx.Rollback() })
r := NewUserRepo(tx)
ctx := context.Background()
if err := r.Create(ctx, User{ID: "u1", Email: "[email protected]"}); err != nil {
t.Fatalf("create: %v", err)
}
got, err := r.Get(ctx, "u1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Email != "[email protected]" {
t.Fatalf("email = %q, want [email protected]", got.Email)
}
}
Expected output when you run it against Docker:
$ go test -tags integration -run TestUserRepo -v ./repo/
=== RUN TestUserRepo_CreateAndGet
--- PASS: TestUserRepo_CreateAndGet (2.31s)
PASS
ok yourapp/repo 2.34s
That 2.3 seconds, mostly container startup, is exactly why this test is tag-gated. Starting a fresh container per test is clean but slow; a common optimization is to start one container for the whole package in TestMain, then use the transaction-rollback trick for per-test isolation, so you pay the container cost once. When a transaction won’t do (you are testing commit behavior itself), TRUNCATE table1, table2 RESTART IDENTITY CASCADE between tests is the fallback. What you must not do is share mutable state across tests and hope ordering saves you; Go may run tests in any order, and t.Parallel() guarantees they overlap.
For the driver and query details behind NewUserRepo, see the Postgres in Go guide and database migrations.
Running integration tests in CI separately
Split CI into two stages. The fast stage gates every push; the slow stage runs the boundary tests.
# Stage 1: fast, runs on every push
go test -short ./...
# Stage 2: integration, runs with Docker available
go test -tags integration ./...
Add -count=1 to the integration stage to bypass the test cache, since a cached “pass” against a database that has since changed is a lie. Give the stage a longer timeout (-timeout 300s) because containers are slow to boot, and run it on a runner that has Docker. Keeping the stages separate means a flaky container never blocks a one-line docs fix, while real regressions in your SQL still get caught before release.
Common mistakes
Mixing slow integration tests into the default run. If go test ./... boots Docker, developers stop running it. Gate every infrastructure test behind a build tag or -short, with no exceptions, so the fast path stays sub-second.
Sharing database state across tests. Two tests that both insert user u1 pass alone and fail together, or worse, pass in a random order and fail in CI. Isolate with a rolled-back transaction or a truncate between tests. Never rely on test order.
No cleanup, so containers and connections leak. A container that outlives its test eats memory and ports until CI falls over. Register teardown with t.Cleanup the moment you create the resource, not at the end of the test where an early t.Fatalf skips it.
Testing against a mock when the boundary is the point. A “database integration test” that swaps in a fake store tests nothing the boundary can break. If the goal is to prove your SQL runs, the SQL must run. Mocks belong in unit tests; see mocking in Go for where they fit.
Non-hermetic tests that depend on external services. A test that hits a shared staging database or a live third-party API is not reproducible: it fails when the network blips or a colleague truncates a table. Own your dependencies. Start your own container so the test passes or fails on your code alone.
What next
Integration tests catch what unit tests structurally cannot: the real query, the real router, the real status code. They cost more (slower, occasionally flaky), so the whole game is keeping them isolated from the fast loop with build tags or -short, and running them in their own CI stage.
- Ground the fundamentals with the Go testing guide, the pillar for this whole cluster, and writing unit tests in Go.
- Go deeper on the boundaries themselves: mocking in Go for the unit side, Postgres in Go and database migrations for the database side.
- New to the language or filling gaps? The complete Go tutorial covers everything the tests above assume.