Testing
muzak.dev/framework/testclient serves an application in-process and issues real requests against it,
so a test exercises the whole stack rather than any one part of it in isolation.
package handlers_test
import (
"net/http"
"testing"
"muzak.dev/framework/testclient"
)
func TestReadItem(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Get("/items/foo", testclient.Header("X-Token", "coneofsilence"))
res.AssertStatus(http.StatusOK)
res.AssertJSON(`{"id":"foo","title":"Foo","description":"There goes my hero"}`)
}
Requests travel over an in-memory network rather than a real socket, so a test needs no free port and cannot be disturbed by anything else on the machine.
What New does
client := testclient.New(t, buildApp())
The application is built, its lifecycle components are started, and both the server and
those components are released through t.Cleanup when the test finishes. A build failure
or a component that refuses to start fails the test immediately, because every later
assertion would be meaningless.
A cookie jar is enabled by default, which lets a login followed by an authenticated call work the way it would in a browser.
| Client option | Effect |
|---|---|
WithHeader(name, value) | A header sent with every request, so a token is not repeated on each call |
WithTimeout(d) | Bounds a single request, defaulting to ten seconds |
WithoutCookies() | Disables the jar, so each request is independent |
WithoutRedirects() | Stops the client following redirects, so a test can assert on the 3xx itself |
Building the application under test
Write a helper that returns the application, and quiet its logger.
// buildApp returns an application with a shared secret guard and a small
// in-memory store.
func buildApp() *muzak.App {
store := core.NewItemStore()
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Version: "1.0.0",
LoggerOptions: muzak.LoggerOptions{Format: muzak.LogFormatNone},
},
muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", "coneofsilence")),
muzak.WithSingleton(store, store.Lifecycle()),
)
app.Include(routers.Items())
return app
}
// authorized is the header every request in this suite needs.
func authorized() testclient.Option {
return testclient.WithHeader("X-Token", "coneofsilence")
}
Because main does nothing but compose, the same routers, handlers and dependencies the
real service uses are what the test exercises.
Issuing requests
client.Get("/items/foo")
client.Post("/items/", testclient.JSON(item))
client.Put("/items/foo", testclient.JSON(rename))
client.Patch("/items/foo", testclient.JSON(patch))
client.Delete("/items/foo")
client.Head("/items/foo")
client.Options("/items/")
client.Do(http.MethodPropfind, "/items/")
| Request option | Effect |
|---|---|
JSON(value) | Encodes the value as a JSON body and sets Content-Type |
RawJSON(body) | Sends the body verbatim, for JSON a Go value could not produce |
Body(contentType, r) | An arbitrary body under a given content type |
Header(name, value) | Sets a header on this request, replacing any client-level value |
Query(name, value) | Adds a query parameter, repeatable to send several values |
Cookie(c) | Sends a cookie in addition to whatever the jar holds |
Subprotocols(...) | Offers WebSocket subprotocols on a WS call |
KeepComments() | Delivers an event stream's comment lines as messages of their own |
Asserting on a response
func TestReadNonexistentItem(t *testing.T) {
client := testclient.New(t, buildApp(), authorized())
res := client.Get("/items/baz")
res.AssertStatus(http.StatusNotFound)
if got := res.Error().Error.Message; got != "Item not found" {
t.Errorf("message = %q", got)
}
}
| Assertion | Checks |
|---|---|
AssertStatus(want) | The status code |
AssertJSON(want) | The body, compared semantically so member order and whitespace do not matter |
AssertHeader(name, want) | One response header |
AssertErrorCode(want) | The code of an error envelope, such as validation_error |
Every assertion returns the response, so they chain:
client.Get("/items/foo").
AssertStatus(http.StatusOK).
AssertHeader("Content-Type", "application/json; charset=utf-8").
AssertJSON(`{"id":"foo","title":"Foo","description":"There goes my hero"}`)
A Response carries Status, Header, Body and Cookies directly, is read fully into
memory so it can be inspected more than once, and offers RequestID(), String(),
JSON(target) and Error().
Decoding into a typed value
item := client.Get("/items/foo").Decode[ItemOut]()
if item.Title != "Foo" {
t.Errorf("title = %q, want %q", item.Title, "Foo")
}
The type argument is written at the call site, which keeps the expected shape visible in
the test and checked by the compiler. testclient.Decoded[ItemOut](client.Get("/items/foo"))
is the free-function form, for chaining directly off a request.
Testing failures
func TestCreateExistingItem(t *testing.T) {
client := testclient.New(t, buildApp(), authorized())
res := client.Post("/items/", testclient.RawJSON(
`{"id":"foo","name":"The Foo ID Stealers"}`))
res.AssertStatus(http.StatusConflict)
res.AssertErrorCode(muzak.CodeConflict)
}
func TestReadItemBadToken(t *testing.T) {
client := testclient.New(t, buildApp())
res := client.Get("/items/foo", testclient.Header("X-Token", "hailhydra"))
res.AssertStatus(http.StatusUnauthorized)
res.AssertErrorCode(muzak.CodeUnauthorized)
}
RawJSON is what tests a body a Go value could not produce, such as a duplicate member, an
unknown member or a malformed document:
res := client.Post("/items/", testclient.RawJSON(`{"id":"foo","nmae":"Foo"}`))
res.AssertStatus(http.StatusUnprocessableEntity)
if got := res.Error().Error.Details[0].Issue; got != "is not a field this endpoint accepts" {
t.Errorf("issue = %q", got)
}
Sessions and cookies
The jar carries whatever a login established, so a flow reads the way it happens.
func TestLoginThenRead(t *testing.T) {
client := testclient.New(t, buildApp())
form := url.Values{"username": {"muzak"}, "password": {"correct-horse-battery"}}
client.Post("/login/", testclient.Body(
"application/x-www-form-urlencoded", strings.NewReader(form.Encode()))).
AssertStatus(http.StatusOK)
client.Get("/feed").AssertStatus(http.StatusOK)
}
WebSockets
func TestItemSocket(t *testing.T) {
client := testclient.New(t, buildApp())
conn := client.WS("/items/plumbus/ws", testclient.Query("token", "jessica"))
if err := conn.WriteText(t.Context(), "hello"); err != nil {
t.Fatalf("WriteText = %v", err)
}
reply, err := conn.ReadText(t.Context())
if err != nil {
t.Fatalf("ReadText = %v", err)
}
if !strings.Contains(reply, "jessica") {
t.Errorf("reply = %q", reply)
}
}
Client.WS fails the test if the handshake is refused. The connection is closed when the
test finishes, so a handler blocked on a read is released even if the test forgets.
To assert on a handshake that is meant to be refused, use TryWS, which returns whatever
came back:
_, response := client.TryWS("/items/plumbus/ws")
response.AssertStatus(http.StatusUnauthorized)
The connection is a real *muzak.WSConn, driven by the same engine the server uses, so a
route is tested end to end rather than against a second implementation.
Server-sent events
func TestStreamItems(t *testing.T) {
client := testclient.New(t, buildApp())
stream := client.SSE("/items/stream", testclient.Query("token", "jessica"))
client.Post("/items/", testclient.Query("token", "jessica"),
testclient.JSON(map[string]string{"id": "plumbus", "name": "Plumbus"})).
AssertStatus(http.StatusCreated)
item := stream.Decode[ItemOut]()
if item.ID != "plumbus" {
t.Errorf("id = %q, want plumbus", item.ID)
}
}
| Call | Effect |
|---|---|
stream.Next() | The next event, failing the test if the stream ends or fails first |
stream.Decode[T]() | The next event's data decoded into T |
stream.TryNext() | The next event and whatever ended the stream, for a test that expects an ending |
stream.LastEventID() | The identifier of the last event that carried one |
stream.Close() | Ends the stream, which is how a test checks that the handler notices |
stream.Response | The status, headers and cookies the stream opened with |
Reads are bounded by the client's timeout, so a stream that never sends the event a test is waiting for fails the test rather than hanging it.
_, err := stream.TryNext()
if !errors.Is(err, muzak.ErrSSEStreamEnded) {
t.Fatalf("err = %v, want the stream to have ended", err)
}
Client.SSEDo opens a stream with another method, which is what a stream answering a
posted document takes, and Client.TrySSE is the counterpart of TryWS:
stream := client.SSEDo(http.MethodPost, "/chat/stream", testclient.JSON(prompt))
_, response := client.TrySSE(http.MethodGet, "/items/stream")
response.AssertStatus(http.StatusServiceUnavailable)
Resuming a dropped stream is a header:
resumed := client.SSE("/items/stream", testclient.Header("Last-Event-ID", stream.LastEventID()))
Testing a handler on its own
A handler is an ordinary typed function, so a unit test can call it directly with a
constructed input, with no router, server or HTTP request in sight. That works whenever the
handler reads nothing from the context. A handler that calls muzak.From needs the
dependency, which is what the test client provides.
Validation rules are testable the same way, without a request at all:
if err := validateTag().Check("Not A Tag"); err == nil {
t.Error("want a spaced tag to be rejected")
}
if err := NotACommonPassword("password1234"); err == nil {
t.Error("want a common password to be rejected")
}
Catching configuration errors in a test
func TestAppBuilds(t *testing.T) {
if err := buildApp().Build(); err != nil {
t.Fatalf("Build = %v", err)
}
}
Build reports every route problem at once: a duplicate path, an unbindable input type, a
path parameter no field binds, a duplicate operation identifier. One test keeps all of them
out of a deployment.
Concurrency
A Client is safe for concurrent use, which lets a test fire parallel requests to check
that request-scoped state stays isolated.
func TestConcurrentRequestsStayIsolated(t *testing.T) {
client := testclient.New(t, buildApp(), authorized())
var wg sync.WaitGroup
for i := range 50 {
wg.Add(1)
go func() {
defer wg.Done()
client.Get("/items/foo").AssertStatus(http.StatusOK)
_ = i
}()
}
wg.Wait()
}
go test ./... -race
Where to go next
Lifecycle covers the components the client starts for you, and Configuration covers pinning settings a test depends on.