Acceptance Testing Go Web Applications with Cookies

by on

When you’re writing a Go web application, there are a lot of ways you can test it. You can test each handler as a unit, or you can go for full blown acceptance tests with a framework like Agouti. In this post, we’ll look at how to test at the HTTP layer using the standard library net/http with cookie support so you can test a full user interaction.

HTTP Testing Basics

We’ll start with the basics of testing HTTP applications in Go. Let’s use this simple hello world web server as an example:

// package main is used here just for ease of running the demo.
// In reality, this would be package app
package main

import (
	"flag"
	"fmt"
	"log"
	"net/http"
)

// App is our Application's base http.Handler
type App struct {
	*http.ServeMux
}

// NewApp constructs a new App and initializes our routing
func NewApp() *App {
	mux := http.NewServeMux()
	app := &App{mux}
	mux.HandleFunc("/", app.Root)
	return app
}

// Root is the root page of our application
func (a *App) Root(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello!")
}

var port = flag.Int("port", 8080, "Port to serve on")

func main() {
	flag.Parse()
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), NewApp()))
}

It’s not just your basic Go HTTP server example, we’ve broken it into a couple of pieces, and there’s a reason for that: testability!

We embed a ServeMux into our App object so that when we construct, we can set up our routing to match paths to instance methods on App. This makes it easy to access App-level objects later like logging and database connections. But for now, the main reason is to be able to construct our App and use it in a test.

I’ve included the main as well just so you can see how you would run an app like this with http.ListenAndServe.

Here’s a basic test to check that our app says hello:

func TestAppRoot(t *testing.T) {
	app := NewApp()
	server := httptest.NewServer(app)
	defer server.Close()

	resp, err := http.Get(server.URL + "/")
	if err != nil {
		t.Error(err)
	}

	buf := &bytes.Buffer{}
	buf.ReadFrom(resp.Body)
	if strings.Index(buf.String(), "Hello!") == -1 {
		t.Error("Root should say hello")
	}
}

We construct our app and an httptest.Server from it, which runs a real web server on a random port. We can then use http.Client to make a get request to our server. Then we read the body and check that it says “Hello!”

That’s the basics of a Go http acceptance test using just the standard library. I consider it an acceptance test because our “interface” to our app is HTTP, just like a user uses. We checked that the page body had Hello, which is the acceptance criteria of the app. Since we’re serving it on a real port we’re executing the full stack of the application.

Session and Cookies

OK, so this works great for static sites, but what about when I have a site that keeps a session and cookies? Out of the box, http.Client doesn’t keep track of cookies, so you can’t sign in to your app and click around as a user.

To start with, let’s modify our app so that you can tell it your name and it will set your name as a cookie and greet you. Here is our new Root:


// Root is the root page of our application
func (a *App) Root(w http.ResponseWriter, r *http.Request) {
	var name string

	nameCookie, err := r.Cookie("name")
	if err == nil {
		name = nameCookie.Value
	} else if err != http.ErrNoCookie {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	
	t := template.Must(template.New("root").Parse(`
	<!doctype html>
	<html>
	<head><title>greeter</title></head>
	<body>
	{{if .}}
	  <h1>Hi {{.}}!</h1>
	{{else}}
	  <h1>Welcome! Who are you?</h1>
	  <form method="POST" action="/name">
	    <input type="text" placeholder="Your name" name="name">
	    <input type="submit" value="Set Name">
	  </form>
	{{end}}
	</body>
	</html>
	`))
	if err := t.Execute(w, name); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
}

In this new root, we check for a "name" cookie and use its value as the user’s name if we find it. Then we have a new page which is now an HTML template. This template will greet someone if they have a name set, or it will present a form that the user can use to set their name.

For this to work we are going to need to add a new action to our app:

// NewApp constructs a new App and initializes our routing
func NewApp() *App {
	mux := http.NewServeMux()
	app := &App{mux}
	mux.HandleFunc("/", app.Root)
	mux.HandleFunc("/name", app.SetName) // <- NEW
	return app
}

func (a *App) SetName(w http.ResponseWriter, r *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name:     "name",
		Value:    r.FormValue("name"),
		HttpOnly: true,
		Expires:  time.Now().Add(24 * 14 * time.Hour),
	})
	http.Redirect(w, r, "/", http.StatusFound)
}

