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:
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:
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
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
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/jsonheader on aPOST/PUTtest 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, whenrouter.ServeHTTP(recorder, req)tests exactly the same routing and handler logic with no network involved at all — saveNewServerfor 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.