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 quite a lot 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/signuprequest. 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/signuprequest, using the same test server client that we used in step 1 (so it automatically passes the CSRF cookie with thePOSTrequest) and including the CSRF token alongside the otherPOSTdata 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 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" ) ... func extractCSRFToken(t *testing.T, body string) string { // Define a regular expression which captures the CSRF token value from the // HTML for our user signup page. csrfTokenRX := regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`) // Use the FindStringSubmatch method to extract the token from the HTML body. // Note that this returns an slice 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 and cookie from the response. Like so:
package main ... func TestUserSignup(t *testing.T) { // Create the application struct containing our mocked dependencies and // establish a new test server. app := newTestApplication(t) ts := newTestServer(t, app.routes()) defer ts.Close() // Make a GET /user/signup request. res := ts.get(t, "/user/signup") // Extract the CSRF token from the response body and print it out using the // t.Logf() function. This works in exactly the same way as fmt.Printf(), // but writes the provided message to the test output. t.Logf("CSRF token is: %q", extractCSRFToken(t, res.body)) // And also log the response cookies in the test output too. t.Logf("cookies are: %v", res.cookies) }
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:98: CSRF token is: "DK1hPanhgvc+IiUQ/kbh15Tw77Nb1FyX6PfsYC3u+zddYIPHDNiJ8iP70WiwZAEwIGU0ndSD9Jx2Avi+tur6oA=="
handlers_test.go:101: cookies are: [csrf_token=Uc3i+qU5CwUd2fR4TiLg57SV2y6PV6gLnvUU3psEAZc=; Path=/; HttpOnly; Secure]
--- 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 it’s printing the CSRF token we extracted from the HTML response body. We can also see that the csrf_token cookie is present in the response cookies.
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.
When sending this POST request, we also need to include a Sec-Fetch-Site: same-origin header, in order to mimic the headers that a (modern) web browser will include when making a POST request in our Snippetbox application. Without this header, the anti-CSRF checks carried out by nosurf will fail.
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" "strings" // New import "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 map 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) testResponse { req, err := http.NewRequest(http.MethodPost, ts.URL+urlPath, strings.NewReader(form.Encode())) if err != nil { t.Fatal(err) } // Set the appropriate Content-Type header for form data and the Sec-Fetch-Site // header. req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Sec-Fetch-Site", "same-origin") res, err := ts.Client().Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } return testResponse{ status: res.StatusCode, headers: res.Header, cookies: res.Cookies(), body: string(bytes.TrimSpace(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 Otherresponse. - A form submission without a valid CSRF token results in a
400 Bad Requestresponse. - An invalid form submission results in a
422 Unprocessable Entityresponse 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() 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 useValidCSRFToken bool wantStatus int wantFormTag string }{ { name: "Valid submission", userName: validName, userEmail: validEmail, userPassword: validPassword, useValidCSRFToken: true, wantStatus: http.StatusSeeOther, }, { name: "Invalid CSRF Token", userName: validName, userEmail: validEmail, userPassword: validPassword, useValidCSRFToken: false, wantStatus: http.StatusBadRequest, }, { name: "Empty name", userName: "", userEmail: validEmail, userPassword: validPassword, useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Empty email", userName: validName, userEmail: "", userPassword: validPassword, useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Empty password", userName: validName, userEmail: validEmail, userPassword: "", useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Invalid email", userName: validName, userEmail: "bob@example.", userPassword: validPassword, useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Short password", userName: validName, userEmail: validEmail, userPassword: "pa$$", useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, { name: "Duplicate email", userName: validName, userEmail: "dupe@example.com", userPassword: validPassword, useValidCSRFToken: true, wantStatus: http.StatusUnprocessableEntity, wantFormTag: formTag, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Reset the cookie jar for each sub-test. ts.resetClientCookieJar(t) // Make a GET /user/signup signup request. This will automatically // add the CSRF cookie from the response to the test client's cookie // jar, and we can extract the CSRF token from the response body. res := ts.get(t, "/user/signup") // Build up the form values for the sub-test, including the CSRF // token if appropriate. form := url.Values{} form.Add("name", tt.userName) form.Add("email", tt.userEmail) form.Add("password", tt.userPassword) if tt.useValidCSRFToken { form.Add("csrf_token", extractCSRFToken(t, res.body)) } // Make the POST /user/signup request using the form values we // created above. The request will automatically include the CSRF // cookie from the test client's cookie jar. res = ts.postForm(t, "/user/signup", form) // And finally, test the response data. assert.Equal(t, res.status, tt.wantStatus) assert.StringContains(t, res.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/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/Duplicate_email (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.016s