tinka alpha
tinkaalpha

HTTP tests

This guide covers testing the App through HTTP with tinkatest. You will learn how to:

Overview

The tinkatest package (github.com/mucolabs/tinka/tinkatest) tests an App the way a browser uses it, without a network: tinkatest.New(t, app) starts the App and returns a client whose requests go straight to the App's handler, as if over HTTPS. A request costs a few microseconds, so a test suite of hundreds of HTTP tests runs in well under a second.

tests/functional/posts_test.go
package functional_test

import (
	"net/http"
	"testing"

	"github.com/mucolabs/tinka/tinkatest"

	"blog/tests"
)

func TestListPosts(t *testing.T) {
	t.Parallel()
	c := tinkatest.New(t, tests.App(t))
	c.Get("/posts").Send(t).
		AssertStatus(http.StatusOK).
		AssertJSONPath("0.title", "Hello")
}

Send(t) sends the request and ties the response to the test, so its assertions report a failure against the line that sent it. Each assertion returns the response, so they chain.

Send requests

The client has a method per HTTP method: Get, Post, Put, Patch, Delete and Head, and Request for any other. Build the request, then Send(t):

go
	c.Post("/posts").
		Header("Accept-Language", "fr").
		Query("draft", "true").
		JSON(post{Title: "Hello"}).
		Send(t)
Headerfunc(key, value string)

Sets a request header.

Queryfunc(key, value string)

Adds a query string parameter.

JSONfunc(v any)

Sends v, encoded as JSON.

Formfunc(values url.Values)

Sends a URL-encoded form, or the fields of a multipart form with File.

Filefunc(field, filename string, content []byte)

Adds a file, as a browser's file input does: the request becomes a multipart form.

Bodyfunc(contentType string, body io.Reader)

Sends a body as it is.

Cookiefunc(name string, v any)

Sends a signed cookie, as Ctx.SetCookie sets. EncryptedCookie and PlainCookie send the other kinds.

Assert on the response

Assert what the test is about, and nothing more: a test that checks every field breaks each time someone adds one, though nothing broke.

AssertStatusfunc(code int)

The status code.

AssertJSONPathfunc(path string, want any)

One value in a JSON body, by keys and indexes joined with dots, such as data.0.title.

AssertProblemfunc(status int, code string)

A problem details error, such as 404 and not_found.

AssertInvalidfunc(fields ...string)

A 422 naming each of the fields that failed validation.

AssertContainsfunc(text string)

Text the body contains, such as what a page shows.

AssertHeaderfunc(key, want string)

A header's value. AssertHeaderMissing checks there's none.

AssertRedirectfunc(location string)

A redirect, to location.

AssertCookiefunc(name string, want any)

A signed cookie the response sets, read with the App's key. AssertEncryptedCookie reads an encrypted one, and AssertCookieMissing checks there's none.

AssertJSONfunc(want any)

The whole JSON body. Keep it for bodies whose every field is the contract.

AssertGoldenfunc(path string)

The whole body, against a file. Run the test with TINKA_UPDATE=1 to write the file, and review the change before committing it.

For anything else, read the response and use plain Go. JSON[T] decodes the body, failing the test when it doesn't decode:

go
	posts := c.Get("/posts").Send(t).JSON[[]post]()
	if len(posts) != 2 || posts[0].Title != "Hello" {
		t.Errorf("GET /posts = %+v, want two posts, Hello first", posts)
	}

Text returns the body as a string, and Cookie[T], EncryptedCookie[T] and PlainCookie the cookies the response sets. A failed assertion prints the request, the status and the start of the body, so the reason is in the message:

Output
posts_test.go:15: GET /posts/1 answered 404 Not Found
    body: {"title":"Not Found","status":404,"detail":"There is nothing at /posts/1.","code":"not_found","request_id":"ZAQ4S6CY77N7VFBUX6VQOAMDTB"}
    want status 200 OK

Test a journey

The client keeps the cookies the App sets, as a browser does, so a test can log in and go on as that user. It doesn't follow redirects: assert on one, then Follow it:

tests/functional/login_test.go
func TestLogin(t *testing.T) {
	t.Parallel()
	c := tinkatest.New(t, tests.App(t))

	c.Get("/dashboard").Send(t).AssertRedirect("/login")

	c.Post("/login").Form(url.Values{"email": {"ada@example.com"}, "password": {"secret"}}).Send(t).
		AssertRedirect("/dashboard").
		AssertCookie("session", session{UserID: 1}).
		Follow().
		AssertContains("Welcome, Ada")
}

Each request in a journey asserts what that step proves. A step that only sets up the next, such as logging in before the test of publishing, needs no assertions of its own beyond the status.

To test as a user without logging in, send the cookie a login would set: c.Get("/dashboard").Cookie("session", session{UserID: 1}).Send(t).

Signed URLs work as they are, since the client reaches the App that signed them:

go
	app := tests.App(t)
	c := tinkatest.New(t, app)
	c.Get(app.SignedURL("newsletter.unsubscribe", time.Hour, 7)).Send(t).AssertStatus(http.StatusOK)

Test what depends on time

Run a test inside testing/synctest's bubble to control time: inside it, time is fake, and moves only when every goroutine of the test waits, so sleeping two hours takes no time at all:

tests/functional/session_test.go
func TestSessionExpires(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		c := tinkatest.New(t, tests.App(t))
		c.Post("/login").Form(credentials).Send(t).AssertRedirect("/dashboard")
		time.Sleep(2*time.Hour + time.Second) // the cookie's Max-Age
		c.Get("/dashboard").Send(t).AssertRedirect("/login")
	})
}

The App's timeouts, cached health checks, and the expiry of cookies and signed URLs all use fake time inside the bubble, unchanged.

Stream responses with a real server

The client hands each request to the App and reads the whole response, which covers nearly every test. A response streamed as it's written, such as server-sent events, a WebSocket, or a test of HTTP/2, needs a real server: tinkatest.NewServer starts the App behind one, on a local port, and returns the same client:

go
	c := tinkatest.NewServer(t, tests.App(t))
	c.Get("/events").Send(t).AssertHeader("Content-Type", "text/event-stream")

Drive a deployed App

For tests that check a whole feature as its users see it, keep what the test checks apart from how it talks to the App: a specification that calls a driver, and a driver that sends requests. The client's HTTPClient and BaseURL give a driver what it needs to reach the App in process, so the same specification can run against a deployed App with a driver of its own:

go
type Driver struct {
	BaseURL string
	Client  *http.Client
}

// In process:
c := tinkatest.New(t, tests.App(t))
specification(t, Driver{BaseURL: c.BaseURL(), Client: c.HTTPClient()})

// Against staging:
specification(t, Driver{BaseURL: "https://staging.blog.example.com", Client: http.DefaultClient})

Only the driver knows URLs and JSON shapes; the specification reads as the feature does.

See also: Testing, Test doubles, Exception handling