Muzak logomuzak
v0.1.10

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 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

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 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
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)
    }
}
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.

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)
    }
}
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 event that carried one
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, 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.

Open source under MIT / Apache-2.0 · sustained by the people who ship on it.Built on net/http, and nothing else.