Search titles and sections of every page. ↑↓ to move, ↵ to open

Tutorial

8. Tests

1 min read Edit on GitHub

Tern apps are tested with go test. Tern's kittest package runs the whole app in the test, and requests its pages and posts its forms as a browser would, keeping cookies between requests. Pages render as they do in production.

#An app for each test

Each test should start from an empty database. Move the app's setup out of main into a function the tests can call with a path of their own. main.go:

package main

import (
	"context"
	"log"
	"os"

	"github.com/Unfield/Tern/kit"
	"github.com/Unfield/Tern/migrate"

	"shelf/internal/shelf"
	"shelf/migrations"
)

func main() {
	path := os.Getenv("DATABASE")
	if path == "" {
		path = "shelf.db"
	}
	app, err := newApp(path)
	if err != nil {
		log.Fatal(err)
	}
	if err := app.ListenAndServe(); err != nil {
		log.Fatal(err)
	}
}

// newApp opens the database at path and returns the app. The tests call
// it too, each with a database of its own.
func newApp(path string) (*kit.App, error) {
	if err := shelf.Open(context.Background(), path); err != nil {
		return nil, err
	}
	// ternApp is generated by tern in tern.gen.go, from src/routes.
	return ternApp(kit.WithPlugins(
		migrate.Plugin(shelf.DB, migrations.FS),
		shelf.Accounts,
	))
}

#The tests

Replace main_test.go, which still tests the greeting from step 1:

package main

import (
	"net/url"
	"path/filepath"
	"testing"

	"github.com/Unfield/Tern/kit/kittest"
)

// These tests run the whole app, as a browser would use it: npm test, or
// go tool tern build && go test ./...

// signedIn starts the app on an empty database and returns a client
// signed in as a new user.
func signedIn(t *testing.T) *kittest.Client {
	t.Helper()
	app, err := newApp(filepath.Join(t.TempDir(), "shelf.db"))
	if err != nil {
		t.Fatal(err)
	}
	return signUp(t, kittest.NewClient(t, app), "ada@example.com")
}

func signUp(t *testing.T, client *kittest.Client, email string) *kittest.Client {
	t.Helper()
	page := client.PostForm("/signup", url.Values{"name": {"Ada"}, "email": {email}, "password": {"a long enough password"}})
	if page.Status != 303 {
		t.Fatalf("signing up: %d", page.Status)
	}
	return client
}

func TestSignedOut(t *testing.T) {
	app, err := newApp(filepath.Join(t.TempDir(), "shelf.db"))
	if err != nil {
		t.Fatal(err)
	}
	page := kittest.NewClient(t, app).Get("/")
	if page.Status != 303 || page.Location() != "/login?next=%2F" {
		t.Fatalf("GET / signed out: %d %s", page.Status, page.Location())
	}
}

func TestSavingALink(t *testing.T) {
	client := signedIn(t)
	page := client.PostForm("/?/add", url.Values{"url": {"https://go.dev/blog/go1.26"}, "title": {"Go 1.26 is released"}})
	if page.Status != 200 {
		t.Fatalf("saving: %d", page.Status)
	}
	page = client.Get("/")
	if got := page.Text(".links li a"); got != "Go 1.26 is released" {
		t.Errorf("the shelf shows %q", got)
	}
}

func TestAnAddressIsRequired(t *testing.T) {
	client := signedIn(t)
	if page := client.PostForm("/?/add", url.Values{"url": {"not an address"}}); page.Status != 400 {
		t.Errorf("an invalid address: %d, want 400", page.Status)
	}
	if page := client.Get("/"); page.Has(".links li") {
		t.Error("an invalid link was saved")
	}
}

func TestLinksArePrivate(t *testing.T) {
	ada := signedIn(t)
	ada.PostForm("/?/add", url.Values{"url": {"https://go.dev"}})
	if page := ada.Get("/links/1"); page.Status != 200 {
		t.Fatalf("Ada's own link: %d", page.Status)
	}
	// Bob is another browser, on the same app.
	bob := signUp(t, ada.Browser(), "bob@example.com")
	if page := bob.Get("/links/1"); page.Status != 404 {
		t.Errorf("Bob sees Ada's link: %d", page.Status)
	}
}

Run them:

npm test

That builds the app, then runs go test ./.... The build is what makes pages render in a test: it produces the client and the server renderer that the app embeds.

  • The client doesn't follow redirects, so a test can check where one goes, as TestSignedOut does. Cookies are kept, so after signing up the client is signed in.
  • page.Text and page.Has query the HTML the server rendered, with CSS selectors.
  • client.Browser() is a second browser on the same app, with no cookies: another user, or a visitor.
  • Shelf keeps its database in package variables, so these tests can't run in parallel. An app that passes its database around instead can call t.Parallel().

To test a single load or action without the whole app, kittest.Ctx makes up a request for it, and acc.ActAs signs a user in for that request. Testing has both, and browser tests with Playwright: npm run test:e2e runs the ones in tests/ against the production build.

Next: Ship it.