Test the App
This chapter writes tests for everything the App does so far. You will learn how to:
- Send requests to the App in a test with
tinkatest - Assert on statuses, JSON bodies, problem details and invalid fields
- Run a Command in a test with
clitest - Give a test its own version of a service with
tinkatest.Fake - Run the tests with
act testandgo test
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:
act make:test postsCREATE tests/functional/posts_test.goThe 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:
AssertStatuschecks the status code.AssertJSONPathchecks one value in a JSON body, by keys and indexes joined with dots:0.titleis the first post's title.AssertJSONchecks the whole body against a value, here aposts.Post, which it compares as JSON.AssertProblemchecks a problem details error by its status and itscode.AssertInvalidchecks for a422that names each of the fields.
Subtests are named as sentences that say what the route does, so a failure reads as the behavior that broke:
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:
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:
act test$ 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 packagesThe 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:
go test ./...? 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.009sFlags 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:
c.Post("/posts").JSON(map[string]string{"title": " "}).Send(t).
AssertInvalid("title", "body")
AssertInvalid("title", "body", "tags")Run the tests again:
act test$ 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 againThe 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:
act test --failed$ GOWORK=off go test -json -run ^(TestCreatePost)$ blog/tests/functional
ok blog/tests/functional 0.009s
3 tests passed in 1 packageRecap
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