HTTP tests
This guide covers testing the App through HTTP with tinkatest. You will learn how to:
- Send requests with headers, query strings, JSON, forms, files and cookies
- Assert on the response, and read it
- Test logins, cookies and redirects across requests
- Test what depends on time, such as a cookie that expires
- Drive a deployed App with the same tests
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.
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):
c.Post("/posts").
Header("Accept-Language", "fr").
Query("draft", "true").
JSON(post{Title: "Hello"}).
Send(t)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.
For anything else, read the response and use plain Go. JSON[T] decodes the body, failing the test when it doesn't decode:
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:
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 OKTest 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:
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:
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:
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:
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:
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