tinka alpha
tinkaalpha

Test the App

This chapter writes tests for everything the App does so far. You will learn how to:

Overview

A Tinka App's tests are Go tests: TestXxx functions in _test.go files, run by go test. The skeleton adds two helpers. tests.App(t) builds the App the way main does, in the test Mode, with the values in .env.test. tinkatest.New starts that App and returns a client whose requests go straight to the App's handler, without a network, so each one takes microseconds.

Each test builds its own App, and each App has its own container, so each test gets its own store. A post one test creates never shows up in another, and every test can run in parallel with t.Parallel().

Test the routes

act make:test creates an HTTP test in tests/functional:

Terminal
act make:test posts
Output
CREATE tests/functional/posts_test.go

The generated test requests GET /posts and checks the status. Replace it with tests for each route. Send(t) sends a request and ties the response to the test, so a failed assertion reports the line that sent it, and each assertion returns the response, so they chain:

Subtests are named as sentences that say what the route does, so a failure reads as the behavior that broke:

tests/functional/posts_test.go
package functional_test

import (
	"net/http"
	"testing"

	"github.com/mucolabs/tinka/tinkatest"

	"blog/app/posts"
	"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, Tinka").
		AssertJSONPath("1.title", "Routes and handlers")
}

func TestShowPost(t *testing.T) {
	t.Parallel()

	t.Run("answers with the post", func(t *testing.T) {
		t.Parallel()
		c := tinkatest.New(t, tests.App(t))
		c.Get("/posts/1").Send(t).
			AssertStatus(http.StatusOK).
			AssertJSON(posts.Post{ID: 1, Title: "Hello, Tinka", Body: "The first post on the blog."})
	})

	t.Run("answers 404 for a post that doesn't exist", func(t *testing.T) {
		t.Parallel()
		c := tinkatest.New(t, tests.App(t))
		c.Get("/posts/99").Send(t).AssertProblem(http.StatusNotFound, "not_found")
	})
}

func TestCreatePost(t *testing.T) {
	t.Parallel()

	t.Run("answers 201 with the new post", func(t *testing.T) {
		t.Parallel()
		c := tinkatest.New(t, tests.App(t))
		c.Post("/posts").JSON(map[string]string{"title": "Testing", "body": "With tinkatest."}).Send(t).
			AssertStatus(http.StatusCreated).
			AssertJSONPath("id", 3)
		c.Get("/posts/3").Send(t).AssertJSONPath("title", "Testing")
	})

	t.Run("refuses a post without a title or a body", func(t *testing.T) {
		t.Parallel()
		c := tinkatest.New(t, tests.App(t))
		c.Post("/posts").JSON(map[string]string{"title": " "}).Send(t).
			AssertInvalid("title", "body")
	})
}

The create test sends a second request with the same client, which reaches the same App, so it proves the post was stored, not only returned.

Test the Command

The clitest package runs Commands in a test, in the same process, the way act runs them. app.Commands() returns the App's Commands, and Run runs one with its arguments and returns its exit code, its stdout and its stderr.

To prove the Command lists what the container holds, the test gives the App a store of its own with a third post. tinkatest.Fake makes every *posts.Store the App resolves be the one you pass, until the test ends. Call it before the App starts: the Command starts the App when it runs. People's output ends with a newline after the result's String, so the expected text ends with one too.

Tests of one package go next to it, so create the file in app/commands. It's in the commands_test package, which can import blog/tests even though that package imports app/commands itself:

app/commands/posts_list_test.go
package commands_test

import (
	"testing"

	"github.com/mucolabs/tinka/cli/clitest"
	"github.com/mucolabs/tinka/tinkatest"

	"blog/app/posts"
	"blog/tests"
)

