JavaRush /Courses /Go SELF /Integration HTTP Tests: httptest.Server

Integration HTTP Tests: httptest.Server

Go SELF
Level 64, Lesson 1
Available

1. Why integration HTTP tests are needed

When you write a unit test for a handler, you test it “in a vacuum”: a single function that receives a ResponseWriter and a Request and writes something into the response. This is very fast and very useful, but there’s one nuance: in real life, a handler almost never lives alone. It lives in the chain “mux → middleware → handler”, and the client sees the result of that entire chain, not a bare function.

An integration test of the HTTP layer is when we spin up a test server and make a request like a real client: via http.Client, via a URL, with all headers and actual reading from resp.Body. This kind of test catches bugs that unit tests often miss: an incorrect route, an unexpected 404/405, forgotten middleware, missing Content-Type, different error formats “in different corners” of the server.

So it doesn’t feel like “yet another type of tests for the sake of tests”, let’s lock the idea in with a simple table:

What we check Unit test of a handler (ResponseRecorder) Integration test (httptest.Server)
Logic of a specific handler Perfect Possible, but more expensive
ServeMux routing (patterns, {id}) Almost not Yes, that’s its job
Middleware (for example, X-Request-ID) Usually no Yes, naturally
Behavior “as the client sees it” Almost Yes, 100%
Speed/simplicity Maximum A bit more complex, but still fast

And yes, integration tests in Go are not “heavy artillery” that takes 10 minutes to run. httptest.Server starts inside the test process: no Docker, no real port in config, no rituals. It’s a solid, practical tool that helps keep your HTTP contract under control.

2. The basics: httptest.Server and the HTTP stack under test

What httptest.Server is

httptest.NewServer(handler) spins up a mini HTTP server right inside your test. It listens on a random free port (so it doesn’t conflict with other tests and your local app), gives you a base URL (ts.URL) and a ready-to-use client (ts.Client()) for making requests.

It’s important to understand the philosophy: it’s “almost like the network”, but without the outside world. Everything happens in one process, fully reproducible. It’s a great balance: we test HTTP behavior “as the client sees it”, but we don’t pay the price of complex environments.

A small diagram to make the picture click:

flowchart LR
    T[Test] -->|creates| S[httptest.Server]
    S -->|ServeHTTP| H[your http.Handler: mux + middleware + handlers]
    T -->|HTTP request| C["ts.Client()"]
    C -->|GET/POST| S
    S -->|HTTP response| C
    C -->|status/headers/json| T

And right away, the discipline without which everything turns into “tests that sometimes hang in weird ways”.

ts := httptest.NewServer(handler)
defer ts.Close() // must close the server

If you forget Close, the server can keep running until the end of the test package. Sometimes it’s “just a leak”, and sometimes it causes strange effects—especially if you have many tests.

Assemble the HTTP stack behind a single entry point

Before writing integration tests, it’s useful to have one function that assembles your entire HTTP stack: mux, middleware, and route registration. Then your tests use exactly the same “entry point” as main.

Let’s imagine we have a toy app called “tasks”, and we’ve already implemented an HTTP API like:

  • POST /api/v1/tasks — create a task
  • GET /api/v1/tasks/{id} — get a task
  • errors are always returned as an error envelope

We’ll build the server using a newHTTPHandler(...) function. It will return an http.Handler that we pass into httptest.NewServer.

func newHTTPHandler(taskHandler http.Handler) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("GET /api/v1/tasks/{id}", taskHandler)
	mux.Handle("POST /api/v1/tasks", taskHandler)
	return mux
}

Yes, in a real project you’ll most likely have a TaskHandler with different methods, or separate handlers per endpoint. What matters here isn’t architectural perfection, but the idea: the test starts the server the same way it will be started in the application.

If you use middleware (for example, request id), it’s very convenient to include it here too—then the integration test will truly verify that the middleware is “alive”.

Mini rules for requests: ts.URL, ts.Client() and resp.Body.Close()

Now let’s talk about how to make requests.

ts.URL is the base address, for example "http://127.0.0.1:54321". In tests we simply append the path as a string. Yes, it’s “string concatenation”, but in tests it’s acceptable and readable.

ts.Client() is a preconfigured http.Client. For a regular NewServer it’s not super-magical, but the habit is good: “the server provided the client”.

And the most important point: resp.Body is a stream. You must close it like the front door of an apartment building: not because you’re mean, but because that’s what adults do.

resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/1")
if err != nil {
	t.Fatalf("request: %v", err)
}
defer resp.Body.Close()

If you don’t close the body, tests can start “nibbling” resources and suddenly become flaky. The worst kind of bug: “it passed on my laptop, but failed in CI”.

