Testing Gin APIs

Using httptest to test Gin handlers directly, with a complete worked example.

Why httptest

Go's standard library ships everything needed to test an HTTP handler without ever binding to a real network socket: net/http/httptest. httptest.NewRequest builds an in-memory *http.Request, and httptest.NewRecorder gives you an http.ResponseWriter that simply records whatever gets written to it — no listening port, no real network round trip, and no risk of port conflicts between parallel test runs.

Because *gin.Engine implements the standard http.Handler interface (covered on the introduction page in this track), testing a Gin route is as simple as calling router.ServeHTTP(recorder, request) directly — the exact same code path a real running server would use to handle that request.

Setting Gin to test mode

Gin's default (debug) mode prints a startup warning banner and verbose route-registration logs — harmless, but noisy in test output. Switch it off once per test binary:

Go
gin.SetMode(gin.TestMode)

A complete example: testing handlers from the Task API

This continues the Task API built in the building-a-rest-api-with-gin page in this track — the same Task struct and TaskStore handlers, tested directly:

Go
package main

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

    "github.com/gin-gonic/gin"
    "github.com/stretchr/testify/assert"
)

func setupRouter(store *TaskStore) *gin.Engine {
    gin.SetMode(gin.TestMode)
    r := gin.New()
    r.GET("/api/v1/tasks/:id", store.getTask)
    r.POST("/api/v1/tasks", store.createTask)
    return r
}

func TestGetTask_ReturnsTaskWhenFound(t *testing.T) {
    store := NewTaskStore()
    store.tasks[1] = Task{ID: 1, Title: "Write docs", Done: false}
    router := setupRouter(store)

    req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/1", nil)
    recorder := httptest.NewRecorder()

    router.ServeHTTP(recorder, req)

    assert.Equal(t, http.StatusOK, recorder.Code)

    var got Task
    err := json.Unmarshal(recorder.Body.Bytes(), &got)
    assert.NoError(t, err)
    assert.Equal(t, "Write docs", got.Title)
}

func TestGetTask_ReturnsNotFoundWhenMissing(t *testing.T) {
    store := NewTaskStore()
    router := setupRouter(store)

    req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/999", nil)
    recorder := httptest.NewRecorder()

    router.ServeHTTP(recorder, req)

    assert.Equal(t, http.StatusNotFound, recorder.Code)
}

Testing a POST with a JSON body

Go
func TestCreateTask_RejectsMissingTitle(t *testing.T) {
    store := NewTaskStore()
    router := setupRouter(store)

    body := bytes.NewBufferString(`{"title": ""}`)
    req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", body)
    req.Header.Set("Content-Type", "application/json")
    recorder := httptest.NewRecorder()

    router.ServeHTTP(recorder, req)

    assert.Equal(t, http.StatusBadRequest, recorder.Code)
}

func TestCreateTask_ReturnsCreatedTask(t *testing.T) {
    store := NewTaskStore()
    router := setupRouter(store)

    body := bytes.NewBufferString(`{"title": "Write docs"}`)
    req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", body)
    req.Header.Set("Content-Type", "application/json")
    recorder := httptest.NewRecorder()

    router.ServeHTTP(recorder, req)

    assert.Equal(t, http.StatusCreated, recorder.Code)

    var created Task
    json.Unmarshal(recorder.Body.Bytes(), &created)
    assert.Equal(t, "Write docs", created.Title)
    assert.False(t, created.Done)
}

Setting Content-Type: application/json on the request matters here — without it, ShouldBindJSON may not treat the body as JSON at all, and the test would fail for a reason unrelated to the actual validation logic being tested.

Running the tests

Bash
go test ./...

go test -v ./... prints each test function's name and pass/fail individually, useful while actively writing or debugging a specific test.

Common mistakes

  • Forgetting the Content-Type: application/json header on a POST/PUT test request — a common cause of a test failing on binding for reasons that have nothing to do with the actual data being sent.
  • Reaching for a real httptest.NewServer (which does open an actual local socket) to test a single handler, when router.ServeHTTP(recorder, req) tests exactly the same routing and handler logic with no network involved at all — save NewServer for genuinely needing a real client (like testing a generated SDK) to connect to.
  • Sharing one TaskStore (or any other mutable state) across multiple test functions without resetting it — tests that depend on execution order or leftover state from a previous test are a common source of flaky, hard-to-debug failures.

Interview questions

Q: How do you test a Gin handler without starting a real HTTP server? Build a request with httptest.NewRequest and a response recorder with httptest.NewRecorder, then call router.ServeHTTP(recorder, request) directly. This works because *gin.Engine implements the standard http.Handler interface, so it can be driven exactly the way a real server would drive it, without ever opening a network socket.

Q: Why might a test for a JSON POST endpoint fail even though the handler logic is correct? A common cause is a missing Content-Type: application/json header on the test request — ShouldBindJSON (and Gin's other binding helpers) can behave differently, or reject the body outright, if the request doesn't declare its content type as JSON, independent of whether the body itself is valid JSON.

Q: Why does the Task API's test setup construct a fresh TaskStore in every test function instead of sharing one? Sharing mutable state across tests makes their outcomes depend on execution order — a test that assumes an empty store, or a specific task ID, can pass or fail depending on what earlier tests left behind. A fresh store per test keeps each test's outcome deterministic and independent of the others.