func TestPostsList(t *testing.T) {
	t.Parallel()
	store := posts.NewStore()
	if _, err := store.Create(t.Context(), "Testing", "Commands too."); err != nil {
		t.Fatal(err)
	}
	app := tests.App(t)
	tinkatest.Fake(t, app, store)

	r := clitest.New(t, app.Commands()).Run("posts:list")
	want := "   1  Hello, Tinka\n   2  Routes and handlers\n   3  Testing\n"
	if r.Code != 0 || r.Stdout != want {
		t.Errorf("exit %d, stdout %q, stderr %q", r.Code, r.Stdout, r.Stderr)
	}
}

Run the tests

act test runs every test of the App, and prints the go test command it runs first:

Terminal
act test
Output
$ GOWORK=off go test -json ./...
?   	blog	[no test files]
?   	blog/app/exceptions	[no test files]
?   	blog/app/handlers	[no test files]
?   	blog/app/posts	[no test files]
?   	blog/app/providers	[no test files]
?   	blog/config	[no test files]
?   	blog/start	[no test files]
?   	blog/tests	[no test files]
ok  	blog/app/commands	0.019s
ok  	blog/tests/functional	0.017s
9 tests passed in 10 packages

The nine tests are TestHome, which came with the skeleton, the three test functions and four subtests in posts_test.go, and TestPostsList. The tests are Go tests, so go test runs them the same way, in your editor and in CI:

Terminal
go test ./...
Output
?   	blog	[no test files]
ok  	blog/app/commands	0.008s
?   	blog/app/exceptions	[no test files]
?   	blog/app/handlers	[no test files]
?   	blog/app/posts	[no test files]
?   	blog/app/providers	[no test files]
?   	blog/config	[no test files]
?   	blog/start	[no test files]
?   	blog/tests	[no test files]
ok  	blog/tests/functional	0.009s

Flags after act test go to go test, so act test -run TestCreatePost ./tests/functional runs one test, and act test --watch runs the tests again on every change.

Read a failure

A test's failure message should say what broke without opening the test. To see one, make the validation test also expect a tags field, which the App never reports:

tests/functional/posts_test.go
		c.Post("/posts").JSON(map[string]string{"title": " "}).Send(t).
			AssertInvalid("title", "body")
			AssertInvalid("title", "body", "tags")

Run the tests again:

Terminal
act test
Output
$ GOWORK=off go test -json ./...
?   	blog	[no test files]
ok  	blog/app/commands	(cached)
?   	blog/app/exceptions	[no test files]
?   	blog/app/handlers	[no test files]
?   	blog/app/posts	[no test files]
?   	blog/app/providers	[no test files]
?   	blog/config	[no test files]
?   	blog/start	[no test files]
?   	blog/tests	[no test files]
--- FAIL: TestCreatePost (0.00s)
    --- FAIL: TestCreatePost/refuses_a_post_without_a_title_or_a_body (0.00s)
        posts_test.go:56: POST /posts answered 422 Unprocessable Entity
            body: {"title":"Unprocessable Entity","status":422,"detail":"Some fields are invalid.","code":"invalid","fields":{"body":"Write the post's body.","title":"Enter a title."},"request_id":"WGZ6KP2QLCBOEGUFSJ5ODB74XL"}
            want errors for the fields title, body, tags, but there is none for tags
FAIL
FAIL	blog/tests/functional	0.011s
Error: 1 test failed.
  Fix: Run act test --failed to run only them again

The message names the request, the status and the body the App sent, then what the test wanted: the body has no tags field. blog/app/commands came from go test's cache, since nothing it depends on changed. Remove "tags" again, and run only the test that failed:

Terminal
act test --failed
Output
$ GOWORK=off go test -json -run ^(TestCreatePost)$ blog/tests/functional
ok  	blog/tests/functional	0.009s
3 tests passed in 1 package

Recap

tests/functional/posts_test.go tests each route through HTTP with tinkatest, and app/commands/posts_list_test.go runs posts:list with clitest, with a store of its own given by tinkatest.Fake. Each test builds its own App, so they all run in parallel. Next, Deploy builds the App for production.

See also: Testing, HTTP tests, Testing Commands, Test doubles