# HTTP tests

> Send requests to the App in a test with tinkatest, assert on statuses, JSON, cookies and redirects, and test what depends on time.

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.

```go title="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)
```

::::options

:::option{name="Header" type="func(key, value string)"}
Sets a request header.
:::

:::option{name="Query" type="func(key, value string)"}
Adds a query string parameter.
:::

:::option{name="JSON" type="func(v any)"}
Sends `v`, encoded as JSON.
:::

:::option{name="Form" type="func(values url.Values)"}
Sends a URL-encoded form, or the fields of a multipart form with `File`.
:::

:::option{name="File" type="func(field, filename string, content []byte)"}
Adds a file, as a browser's file input does: the request becomes a multipart form.
:::

:::option{name="Body" type="func(contentType string, body io.Reader)"}
Sends a body as it is.
:::

:::option{name="Cookie" type="func(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.

::::options

:::option{name="AssertStatus" type="func(code int)"}
The status code.
:::

:::option{name="AssertJSONPath" type="func(path string, want any)"}
One value in a JSON body, by keys and indexes joined with dots, such as `data.0.title`.
:::

:::option{name="AssertProblem" type="func(status int, code string)"}
A [problem details](../basics/exception-handling.md) error, such as `404` and `not_found`.
:::

:::option{name="AssertInvalid" type="func(fields ...string)"}
A `422` naming each of the fields that failed validation.
:::

:::option{name="AssertContains" type="func(text string)"}
Text the body contains, such as what a page shows.
:::

:::option{name="AssertHeader" type="func(key, want string)"}
A header's value. `AssertHeaderMissing` checks there's none.
:::

:::option{name="AssertRedirect" type="func(location string)"}
A redirect, to `location`.
:::

:::option{name="AssertCookie" type="func(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.
:::

:::option{name="AssertJSON" type="func(want any)"}
The whole JSON body. Keep it for bodies whose every field is the contract.
:::

:::option{name="AssertGolden" type="func(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:

```text title="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:

```go title="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:

```go title="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.

:::warning
Sleeping in a bubble moves time, but doesn't wait for work in the background, such as an event listener the request started. Call `synctest.Wait()` before asserting on such work: it returns once every goroutine of the test is blocked. Never call `App.Serve` inside a bubble: it listens on a real network, which the bubble doesn't allow. `tinkatest.New` starts the App without serving it, so it works there.
:::

## 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](introduction.md), [Test doubles](test-doubles.md), [Exception handling](../basics/exception-handling.md)
