Testing HTML forms
In this chapter we’re going to add an end-to-end test for the POST /user/signup
route, which is handled by our userSignupPost
handler.
Testing this route is made a bit more complicated by the anti-CSRF check that our application does. Any request that we make to POST /user/signup
will always receive a 400 Bad Request
response unless the request contains a valid CSRF token and cookie. To get around this we need to simulate the workflow of a real-life user as part of our test, like so:
Make a
GET /user/signup
request. This will return a response which contains a CSRF cookie in the response headers and the CSRF token for the signup page in the response body.Extract the CSRF token from the HTML response body.
Make a
POST /user/signup
request, using the samehttp.Client
that we used in step 1 (so it automatically passes the CSRF cookie with thePOST
request) and including the CSRF token alongside the otherPOST
data that we want to test.
Let’s begin by adding a new helper function to our cmd/web/testutils_test.go
file for extracting the CSRF token (if one exists) from an HTML response body:
package main import ( "bytes" "html" // New import "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "regexp" // New import "testing" "time" "snippetbox.alexedwards.net/internal/models/mocks" "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" ) // Define a regular expression which captures the CSRF token value from the // HTML for our user signup page. var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`) func extractCSRFToken(t *testing.T, body string) string { // Use the FindStringSubmatch method to extract the token from the HTML body. // Note that this returns an array with the entire matched pattern in the // first position, and the values of any captured data in the subsequent // positions. matches := csrfTokenRX.FindStringSubmatch(body) if len(matches) < 2 { t.Fatal("no csrf token found in body") } return html.UnescapeString(matches[1]) } ...
Now that’s in place, let’s go back to our cmd/web/handlers_test.go
file and create a new TestUserSignup
test.
To start with, we’ll make this perform a GET /user/signup
request and then extract and print out the CSRF token from the HTML response body. Like so:
package main ... func TestUserSignup(t *testing.T) { // Create the application struct containing our mocked dependencies and set // up the test server for running an end-to-end test. app := newTestApplication(t) ts := newTestServer(t, app.routes()) defer ts.Close() // Make a GET /user/signup request and then extract the CSRF token from the // response body. _, _, body := ts.get(t, "/user/signup") csrfToken := extractCSRFToken(t, body) // Log the CSRF token value in our test output using the t.Logf() function. // The t.Logf() function works in the same way as fmt.Printf(), but writes // the provided message to the test output. t.Logf("CSRF token is: %q", csrfToken) }
Importantly, you must run tests using the -v
flag (to enable verbose output) in order to see any output from the t.Logf()
function.
Let’s go ahead and do that now:
$ go test -v -run="TestUserSignup" ./cmd/web/ === RUN TestUserSignup handlers_test.go:81: CSRF token is: "C92tcpQpL1n6aIUaF8XAonwy+YjcVnyaAaOvfkdl6vJqoNSbgaTtdBRC61pFMoGP2ojV+sZ1d0SUikah3mfREQ==" --- PASS: TestUserSignup (0.01s) PASS ok snippetbox.alexedwards.net/cmd/web 0.010s
OK, that looks like it’s working. The test is running without any problems and printing out the CSRF token that we’ve extracted from the response body HTML.
Testing post requests
Now let’s head back to our cmd/web/testutils_test.go
file and create a new postForm()
method on our testServer
type, which we can use to send a POST
request to our test server with specific form data in the request body.
Go ahead and add the following code (which follows the same general pattern that we used for the get()
method earlier in the book):
package main import ( "bytes" "html" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" // New import "regexp" "testing" "time" "snippetbox.alexedwards.net/internal/models/mocks" "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" ) ... // Create a postForm method for sending POST requests to the test server. The // final parameter to this method is a url.Values object which can contain any // form data that you want to send in the request body. func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) { rs, err := ts.Client().PostForm(ts.URL+urlPath, form) if err != nil { t.Fatal(err) } // Read the response body from the test server. defer rs.Body.Close() body, err := io.ReadAll(rs.Body) if err != nil { t.Fatal(err) } body = bytes.TrimSpace(body) // Return the response status, headers and body. return rs.StatusCode, rs.Header, string(body) }
And now, at last, we’re ready to add some table-driven sub-tests to test the behavior of our application’s POST /user/signup
route. Specifically, we want to test that:
- A valid signup results in a
303 See Other
response. - A form submission without a valid CSRF token results in a
400 Bad Request
response. - A invalid form submission results in a
422 Unprocessable Entity
response and the signup form is redisplayed. This should happen when:- The name, email or password fields are empty.
- The email is not in a valid format.
- The password is less than 8 characters long.
- The email address is already in use.
Go ahead and update the TestUserSignup
function to carry out these tests like so:
package main import ( "net/http" "net/url" // New import "testing" "snippetbox.alexedwards.net/internal/assert" ) ... func TestUserSignup(t *testing.T) { app := newTestApplication(t) ts := newTestServer(t, app.routes()) defer ts.Close() _, _, body := ts.get(t, "/user/signup") validCSRFToken := extractCSRFToken(t, body) const ( validName = "Bob" validPassword = "validPa$$word" validEmail = "bob@example.com" formTag = "<form action='/user/signup' method='POST' novalidate>" ) tests := []struct { name string userName string userEmail string userPassword string csrfToken string wantCode int wantFormTag string }{ { name: "Valid submission", userName: validName, userEmail: validEmail, userPassword: validPassword, csrfToken: validCSRFToken, wantCode: http.StatusSeeOther, }, { name: "Invalid CSRF Token", userName: validName, userEmail: validEmail, userPassword: validPassword, csrfToken: "wrongToken", wantCode: http.StatusBadRequest, }, { name: "Empty name", userName: "", userEmail: validEmail, userPassword: validPassword, csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Empty email", userName: validName, userEmail: "", userPassword: validPassword, csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Empty password", userName: validName, userEmail: validEmail, userPassword: "", csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Invalid email", userName: validName, userEmail: "bob@example.", userPassword: validPassword, csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Short password", userName: validName, userEmail: validEmail, userPassword: "pa$$", csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Duplicate email", userName: validName, userEmail: "dupe@example.com", userPassword: validPassword, csrfToken: validCSRFToken, wantCode: http.StatusUnprocessableEntity, wantFormTag: formTag, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { form := url.Values{} form.Add("name", tt.userName) form.Add("email", tt.userEmail) form.Add("password", tt.userPassword) form.Add("csrf_token", tt.csrfToken) code, _, body := ts.postForm(t, "/user/signup", form) assert.Equal(t, code, tt.wantCode) if tt.wantFormTag != "" { assert.StringContains(t, body, tt.wantFormTag) } }) } }
If you run the test, you should see that all the sub-tests run and pass successfully — similar to this:
$ go test -v -run="TestUserSignup" ./cmd/web/ === RUN TestUserSignup === RUN TestUserSignup/Valid_submission === RUN TestUserSignup/Invalid_CSRF_Token === RUN TestUserSignup/Empty_name === RUN TestUserSignup/Empty_email === RUN TestUserSignup/Empty_password === RUN TestUserSignup/Invalid_email === RUN TestUserSignup/Short_password === RUN TestUserSignup/Long_password === RUN TestUserSignup/Duplicate_email --- PASS: TestUserSignup (0.01s) --- PASS: TestUserSignup/Valid_submission (0.00s) --- PASS: TestUserSignup/Invalid_CSRF_Token (0.00s) --- PASS: TestUserSignup/Empty_name (0.00s) --- PASS: TestUserSignup/Empty_email (0.00s) --- PASS: TestUserSignup/Empty_password (0.00s) --- PASS: TestUserSignup/Invalid_email (0.00s) --- PASS: TestUserSignup/Short_password (0.00s) --- PASS: TestUserSignup/Long_password (0.00s) --- PASS: TestUserSignup/Duplicate_email (0.00s) PASS ok snippetbox.alexedwards.net/cmd/web 0.016s