Let's Go تست › تست فرم‌های HTML
قبلی · فهرست · بعدی
فصل ۱۳.۶.

تست فرم‌های HTML

در این فصل می‌خواهیم یک تست end-to-end برای route POST /user/signup اضافه کنیم که توسط handler userSignupPost ما مدیریت می‌شود.

تست این route به دلیل بررسی ضد CSRF که برنامه ما انجام می‌دهد کمی پیچیده‌تر می‌شود. هر درخواستی که به POST /user/signup می‌دهیم همیشه یک پاسخ 400 Bad Request دریافت می‌کند مگر اینکه درخواست شامل یک CSRF token و cookie معتبر باشد. برای دور زدن این، باید workflow یک کاربر واقعی را به عنوان بخشی از تست خود شبیه‌سازی کنیم، به این شکل:

  1. یک درخواست GET /user/signup ارسال کنید. این یک پاسخ برمی‌گرداند که شامل یک CSRF cookie در headerهای پاسخ و CSRF token برای صفحه signup در بدنه پاسخ است.

  2. CSRF token را از بدنه پاسخ HTML استخراج کنید.

  3. یک درخواست POST /user/signup ارسال کنید، با استفاده از همان http.Client که در مرحله 1 استفاده کردیم (تا به طور خودکار CSRF cookie را با درخواست POST ارسال کند) و شامل کردن CSRF token در کنار داده‌های POST دیگری که می‌خواهیم تست کنیم.

بیایید با افزودن یک تابع helper جدید به فایل cmd/web/testutils_test.go برای استخراج CSRF token (در صورت وجود) از بدنه پاسخ HTML شروع کنیم:

File: cmd/web/testutils_test.go
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])
}

...

حالا که این در جای خود قرار دارد، بیایید به فایل cmd/web/handlers_test.go برگردیم و یک تست جدید TestUserSignup ایجاد کنیم.

برای شروع، این را طوری می‌سازیم که یک درخواست GET /user/signup انجام دهد و سپس CSRF token را از بدنه پاسخ HTML استخراج و چاپ کند. به این شکل:

File: cmd/web/handlers_test.go
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)
}

مهم است که باید تست‌ها را با flag -v (برای فعال کردن خروجی verbose) اجرا کنید تا هر خروجی از تابع t.Logf() را ببینید.

بیایید همین حالا این کار را انجام دهیم:

$ 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

خوب، به نظر می‌رسد که کار می‌کند. تست بدون هیچ مشکلی اجرا می‌شود و CSRF token را که از بدنه پاسخ HTML استخراج کرده‌ایم چاپ می‌کند.

تست درخواست‌های POST

حالا بیایید به فایل cmd/web/testutils_test.go برگردیم و یک متد جدید postForm() روی نوع testServer ایجاد کنیم که می‌توانیم از آن برای ارسال یک درخواست POST به سرور تست با داده‌های فرم خاص در بدنه درخواست استفاده کنیم.

پیش بروید و کد زیر را اضافه کنید (که همان الگوی کلی را دنبال می‌کند که برای متد get() قبلاً در کتاب استفاده کردیم):

File: cmd/web/testutils_test.go
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)
}

و حالا، در نهایت، آماده‌ایم که چند sub-test مبتنی بر جدول (table-driven) برای تست رفتار route POST /user/signup برنامه خود اضافه کنیم. به طور خاص، می‌خواهیم تست کنیم که:

پیش بروید و تابع TestUserSignup را برای انجام این تست‌ها به این شکل به‌روزرسانی کنید:

File: cmd/web/handlers_test.go
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)
            }
        })
    }
}

اگر تست را اجرا کنید، باید ببینید که همه sub-testها اجرا می‌شوند و با موفقیت پاس می‌شوند — مشابه این:

$ 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