Testing Go Backend Applications: Unit, Integration and HTTP Tests
A practical testing strategy for Go backend services: table-driven unit tests, httptest for handlers, mocks for services, and integration tests against a real PostgreSQL database.
TL;DR
Match the testing technique to the layer — table-driven unit tests for pure logic, hand-written fakes for the service layer, httptest through the real handler for HTTP-contract correctness, and integration tests against real PostgreSQL for anything that constructs SQL.
What You'll Learn
- How to write table-driven unit tests for pure business logic with no I/O
- How to substitute a hand-written fake for a repository interface at the service layer
- How to test HTTP handlers through the real router with httptest instead of calling service methods directly
- How to test authorization failures at the middleware level, not just the success path
- How to set up integration tests against a real PostgreSQL instance, gated behind a build tag
- How to organize and run fast unit tests separately from database-dependent integration tests
Prerequisites
- Go interfaces
- Basic Go
The problem
A backend service has at least three layers worth testing differently: pure business logic, HTTP handling, and database access — and testing all three the same way (usually: mocking everything) produces a suite that's fast and green but doesn't catch real bugs, particularly SQL errors and serialization mismatches that only show up against a real database or a real HTTP request/response cycle.
The goal isn't maximum test count or 100% coverage — it's picking the right technique per layer so the suite catches real regressions without becoming slow or brittle.
Why it matters
Mismatched testing strategy shows up in specific, avoidable ways:
- Over-mocked repository tests pass even when the actual SQL query is
wrong — a typo'd column name or a broken
JOINonly fails against a real database. - Handler tests that skip
httptestand call service methods directly miss bugs in status code mapping, JSON encoding, and middleware ordering — the things that are actually part of the HTTP contract. - No integration tests at all means schema drift (a migration that doesn't match what the repository code expects) isn't caught until it breaks in a real environment.
Table-driven unit tests for business logic
Pure logic — validation, calculations, state transitions — belongs in table-driven unit tests with no I/O at all:
func TestValidateOrder(t *testing.T) {
tests := []struct {
name string
order Order
wantErr string
}{
{
name: "valid order",
order: Order{CustomerID: "cust_1", Items: []Item{{SKU: "sku_1", Qty: 2}}},
},
{
name: "missing customer",
order: Order{Items: []Item{{SKU: "sku_1", Qty: 1}}},
wantErr: "customer id is required",
},
{
name: "zero quantity",
order: Order{CustomerID: "cust_1", Items: []Item{{SKU: "sku_1", Qty: 0}}},
wantErr: "item quantity must be positive",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateOrder(tt.order)
if tt.wantErr == "" {
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
return
}
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("expected error %q, got %v", tt.wantErr, err)
}
})
}
}This style scales cleanly as cases grow — adding a case is a new struct
literal, not new test code — and each subtest reports independently with
t.Run, so a failure names the exact case that broke.
Testing the service layer with a hand-written fake
The service layer depends on a repository through a small interface (see Go Interfaces: Design Small, Testable Components), so tests can substitute a fake implementation instead of a real database:
type fakeOrderRepository struct {
orders map[string]Order
err error
}
func (f *fakeOrderRepository) FindByID(_ context.Context, id string) (*Order, error) {
if f.err != nil {
return nil, f.err
}
o, ok := f.orders[id]
if !ok {
return nil, ErrOrderNotFound
}
return &o, nil
}
func TestOrderService_GetOrder_NotFound(t *testing.T) {
repo := &fakeOrderRepository{orders: map[string]Order{}}
svc := NewOrderService(repo)
_, err := svc.GetOrder(context.Background(), "missing")
if !errors.Is(err, ErrOrderNotFound) {
t.Fatalf("expected ErrOrderNotFound, got %v", err)
}
}A hand-written fake stays small and readable for a small interface — there's no need for a mocking framework here. Reach for one only once fakes start duplicating a lot of boilerplate across many tests.
HTTP tests with httptest
Handler tests should go through the actual http.Handler you'd wire up in a
production REST API, using
httptest.NewRecorder and httptest.NewRequest, so they cover routing,
status codes, and response encoding — not just the service call underneath:
func TestGetOrderHandler(t *testing.T) {
tests := []struct {
name string
orderID string
repoErr error
wantStatus int
}{
{name: "found", orderID: "order_1", wantStatus: http.StatusOK},
{name: "not found", orderID: "missing", repoErr: ErrOrderNotFound, wantStatus: http.StatusNotFound},
{name: "internal error", orderID: "order_1", repoErr: errors.New("db down"), wantStatus: http.StatusInternalServerError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &fakeOrderRepository{
orders: map[string]Order{"order_1": {ID: "order_1"}},
err: tt.repoErr,
}
handler := NewOrderHandler(NewOrderService(repo))
req := httptest.NewRequest(http.MethodGet, "/orders/"+tt.orderID, nil)
req = req.WithContext(context.Background())
rr := httptest.NewRecorder()
router := chi.NewRouter()
router.Mount("/orders", handler.Routes())
router.ServeHTTP(rr, req)
if rr.Code != tt.wantStatus {
t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, rr.Code, rr.Body.String())
}
})
}
}Routing the request through the real chi.Router — rather than calling the
handler function directly — also catches routing mistakes (wrong method,
missing path parameter) that a direct function call would silently skip.
Testing authorization failures
An unauthorized-request test belongs at the same level, verifying the middleware chain rejects the request before it reaches the handler:
func TestGetCurrentUser_Unauthorized(t *testing.T) {
router := chi.NewRouter()
router.Use(AuthMiddleware(testJWTSecret))
router.Get("/me", currentUserHandler)
req := httptest.NewRequest(http.MethodGet, "/me", nil) // no Authorization header
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}Integration testing against PostgreSQL
Repository code that constructs SQL should be tested against a real
database — a fake repository can't catch a broken query. Run a disposable
PostgreSQL instance for the test run (via docker compose in CI, or a
locally running container) and gate these tests behind a build tag or an
environment variable so they don't run as part of the default fast suite:
//go:build integration
func TestPostgresOrderRepository_FindByID(t *testing.T) {
db := setupTestDB(t) // opens a connection, runs migrations, wraps in a transaction
repo := NewPostgresOrderRepository(db)
want := insertTestOrder(t, db, "cust_1")
got, err := repo.FindByID(context.Background(), want.ID)
if err != nil {
t.Fatalf("FindByID: %v", err)
}
if got.CustomerID != want.CustomerID {
t.Fatalf("expected customer %s, got %s", want.CustomerID, got.CustomerID)
}
}setupTestDB typically opens a
transaction per test and rolls it
back at the end via t.Cleanup, so tests don't leak data into each other and
don't require resetting the database between runs:
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("pgx", os.Getenv("TEST_DATABASE_URL"))
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}Run these separately from unit tests: go test ./... for the fast suite,
go test -tags=integration ./... for the suite that needs a database, so
local development and CI's unit-test stage stay fast.
Test organization
Keep unit tests next to the code they test (order_service_test.go beside
order_service.go), and integration tests either in a tests/ directory or
alongside the repository package behind the integration build tag — either
works, but pick one convention and apply it consistently across a codebase.
Common mistakes
- Mocking the repository in every test, including the ones meant to verify the repository's own SQL — that layer needs a real database to mean anything.
- Calling handler functions directly instead of through the router, which silently skips routing and middleware bugs.
- No cleanup between integration tests, leading to order-dependent test failures as data accumulates across runs.
- Testing implementation details (asserting a private helper was called a certain number of times) instead of observable behavior (the returned value or HTTP response) — this makes refactors fail tests without a real regression.
- Skipping the unauthorized/error paths. A suite that only tests the success path won't catch a status-code regression when error handling changes.
Summary
Match the testing technique to the layer: table-driven unit tests for pure
logic, hand-written fakes for service-layer tests against a small interface,
httptest routed through the real handler for HTTP-contract correctness,
and integration tests against a real PostgreSQL instance for anything that
constructs SQL. Keeping these separate — and running the database-dependent
suite on its own — is what keeps a backend test suite both trustworthy and
fast. This is correctness testing; it's a different concern from
load testing, which checks how the same
endpoints hold up under real traffic rather than whether they return the
right result.
Interview Questions
Key Takeaways
- Mocking the repository in every test, including ones meant to verify its own SQL, never catches a broken query — that layer needs a real database
- Calling handler functions directly instead of routing through chi.Router silently skips routing and middleware bugs
- Integration tests should roll back a transaction per test (via t.Cleanup) rather than requiring a database reset between runs
- Run unit and integration suites separately (go test ./... vs go test -tags=integration ./...) so CI's fast stage stays fast
- Testing implementation details instead of observable behavior makes refactors fail tests without a real regression
- A suite that only tests the success path won't catch a status-code regression when error handling changes
funcRelated()[]Article
Building a Production-Ready REST API with Go
A clean, minimal-abstraction project structure for production Go REST APIs: routing, configuration, handlers, services, repositories, validation, error handling, logging, middleware, and health checks.
Database Transactions in Go: Isolation Levels, Locks and Real-World Failures
PostgreSQL transaction isolation levels in Go, explained through a real race condition: lost updates, SELECT FOR UPDATE, and why app-level locking isn't enough.
Load Testing a Go API: Finding the Real Bottleneck
Load testing a Go API with k6: throughput, p50/p95/p99 latency, and a systematic way to find whether the bottleneck is the app, the database, or the network.