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"}`)
}
The application is served on a loopback port the operating system chooses, by the same server
and ServerOptions that Run uses, so a request production refuses (a 100 KB header, say) is
refused in a test too. It speaks plain HTTP whatever TLS is configured.
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.
The cleanup shuts the application down as App.Shutdown does: it reports the application
unready to readiness probes and waits ServerOptions.DrainDelay, ends the event streams and
WebSockets and waits for the requests still running, waits for the background tasks registered
with ctx.AfterResponse, and then stops the components. A test may
call app.Shutdown itself to assert on what a shutdown does, and the cleanup then has nothing
left to do. An application is served by one client at a time, as it is run by one run method at
a time, so build one App per client. A single client is safe for concurrent use.
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 |
Override(provide), OverrideAcquire(provide) | Replaces every provider of one type before the application is built. See Replacing a dependency |
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.
Replacing a dependency
A test that should not reach the real database, clock or caller replaces the provider of that type, and leaves the routes that use it alone:
client := testclient.New(t, buildApp(),
testclient.Override(func(*muzak.Context) (CurrentUser, error) {
return CurrentUser{Username: "alice"}, nil
}))
Every provider of exactly that type in the application is replaced: on every route, on a
handler given to Mount, on a file mount and on the documentation. The replacement keeps the
lifetime of the provider it stands in for, so an overridden singleton is still computed once.
Guards are not providers and still run. An override for a type nothing provides fails the test
with the application's build error, which is what catches a test overriding CurrentUser
where the application provides *CurrentUser.
testclient.OverrideAcquire does the same for a fake that has to be released, and
App.Override and App.OverrideAcquire are the same calls made on the application before it
is built. Dependencies covers them with the providers
they replace.
A security scheme built by muzak.JWTBearer or muzak.APIKeyVerifier is not a provider, so
no override lets a request past it: like a guard, it refuses in a test exactly as it does for
a client. A test that wants to be let in presents a credential, such as a token it signed
with a key the test configured. Authentication shows how.
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. A Host header sets the host the request is sent for, which is how a test reaches host-based routing |
Query(name, value) | Adds a query parameter, repeatable to send several values |
Cookie(c) | Sends a cookie in addition to whatever the jar holds. Only the name and value are sent, so a cookie from res.Cookies can be passed back as it is |
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.
Calling a typed endpoint
An operation declared with muzak.NewEndpoint can be tested the way its Go callers use it,
through Endpoint.Call against the application under test:
func TestGetItemEndpoint(t *testing.T) {
server := testclient.New(t, buildApp())
items := muzak.NewClient(muzak.ClientOptions{
AllowPrivateNetworks: true,
BaseURL: server.URL(),
})
item, err := itemapi.GetItem.Call(t.Context(), items, itemapi.GetItemIn{ID: "foo"},
muzak.CallHeader("X-Token", "coneofsilence"))
if err != nil {
t.Fatalf("Call = %v", err)
}
if item.Title != "Foo" {
t.Errorf("title = %q, want %q", item.Title, "Foo")
}
}
The test client serves on a loopback address, which a muzak.Client refuses unless it is
built with AllowPrivateNetworks. The same holds for a test that points code under test at
an httptest.Server: build the client that code uses from options the test can set, rather
than relaxing the client production uses. Typed Endpoints
covers what a call writes and returns, and HTTP Client covers
the address policy.
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)
}
Testing another language
A locale is chosen by a header, so testing one is a header on the request. The response reports what it answered in, which is worth asserting alongside the body: a test that only checks the words passes for the right reason and for the wrong one.
func TestRejectionInSpanish(t *testing.T) {
client := testclient.New(t, buildApp(t))
res := client.Post("/items/",
testclient.Header("Accept-Language", "es"),
testclient.RawJSON(`{"name":""}`))
res.AssertStatus(http.StatusUnprocessableEntity)
res.AssertHeader("Content-Language", "es")
if got := res.Error().Error.Details[0].Issue; got != "es obligatorio" {
t.Errorf("issue = %q", got)
}
}
An application that has not been localized needs no change to its tests. With no store configured every message reads exactly as it did, which is the property worth having a test of its own for.
Sessions and cookies
The jar carries whatever a sign-in established, so a flow reads the way it happens. The
example application signs in with a session: POST /login/
regenerates the session, records who signed in and answers with the username, and
POST /logout/ destroys the session.
func TestSignInThenOut(t *testing.T) {
client := testclient.New(t, buildApp(), authorized())
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).
AssertJSON(`{"username":"muzak"}`)
// The jar sends the session cookie with every request from here on.
client.Post("/logout/").AssertStatus(http.StatusOK)
}
The session itself never appears in a response body, only in its cookie, so a test asserts on what a signed-in request is allowed to do rather than on a session identifier.
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. Closing
waits up to CloseGracePeriod (250 ms) for the server to hang up first, so a handler that is not
reading when the test ends costs that much at cleanup.
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 complete event that carried one; an event cut off part way leaves it where it was |
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 or a template that names one twice, a duplicate operation
identifier, two mounts at one path, a certificate without its key, a Nested that would
validate nothing, an unreachable location tag, and a category, title or security declaration
that cannot be written to the document. One test keeps all of them out of a deployment.
Configure the application before the first call to Build, Document, ServeHTTP or a run
method. Use, Include, route registration and the mounts panic when called afterwards,
naming the call, so a helper that builds the application for a test and then adds a guard fails
the test instead of quietly running without it.
Catching a breaking change in a test
An application that builds can still break its clients, by removing an operation or adding a required member to a request. One more test compares the document the application generates with a baseline committed beside it:
func TestAPICompatibility(t *testing.T) {
testclient.AssertCompatible(t, buildApp(), "testdata/openapi.json")
}
A breaking change fails the test and lists what breaks, and a change that may break some
clients is logged for a person to judge. Run it once with MUZAK_UPDATE_OPENAPI=1 to record
the baseline, and again whenever a change it reports is intended.
API Compatibility covers how each change is judged,
and muzak diff in the command line makes the same
comparison between two files for a CI step outside the test suite.
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.