Skip to content
Testing

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.

GoBackend.dev11 min read
GoTestingPostgreSQL

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 JOIN only fails against a real database.
  • Handler tests that skip httptest and 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.