Lesson 03 / 16
HTTP Tests
The same handler is tested with three requests without ever opening a real port: an existing item returns 200, a nonexistent one 404, a request without a parameter 400 — all three run information learned only by calling handler.ServeHTTP. The limiting measurement reads that the request's address is a fixed address RFC 5737 reserves for documentation purposes, showing that the test exercises the handler, not the network.
Published
In this lesson
- Tests an HTTP handler by calling it directly with server-side and client-side test helpers, without opening a real port
- Classifies the same handler's verdict on three requests (200, 404, 400) as run information and compares it against the status code count written in the source
- Limits that the tested request does not come from a real connection, by reading the request's address
Contents
The previous lesson measured how a table’s case count matches its running subtest count,
and whether this match comes from the source or from the run. This lesson carries the
same question into the network layer: a server’s response to a request is also
information that can only be learned by running it — but “running” here does not mean
opening a real port, listening on a socket, or having a client call that socket. The
test package’s net/http/httptest subpackage separates these two: testing a handler’s
behavior can mean testing that handler directly with a function call, not over the
network.
If the behavior of an HTTP server running in production can be tested without connecting to the server directly and sending the request by hand, that test does not depend on the network settings of the machine running the server, on finding a free port, or on a timeout — meaning its result is independent of the environment. This lesson measures that claim in two steps: first how a handler is called without a connection at all, then how the fact that the request this call produces did not come from a real network can itself be verified.
The Handler Is Called Directly, No Port Is Ever Opened
The inventory package moves into the HTTP layer in this lesson: itemHandler takes a
store (a dictionary from name to Item) and returns an http.HandlerFunc. The
handler decides in three steps: first it reads the name parameter from the request’s
query string, then checks whether that name exists in the store, and finally, if it
does, writes the item’s price as plain text. If the request carries no name query
parameter it returns 400, if the name it carries is not in the store 404, if it is,
it returns the item’s price in the body with 200. All this handler needs to be able to
connect to an ordinary Go HTTP server is to be registered on an http.ServeMux — but
this lesson never connects it to a server at all.
The handler’s signature is also a choice that makes this separation easier:
itemHandler takes the store as a parameter and returns an http.HandlerFunc, the
store itself is not pinned as a package-level variable. This lets the same handler be
rebuilt with a different store — for instance the single-item fixed store used in this
lesson, or an empty store in another measurement; the handler itself never knows which
store it is working with, it only looks at the map[string]Item value it was given.
This shape is a closure — the function itemHandler returns keeps holding the store
variable given at the call that created it, in its own body, just like the previous
lesson’s provideSamples helper stays bound to its own caller. The test helpers never
go inside this closure; they only call the http.HandlerFunc signature visible from the
outside, and which store is held inside the closure stays invisible to the measurement
itself.
The call that builds the request uses the http.MethodGet constant instead of writing
the "GET" string by hand — these constants defined by the standard library itself
catch a misspelled method name from a single source, if not at compile time, and every
request used throughout this lesson shares the same constant.
Two routines from the httptest package make this connection unnecessary:
httptest.NewRequest produces the Go equivalent of a request a client would send over
the network — a client-side helper, because normally this request is sent by a client
over a connection. httptest.NewRecorder is a structure that implements an
http.ResponseWriter and collects a response — a server-side helper, because normally
this is what writes a response to the other end of a connection. Used together, the call
handler.ServeHTTP(recorder, request) runs the handler without ever touching a real
network stack, inside the same process, like an ordinary function call.
The httptest package also has a NewServer routine, and it really does open a port —
it binds the given handler to a temporary, free port on the local machine, returns an
address, and waits for a real client request to be sent to that address. This routine is
needed when you want to test the real network path (opening a connection, sending data,
closing the connection), but this lesson does not use it: testing itemHandler’s logic
needs no network path at all, and opening a real port would both slow the measurement
down and add a piece of information — which port happens to be free — that can change
from run to run, exactly the thing this topic forbids writing down. The Recorder and
NewRequest pair tests the same logic without ever paying this cost.
The Verdict of Three Requests
TM7. How many distinct status codes are written in the handler’s source can be
counted from the source: http.StatusBadRequest and http.StatusNotFound are written
explicitly, and a third outcome (http.StatusOK, net/http’s own default when no
status code is written at all) stands implicit, named by no constant — three distinct
outcomes in total can be derived from the source. Which request triggers which
outcome, though, is learned not from the source but by actually calling the handler.
statusCodeCountInSource does this count without using a tool or a run at all: it takes
the handler’s body as a string and checks whether the StatusBadRequest and
StatusNotFound constants appear in it, counting StatusOK itself as an implicit
default since it is never written at all. This is another form of the source-counting
routine set up in the previous two lessons (counting func Test occurrences, counting
lines starting with {") — here what is counted is not a function name or a case row,
but a constant’s name, though the method is the same: read the source as a string and
count how many times a searched pattern occurs in it.
// measurement.go — the same handler is tested with three requests, no real port is ever opened
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Item struct {
Name string
Quantity int
Price int
}
func itemHandler(store map[string]Item) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
it, found := store[name]
if !found {
http.Error(w, "not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "%d", it.Price)
}
}
const handlerSource = `
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
it, found := store[name]
if !found {
http.Error(w, "not found", http.StatusNotFound)
return
}
fmt.Fprintf(w, "%d", it.Price)
`
func statusCodeCountInSource(source string) int {
n := 1 // the default success return with no written constant
for _, s := range []string{"StatusBadRequest", "StatusNotFound"} {
if strings.Contains(source, s) {
n++
}
}
return n
}
func main() {
fmt.Println("-- source: how many distinct status codes are written --")
fmt.Println("status code count written in the handler:", statusCodeCountInSource(handlerSource))
store := map[string]Item{"pen": {"pen", 3, 250}}
handler := itemHandler(store)
fmt.Println()
fmt.Println("-- run: the verdict of three requests --")
request1 := httptest.NewRequest(http.MethodGet, "/item?name=pen", nil)
recorder1 := httptest.NewRecorder()
handler.ServeHTTP(recorder1, request1)
fmt.Println("existing item, status code:", recorder1.Code, "body:", recorder1.Body.String())
request2 := httptest.NewRequest(http.MethodGet, "/item?name=missing", nil)
recorder2 := httptest.NewRecorder()
handler.ServeHTTP(recorder2, request2)
fmt.Println("nonexistent item, status code:", recorder2.Code)
request3 := httptest.NewRequest(http.MethodGet, "/item", nil)
recorder3 := httptest.NewRecorder()
handler.ServeHTTP(recorder3, request3)
fmt.Println("request without a parameter, status code:", recorder3.Code)
}
-- source: how many distinct status codes are written -- status code count written in the handler: 3 -- run: the verdict of three requests -- existing item, status code: 200 body: 250 nonexistent item, status code: 404 request without a parameter, status code: 400
TM8. Even though the source is known to have three distinct status codes written,
which request will trigger which code is only learned once ServeHTTP is called — three
httptest.NewRequest calls each build a client request; three httptest.NewRecorder
values collect these requests’ responses separately, and recorder.Code returns the
status code the handler actually wrote each time. This is the network-layer counterpart
of the same distinction set up in the previous two lessons: how many outcomes could
happen can be counted from the source, which outcome actually happens can only be
learned from the run. In Test Package Basics this distinction was whether a function
passed or failed, in Table-Driven Tests it was how many subtests a case turned into;
here the same skeleton is applied to which status code a request turns into — three
lessons run the same frame on a different surface.
It matters that all three requests pass through the same handler variable — the
handler is built once, then called three times with three different inputs, just as a
real server’s real run has the same handler answering consecutive requests. The
difference is that here these consecutive calls come not from a listening socket but
directly from a loop.
The first request’s body is also read separately: recorder1.Body.String() returns not
only the status code but also the 250 string the handler wrote with fmt.Fprintf —
the inventory package’s decision from earlier lessons to keep the price in cents
carries through here too. The body is not read for the other two requests, because the
text http.Error writes is irrelevant to this measurement’s question; the only thing
asked is the status code, and what http.Error produces is a version-dependent detail
not written in the source. This is a natural extension of the rule from the second and
third measurements not to print the tool’s own output: only the asked measure is read,
everything else is ignored.
The Limiting Measurement: What Is Tested Is Not the Network but the Handler
TM9. The RemoteAddr field of the request httptest.NewRequest produces always
carries the same fixed value: 192.0.2.1:1234. This address is not random — it is a
block RFC 5737 reserves for documentation and example purposes, never routed on any real
network. The httptest package uses this same address on every call, because the
request it produces never actually comes from a real connection; since no connection is
ever made, there is no real remote address to fill in either.
On a real HTTP server, the RemoteAddr field carries the real address of the client
that opened the connection, and it can change on every request, on every run — exactly
the kind of information this topic has said from the start should never be printed.
httptest.NewRequest filling this field with a fixed value is a consequence of the
tool’s own design: since a request does not come from a real connection, there is no
real address to fill it with either, and the package fills this gap not with something
random or empty, but with a fixed value that has no counterpart on any real network. This
choice also implies that if a handler’s logic looks at the RemoteAddr field (an access
restriction, for instance), that logic will always meet the same fixed address under
httptest — itemHandler carries no such logic in this measurement, but this is a
detail worth remembering when a similar handler is tested in the future: even if an
access restriction works correctly in a real deployment, a test written with this test
helper never actually tests that restriction, because the test itself always runs with a
fixed address.
// boundary.go — the request's address is a fixed block reserved for documentation, not a real connection
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
func main() {
request := httptest.NewRequest(http.MethodGet, "/item?name=pen", nil)
isDocumentationAddress := strings.HasPrefix(request.RemoteAddr, "192.0.2.")
fmt.Println("is the request's address in a block reserved for documentation:", isDocumentationAddress)
}
is the request's address in a block reserved for documentation: true
The test helper used in this lesson, then, tests the handler’s logic — whether it produces the correct status code for the input — not the network layer itself. No real network connection was ever made, listened on, or closed here; the test stayed entirely inside the same process, in a chain of function calls. This is not a shortcoming but a deliberate boundary: opening a real socket to test a handler’s own logic is an unnecessary cost and an unnecessary source of flakiness — it adds a failure cause that has nothing to do with the handler’s own logic, such as a port already being used by another process. But this boundary has a cost too: this test can never catch a defect that might show up in a real listener’s setup, in timeouts, or in concurrent connection management, because that layer never runs at all in this measurement.
This separation is also visible in the handler’s own signature: http.HandlerFunc is an
ordinary Go function taking an http.ResponseWriter and a *http.Request — it takes
neither a socket descriptor nor a network context. All the handler sees is these two
values, and the test helpers are exactly a way of producing these two values without
ever making a real connection. This is why it is not possible to tell, from a handler’s
own body, whether it was called from the network or from httptest — the handler never
sees the difference between the two, it only works with the http.ResponseWriter and
*http.Request pair it is given. This is the source of the handler’s testability: as
long as the interface itself (these two types) stays fixed, what stands behind it (a
real connection or a recording structure) makes no difference in the handler’s eyes. The
next lesson carries this same observation one step further — this time not the handler
itself but a dependency it calls is moved behind an interface.
Summary
httptest.NewRequestis a client-side helper,httptest.NewRecordera server-side one; together they test a handler directly with a function call, without opening a real port.- The status code count in the handler (three) can be counted from the source, but which
request triggers which code can only be learned by calling
ServeHTTP. - The same
handlervariable was called back to back with three different requests; all three verdicts were read as run information, the address and port were never printed anywhere. - The limiting measurement: the address
httptest.NewRequestproduces sits in a fixed block RFC 5737 reserves for documentation — proof that what is tested is not the network but the handler itself. - This boundary is not a shortcoming but a deliberate choice, and it has a cost too: defects tied to a real listener’s behavior never show up in this test at all.
httptest.NewServercan open a real port, but this lesson did not use it; testing the handler’s logic needed no real network path at all, so this cost was excluded from the start.
Next Step
This lesson measured that a handler’s behavior is tested by calling it directly, without
ever opening a real connection: how many distinct outcomes were written in the source
could be counted, which request produced which outcome was only learned by running it,
and that the request itself did not come from a real network was verified by reading its
own address. But the handler itself still used a fixed dictionary built by hand inside
the main function — in a real production program this data would come from an
external dependency, a storage layer for instance, and that layer might be too slow or
too unreliable to actually run in every test. The next lesson moves this dependency
behind an interface and measures how the same test body runs with a real and a fake
implementation — showing that what makes the fake possible is the interface being
written in the source.
To keep your progress and take notes, Log in
My notes
Log in to take notes.