3. Verify the API contract: status, headers, and JSON structure

Integration tests are especially useful when you verify a contract, not “whatever happened to work today”. So instead of “compare the body as a string”, we almost always do this:

  1. check resp.StatusCode
  2. check Content-Type (if it’s JSON)
  3. decode JSON and check the fields

We already decided on types for the error envelope earlier; let’s pin them down in the test file (or in a package if you want reuse).

type apiError struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

type errorEnvelope struct {
	Error apiError `json:"error"`
}

For a successful response, let’s use a simple task DTO:

type taskDTO struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

And a small helper so JSON errors read nicely in a test. Plus t.Helper(), so the test fails “at the call site”, not inside the helper—this saves nerves and tea.

func mustJSON(t *testing.T, r io.Reader, dst any) {
	t.Helper()
	if err := json.NewDecoder(r).Decode(dst); err != nil {
		t.Fatalf("decode json: %v", err)
	}
}

By the way, the very idea that “errors are values; you should program them, not just print them” is very Go-like. In the HTTP layer it looks like this: we work with errors structurally, not as strings. And we do the same in tests: decode the structure and assert on fields.

4. Practice: integration HTTP test scenarios

Routing and {id}

Let’s start with the most down-to-earth case: make sure the ServeMux pattern actually matches, and that {id} is extracted correctly. This is a typical example of something a unit test for a handler won’t catch (because there you might accidentally test a handler that isn’t registered on that path at all).

Let’s make a minimal mux and server:

mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, r.PathValue("id"))
})
ts := httptest.NewServer(mux)
defer ts.Close()

Then make the request:

resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/42")
if err != nil {
	t.Fatalf("request: %v", err)
}
defer resp.Body.Close()

And assert:

b, _ := io.ReadAll(resp.Body)
if string(b) != "42" {
	t.Fatalf("body=%q, want %q", string(b), "42")
}

This test looks almost ridiculously simple, but it checks an important thing: the route is actually registered correctly, and the server can actually extract {id}. In a real project, tests like this save you from “oops, I wrote "/task/{id}" instead of "/tasks/{id}", and now everything is 404”.

Success: POST creates a task and returns JSON

Now let’s move to a normal API scenario: creating a task. We’ll verify that the server:

  • accepted JSON,
  • returned the correct status,
  • returned the correct Content-Type,
  • returned the task DTO.

Suppose we already have a handler that serves POST /api/v1/tasks. In the test, it’s important for us to send a JSON body.

body := strings.NewReader(`{"title":"buy milk"}`)
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/v1/tasks", body)
req.Header.Set("Content-Type", "application/json")

The call:

resp, err := ts.Client().Do(req)
if err != nil {
	t.Fatalf("request: %v", err)
}
defer resp.Body.Close()

Assert status and header:

if resp.StatusCode != http.StatusCreated {
	t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusCreated)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" {
	t.Fatalf("Content-Type=%q", ct)
}

And assert the JSON body:

var got taskDTO
mustJSON(t, resp.Body, &got)

if got.Title != "buy milk" {
	t.Fatalf("title=%q", got.Title)
}

Notice an important detail: we don’t compare the entire JSON as a string. Tomorrow you’ll add a field updated_at or change the field order—the string comparison will fall apart, even though the contract “task is created” remains true. The test should be your ally, not a picky critic.

400 error: invalid {id} and fields in the error envelope

Now the fun part: checking the error envelope in integration. We want to ensure that not only the handler “internally” can produce an envelope, but that the whole stack (mux, common error handler, middleware) does not break the format.

Scenario: the client calls GET /api/v1/tasks/abc. By contract, this is a validation error, because {id} must be a number.

Request:

resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/abc")
if err != nil {
	t.Fatalf("request: %v", err)
}
defer resp.Body.Close()

Assert status and decode the envelope:

if resp.StatusCode != http.StatusBadRequest {
	t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusBadRequest)
}

var env errorEnvelope
mustJSON(t, resp.Body, &env)

Assert fields:

if env.Error.Code != "validation" {
	t.Fatalf("code=%q", env.Error.Code)
}
if env.Error.Fields["id"] == "" {
	t.Fatalf("fields.id is empty")
}

Here we’re checking exactly what we promised the client: “a validation error has code "validation" and a fields map.” The text can be “must be integer” or “invalid id”—it depends on your standard. But the fact that fields exists and contains "id" is part of the contract.

404 error: not_found must also be an envelope

The next scenario is similar, but the meaning is different: the id is valid, but the entity doesn’t exist. This is not validation; it’s not_found.

Suppose GET /api/v1/tasks/9999 returns not_found. In your test setup you can start the server with empty storage or with a couple of tasks—the same way you did it in the application code.

