Shipping
Testing
Test the real application through app.Handler() and net/http/httptest — pages, forms with their forgery token, documents and the static export.
A collage application is an http.Handler. app.Handler() returns it, with no
server listening and no port to choose, so net/http/httptest drives the whole site
from an ordinary Go test: routing, data handlers, templates, the cache, forms,
documents and middleware, exactly as they run in production.
The project collage new scaffolds comes with a test file built this way. This page
walks through its pattern.
Test the application main builds
The scaffolded main.go builds the application in a function of its own:
func newApp(devMode bool, port int) (*collage.App, error)
main calls it to serve, and the tests call it to test. That is the most important
decision in the test file: what the tests exercise is the site that actually runs,
with its real configuration, routes and mounts, not a second wiring that slowly
drifts from it.
func handler(t *testing.T) http.Handler {
t.Helper()
cacheDir = t.TempDir()
app, err := newApp(false, 0)
if err != nil {
t.Fatalf("newApp() = %v, want nil", err)
}
return app.Handler()
}
// get returns the response to a GET of target, and its body.
func get(t *testing.T, h http.Handler, target string) (*httptest.ResponseRecorder, string) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec, rec.Body.String()
}
newApp(false, 0) builds the production configuration: development mode off, and a
port of 0, which collage replaces with its default — nothing listens anyway.
A test is then a request and a look at the response:
func TestPagesRender(t *testing.T) {
h := handler(t)
for target, want := range map[string]string{
"/": "Explore the features",
"/features": "Four live demos",
"/hello": "Hello, stranger!",
} {
rec, body := get(t, h, target)
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d, want 200", target, rec.Code)
continue
}
if !strings.Contains(body, want) {
t.Errorf("GET %s does not contain %q", target, want)
}
// Counted, not merely found: a layout writing one and a page hoisting
// another is two titles, which is a page that looks fine and is not.
if n := strings.Count(body, "<title>"); n != 1 {
t.Errorf("GET %s has %d titles, want exactly 1", target, n)
}
}
}
Build a fresh handler per test, as handler(t) does. The first call to
app.Handler() starts the application and closes registration, and each test then
starts from an empty cache.
If starting fails — a plugin's Init fails, a page names an unregistered error page,
a mount shadows a route — app.Handler() answers every request with a 503 and logs
why. Call app.Start() first when you want that failure as an error in the test:
if err := app.Start(); err != nil {
t.Fatalf("start: %v", err)
}
Isolate the disk cache
The scaffold's cache directory is a package variable, and handler points it at a
fresh directory before building the application:
// cacheDir is where rendered pages are kept between restarts. A variable so the
// tests can point it at a directory of their own.
var cacheDir = ".cache"
With development mode off, the application uses the disk cache, and a disk cache is
shared by everything that uses the same directory and the same build. Without
cacheDir = t.TempDir() a test could be served a page an earlier test rendered —
or an earlier run, since the cache survives the process — and pass or fail for
reasons that have nothing to do with the code. It would also leave a .cache
directory in your package.
Because cacheDir is shared by the whole package, these tests do not call
t.Parallel(). If you want parallel tests, pass the directory to newApp as a
parameter instead.
A test that is about caching can use this: make two requests to one handler and
check the second is served from the cache, or call app.InvalidateTags between
them and check it is not.
Forms and the forgery token
Every action behind an unsafe method — a form post, a fetch() — checks a
request-forgery token, and a test has to send one the way a browser does:
GETthe page with the form. The response sets thecollage_csrfcookie, and{{csrfToken}}in the page renders a hidden_csrffield carrying the same token.- Read the token out of the page.
POSTthe form with the token in the_csrffield — or in theX-CSRF-Tokenheader, as afetch()does — and the cookie from step 1.
The scaffold's helpers:
// token reads the forgery token out of a rendered page, the way a browser does.
func token(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`name="_csrf" value="([^"]+)"`).FindStringSubmatch(body)
if m == nil {
t.Fatalf("no forgery token in the page:\n%s", body)
}
return m[1]
}
// post sends a POST to target, carrying the cookies from page.
func post(t *testing.T, h http.Handler, target string, form url.Values, header http.Header, page *httptest.ResponseRecorder) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for name, values := range header {
req.Header[name] = values
}
if page != nil {
for _, c := range (&http.Response{Header: page.Header()}).Cookies() {
req.AddCookie(c)
}
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
(&http.Response{Header: page.Header()}).Cookies() parses the recorder's
Set-Cookie headers with the standard library's own cookie parser, so the test sends
back exactly what a browser would.
A form post, with the token in the field:
func TestHelloGreetsTheSubmittedName(t *testing.T) {
h := handler(t)
page, body := get(t, h, "/features")
rec := post(t, h, "/hello", url.Values{"_csrf": {token(t, body)}, "name": {"Ada"}}, nil, page)
if rec.Code != http.StatusOK {
t.Fatalf("POST /hello = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Hello, Ada!") {
t.Errorf("the response does not greet the name:\n%s", rec.Body.String())
}
}
A JSON endpoint, with the token in the header:
func TestCountAnswersWithTheNewCount(t *testing.T) {
h := handler(t)
page, body := get(t, h, "/features")
rec := post(t, h, "/api/count", nil, http.Header{"X-Csrf-Token": {token(t, body)}}, page)
if rec.Code != http.StatusOK {
t.Fatalf("POST /api/count = %d, want 200", rec.Code)
}
var answer struct{ Count int64 }
if err := json.Unmarshal(rec.Body.Bytes(), &answer); err != nil {
t.Fatalf("body = %q, want JSON: %v", rec.Body.String(), err)
}
if answer.Count < 1 {
t.Errorf("count = %d, want at least 1 after a click", answer.Count)
}
}
And the test that keeps the protection on:
// Without a token a submission never reaches its handler. This is the test that
// fails if the protection is ever turned off by accident.
func TestASubmissionWithNoTokenIsRefused(t *testing.T) {
h := handler(t)
for _, target := range []string{"/api/count", "/hello"} {
if rec := post(t, h, target, url.Values{"name": {"Ada"}}, nil, nil); rec.Code != http.StatusForbidden {
t.Errorf("POST %s with no token = %d, want 403", target, rec.Code)
}
}
}
The token is signed with Security.CSRFKey, or with a key generated per
application when none is set. Either way the page and the post in one test go to the
same application, so the tests need no key of their own. _csrf and collage_csrf
are the default names; an application that renames them with
Security.CSRFFieldName or CSRFCookieName renames them in its tests too.
Documents, redirects and status codes
A document is tested like a page. Check the content type as well as the body, since the content type is half of what a document is:
func TestHealthCheck(t *testing.T) {
rec, body := get(t, handler(t), "/healthz")
if rec.Code != http.StatusOK {
t.Fatalf("GET /healthz = %d, want 200", rec.Code)
}
var health struct{ Status string }
if err := json.Unmarshal([]byte(body), &health); err != nil || health.Status != "ok" {
t.Errorf("body = %q, want a status of ok", body)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
The not-found page, a 405 with its Allow header, a redirect's Location, a
Cache-Control header — each is a field on the recorder:
func TestNotFoundPage(t *testing.T) {
rec, body := get(t, handler(t), "/there-is-nothing-here")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if !strings.Contains(body, "There is nothing at this address") {
t.Errorf("body = %q, want this site's own not-found page", body)
}
}
Middleware registered with app.Use runs in these tests too, since it is part of
the handler. To test a preview or a collage.Vary dimension, set the cookie or
header on the request before ServeHTTP.
Every page renders
A test that visits every page catches the template that fails only on one of them.
collage-docs loads its content and requests each page — here with the handler and
get helpers above, one application for the whole walk:
// Every page of the documentation renders, with its own title.
func TestEveryDocRenders(t *testing.T) {
loaded, err := site.Load(content.FS)
if err != nil {
t.Fatalf("site.Load: %v", err)
}
h := handler(t)
for _, page := range loaded.Pages() {
rec, body := get(t, h, page.URL())
if rec.Code != http.StatusOK {
t.Errorf("GET %s = %d", page.URL(), rec.Code)
continue
}
if !strings.Contains(body, "<title>"+page.Title+" — collage</title>") {
t.Errorf("GET %s has no title %q", page.URL(), page.Title)
}
}
}
For a site whose pages come from a CMS, the same test can walk the list your path provider returns.
Testing the export
The static export is code too, and the one place where a skipped page is silent in
production. Run it into t.TempDir() and check the files you expect are there:
// The export writes the home page, every doc and the 404 page.
func TestExport(t *testing.T) {
cacheDir = t.TempDir()
app, err := newApp(false, 0)
if err != nil {
t.Fatal(err)
}
out := t.TempDir()
if err := staticBuild(app, out, false); err != nil {
t.Fatalf("staticBuild: %v", err)
}
loaded, _ := site.Load(content.FS)
want := []string{"index.html", "404.html"}
for _, page := range loaded.Pages() {
want = append(want, filepath.Join("docs", page.Slug, "index.html"))
}
for _, file := range want {
if _, err := os.Stat(filepath.Join(out, file)); err != nil {
t.Errorf("%s was not written: %v", file, err)
}
}
}
It calls the same staticBuild that collage export runs, so the test covers the
path provider and the builder options as well as the pages. staticBuild returns
the build's error, which fails the test for a degraded page, an empty render or a
panic.
To assert on skips and warnings as well, call the builder directly and read the report:
builder, err := collage.NewBuilder(app, collage.BuildOptions{OutDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
report, err := builder.Build(context.Background())
if err != nil {
t.Fatalf("build: %v", err)
}
for _, skip := range report.Skipped {
if skip.Page == "home" {
t.Errorf("home was skipped: %s", skip.Reason)
}
if errors.Is(skip.Err, collage.ErrDynamicPathUnresolved) {
t.Errorf("%s has a {param} the path provider does not cover", skip.Page)
}
}
if len(report.Warnings) > 0 {
t.Errorf("warnings: %v", report.Warnings)
}
skip.Err is the sentinel behind each skip, since v0.10.0; see
Errors.
Rendering without HTTP
app.RenderPath renders one page the way the export does — no request, no page
cache, no middleware — and returns the HTML and what the render did. The render
hooks of your plugins still run, and so does the data cache: a value
collage.Cached stored during one
render, or one served request, is what the next RenderPath on the same
application is given. Build a fresh application when a test needs fresh data:
result, err := app.RenderPath(context.Background(), "/blog/hello-world", "", nil)
if err != nil {
t.Fatalf("render: %v", err)
}
if result.Degraded() {
t.Errorf("a fragment failed: %+v", result.Metadata)
}
app.RenderDocumentPath does the same for a document. Most tests are better served
by app.Handler(), which tests what a reader gets; these are for looking at one
render in detail.
Running them
go test ./...
Run it in CI before building or exporting. A test that uses the real newApp fails
there on anything that would stop the site from starting, which is cheaper than
finding out on the server.