Tutorial
8. Tests
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
TestSignedOutdoes. Cookies are kept, so after signing up the client is signed in. page.Textandpage.Hasquery 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.