resp, err := ts.Client().Get(ts.URL + "/api/v1/tasks/9999")
if err != nil {
	t.Fatalf("request: %v", err)
}
defer resp.Body.Close()

Assert status + envelope:

if resp.StatusCode != http.StatusNotFound {
	t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusNotFound)
}

var env errorEnvelope
mustJSON(t, resp.Body, &env)

if env.Error.Code != "not_found" {
	t.Fatalf("code=%q", env.Error.Code)
}

Why is an integration test more important here than a unit test? Because it’s not rare to have a situation like: “in one handler, not_found is wrapped; in another, we forgot and returned a bare http.Error”. A unit test for a single handler won’t catch that if you didn’t write unit tests for every endpoint. Integration tests help keep a single standard, at least on critical routes.

500 error: the message must be stable and safe

With 500 errors, there’s a special agreement: the client receives a stable message (for example, "internal error"), while the real details stay in the logs. This isn’t “nitpicking”—it’s about security and UX: the user doesn’t need to see "dial tcp 10.0.0.12:5432: connection refused".

It’s useful to verify this via integration to ensure the shared error handler actually “cuts off” internals and returns a stable envelope.

A test scenario usually looks like this: start the server in a configuration where the handler intentionally fails with an “internal error”. How exactly depends on your application architecture. The key is what the client must see:

  • HTTP 500
  • {"error":{"code":"internal","message":"internal error"}}

The assertion looks familiar:

if resp.StatusCode != http.StatusInternalServerError {
	t.Fatalf("status=%d, want %d", resp.StatusCode, http.StatusInternalServerError)
}

var env errorEnvelope
mustJSON(t, resp.Body, &env)

if env.Error.Message != "internal error" {
	t.Fatalf("message=%q", env.Error.Message)
}

But “asserting that message contains the internal error text” is exactly the anti-goal: you’re locking a details leak in as the norm. That test would protect incorrect behavior, not correct behavior.

Table-driven approach: less copy-paste, more meaning

After you’ve written 3–5 integration tests, you suddenly discover that 60% of the code is the same thing: create a request, call Do, close the body, check status, decode JSON.

The simplest solution is table-driven tests plus a few small helpers. But be careful: integration tests shouldn’t turn into a “combine harvester that checks everything in the world in one loop”. A good style is one test case = one clear scenario.

For example, you can keep a table for error cases:

cases := []struct {
	name   string
	path   string
	status int
	code   string
}{
	{"bad id", "/api/v1/tasks/abc", 400, "validation"},
	{"missing", "/api/v1/tasks/9999", 404, "not_found"},
}

And run it like this:

for _, tc := range cases {
	t.Run(tc.name, func(t *testing.T) {
		resp, _ := ts.Client().Get(ts.URL + tc.path)
		defer resp.Body.Close()

		var env errorEnvelope
		mustJSON(t, resp.Body, &env)
	})
}

Notice: I’m intentionally not trying to cram “both success and errors and different methods” into this. As soon as the table becomes too generic, readability drops. Your test starts to look like an accounting form rather than a behavior check.

5. Common mistakes

Mistake No. 1: forgetting defer ts.Close().
As a result, the server keeps living longer than it should. Sometimes it’s almost invisible, and sometimes it leads to “flaky” problems and resource leaks. The fix is trivial: created the server—immediately write defer ts.Close() right next to it.

Mistake No. 2: forgetting defer resp.Body.Close().
This is one of the most common reasons tests start behaving unstably as the number of scenarios grows. Body is a stream; it holds resources, and tests must release them too. If you don’t want to remember—create a helper like mustClose(t, resp.Body) and use it consistently.

Mistake No. 3: comparing JSON as a string.
Today the field order is one thing, tomorrow it’s another; the day after tomorrow you added omitempty, and a “perfectly working API” suddenly breaks the test. String comparison is rarely useful; for contracts it’s almost always harmful. It’s more reliable to decode JSON into a struct/map and check the fields you care about.

Mistake No. 4: the integration test tries to verify “absolutely everything”.
When a single test simultaneously checks routing, middleware, a success response, and three different error types, you get a test that’s hard to read and hard to fix. It fails, and it’s unclear why. Integration tests should be small: one scenario—one reason to fail.

Mistake No. 5: not checking Content-Type even though the API promises JSON.
It’s very easy to accidentally return a JSON body but forget the header, or return text/plain via http.Error. For clients this can be critical (especially for automated clients and SDKs). If the contract says “this is JSON”—the header becomes part of the contract, and it’s worth checking at least in key tests.

1
Task
Go SELF, level 64, lesson 1
Locked
POST Note
POST Note
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION