You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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 optionEffect
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 optionEffect
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)
    }
}
AssertionChecks
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)
    }
}
CallEffect
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.ResponseThe 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.