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.
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, 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.
funcRelated()[]Article
Scaling PostgreSQL for Go Applications: Read Replicas, Connection Pools and Query Performance
A practical order of operations for scaling PostgreSQL under a growing Go service: query optimization first, then pooling, then read replicas — and why the order matters.
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.
Idempotent Consumers in Go: Handling Duplicate Messages Safely
Why message consumers must assume duplicate delivery, and how to implement deduplication in Go and PostgreSQL without a race condition.