At the path "/name" we now have a SetName action that will set the name cookie to the name form value passed in and redirect back to the root.

Cool! So now that our app works, let’s look at how to test it:

func TestSetName(t *testing.T) {
	app := NewApp()
	server := httptest.NewServer(app)
	defer server.Close()

	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Error(err)
	}
	client := &http.Client{Jar: jar}

	resp, err := client.Get(server.URL + "/")
	if err != nil {
		t.Error(err)
	}

	buf := &bytes.Buffer{}
	buf.ReadFrom(resp.Body)
	if strings.Index(buf.String(), "Welcome") == -1 {
		t.Errorf("Root should say Welcome!:\n%s", buf.String())
	}

	resp, err = client.PostForm(
        	server.URL+"/name",
        	url.Values{"name": {"Nick"}},
        )
	if err != nil {
		t.Error(err)
	}

	buf.Reset()
	buf.ReadFrom(resp.Body)
	if strings.Index(buf.String(), "Hi Nick!") == -1 {
		t.Errorf("Root should say Hi Nick!:\n%s", buf.String())
	}
}

In this new test, we set up our App same as before, but now we instantiate an http.Client to use with a cookiejar.Jar. By adding a cookie jar to our client, we’re telling it to track cookies it receives. Now, when we make our get request we use client.Get instead of http. Although, it’s really the client.PostForm that matters, since that sets the cookie.

After posting the form, we’re redirected to root which renders our name. Go’s http.Client automatically follows redirects, so this is just one step. Nice!

A bit of refactoring

At this point we have a working test, but it’s quite verbose! To solve this, I like to merge the test server, cookie-enabled client, and the testing variable into one object that has a nice testing interface. Check it out:

// AppTestServer is an App wrapped up for testing
type AppTestServer struct {
	client *http.Client
	app    *App
	t      *testing.T
	server *httptest.Server
}

// NewAppTestServer creates a new AppTestServer with a cookie-enabled client
func NewAppTestServer(t *testing.T) *AppTestServer {
	app := NewApp()
	server := httptest.NewServer(app)

	jar, err := cookiejar.New(nil)
	if err != nil {
		t.Error(err)
	}
	client := &http.Client{Jar: jar}

	return &AppTestServer{
		client: client,
		app:    app,
		t:      t,
		server: server,
	}
}

// Close shuts down the test server
func (ats *AppTestServer) Close() {
	ats.server.Close()
}

// Get will make a get request to the given path and
// check for errors, returning the body
func (ats *AppTestServer) Get(path string) string {
	resp, err := ats.client.Get(ats.server.URL + path)
	if err != nil {
		ats.t.Error(err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		ats.t.Error(err)
	}
	return string(b)
}

// PostForm will post a form to the given path and
// check for errors, returning the body
func (ats *AppTestServer) PostForm(path string, values url.Values) string {
	resp, err := ats.client.PostForm(ats.server.URL+path, values)
	if err != nil {
		ats.t.Error(err)
	}
	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		ats.t.Error(err)
	}
	return string(b)
}

// AssertSee will check that the given query is found in
// the body or error the test
func AssertSee(t *testing.T, body, query string) {
	if strings.Index(body, query) == -1 {
		t.Errorf("Expected to see %s in\n%s", query, body)
	}
}

func TestSetName(t *testing.T) {
	ats := NewAppTestServer(t)
	defer ats.Close()

	body := ats.Get("/")
	AssertSee(t, body, "Welcome")

	body = ats.PostForm("/name", url.Values{"name": {"Nick"}})
	AssertSee(t, body, "Hi Nick!")
}

Tests are code too! It’s important to refactor and abstract your test code. It’s more code overall, but it makes the test very readable because we can tell exactly what actions we’re performing and what we’re looking for.

I hope this helps you start acceptance testing your Go web application!

— Nick Gauthier

Frustrated with video conferencing?

MeetSpace has the best audio quality and reliability around, and is built for distributed teams.

Start Your Free Trial