Let's Go تست › تست end-to-end
قبلی · فهرست · بعدی
فصل ۱۳.۳.

تست end-to-end

در فصل قبل، الگوی کلی تست واحد برای handlerهای HTTP به صورت جداگانه را بررسی کردیم.

اما — در بیشتر مواقع — handlerهای HTTP شما در واقع به صورت جداگانه استفاده نمی‌شوند. بنابراین در این فصل نحوه اجرای تست‌های end-to-end روی برنامه وب را توضیح می‌دهیم که routing، middleware و handlerها را شامل می‌شود. در بیشتر موارد، تست end-to-end اطمینان بیشتری نسبت به تست واحد به صورت جداگانه می‌دهد که برنامه شما به درستی کار می‌کند.

برای نشان دادن این موضوع، تابع TestPing را طوری تغییر می‌دهیم که یک تست end-to-end روی کد ما اجرا کند. به طور خاص، می‌خواهیم تست اطمینان حاصل کند که یک درخواست GET /ping به برنامه ما، تابع handler ping را فراخوانی می‌کند و نتیجه آن کد وضعیت 200 OK و بدنه پاسخ "OK" است.

در اصل، می‌خواهیم تست کنیم که برنامه ما یک route مانند این دارد:

Route pattern Handler Action
GET /ping ping بازگرداندن پاسخ 200 OK

استفاده از httptest.Server

کلید تست end-to-end برنامه ما، تابع httptest.NewTLSServer() است که یک نمونه از httptest.Server ایجاد می‌کند که می‌توانیم درخواست‌های HTTPS به آن ارسال کنیم.

الگوی کلی کمی پیچیده است که از ابتدا توضیح داده شود، بنابراین بهتر است ابتدا با نوشتن کد نشان دهیم و سپس جزئیات را بررسی کنیم.

با این در نظر، به فایل handlers_test.go برگردید و تست TestPing را طوری به‌روزرسانی کنید که به این شکل باشد:

File: cmd/web/handlers_test.go
package main

import (
    "bytes"
    "io"
    "log/slog" // New import
    "net/http"
    "net/http/httptest"
    "testing"

    "snippetbox.alexedwards.net/internal/assert"
)

func TestPing(t *testing.T) {
    // Create a new instance of our application struct. For now, this just
    // contains a structured logger (which discards anything written to it).
    app := &application{
        logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
    }

    // We then use the httptest.NewTLSServer() function to create a new test
    // server, passing in the value returned by our app.routes() method as the
    // handler for the server. This starts up a HTTPS server which listens on a
    // randomly-chosen port of your local machine for the duration of the test.
    // Notice that we defer a call to ts.Close() so that the server is shutdown
    // when the test finishes.
    ts := httptest.NewTLSServer(app.routes())
    defer ts.Close()

    // The network address that the test server is listening on is contained in
    // the ts.URL field. We can  use this along with the ts.Client().Get() method
    // to make a GET /ping request against the test server. This returns a
    // http.Response struct containing the response.
    rs, err := ts.Client().Get(ts.URL + "/ping")
    if err != nil {
        t.Fatal(err)
    }

    // We can then check the value of the response status code and body using
    // the same pattern as before.
    assert.Equal(t, rs.StatusCode, http.StatusOK)

    defer rs.Body.Close()
    body, err := io.ReadAll(rs.Body)
    if err != nil {
        t.Fatal(err)
    }
    body = bytes.TrimSpace(body)

    assert.Equal(t, string(body), "OK")
}

چند نکته درباره این کد وجود دارد که باید به آن اشاره کنیم و بررسی کنیم.

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

$ go test ./cmd/web/
--- FAIL: TestPing (0.00s)
    handlers_test.go:41: got 404; want 200
    handlers_test.go:51: got: Not Found; want: OK
FAIL
FAIL    snippetbox.alexedwards.net/cmd/web      0.007s
FAIL

اگر همراه با ما پیش می‌روید، باید در این مرحله یک خطا دریافت کنید.

از خروجی تست می‌بینیم که پاسخ درخواست GET /ping ما کد وضعیت 404 دارد، نه 200 که انتظار داشتیم. و این به این دلیل است که هنوز یک route GET /ping را با router خود ثبت نکرده‌ایم.

بیایید آن را اصلاح کنیم:

File: cmd/web/routes.go
package main

...

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    mux.Handle("GET /static/", http.FileServerFS(ui.Files))

    // Add a new GET /ping route.
    mux.HandleFunc("GET /ping", ping)

    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup))
    mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost))
    mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin))
    mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost))

    protected := dynamic.Append(app.requireAuthentication)

    mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost))
    mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)
    return standard.Then(mux)
}

و اگر دوباره تست‌ها را اجرا کنید، همه چیز باید اکنون پاس شود.

$ go test ./cmd/web/
ok      snippetbox.alexedwards.net/cmd/web    0.008s

استفاده از helperهای تست

تست TestPing ما اکنون به خوبی کار می‌کند. اما فرصت خوبی وجود دارد که بخشی از این کد را به توابع helper تبدیل کنیم که می‌توانیم هنگام افزودن تست‌های end-to-end بیشتر به پروژه خود، دوباره استفاده کنیم.

قوانین سخت و سریعی درباره محل قرار دادن متدهای helper برای تست‌ها وجود ندارد. اگر یک helper فقط در یک فایل *_test.go خاص استفاده می‌شود، احتمالاً منطقی است که آن را به صورت inline در همان فایل در کنار تست‌های خود قرار دهید. در طرف دیگر، اگر می‌خواهید از یک helper در تست‌های چندین package استفاده کنید، ممکن است بخواهید آن را در یک package قابل استفاده مجدد به نام internal/testutils (یا مشابه) قرار دهید که می‌تواند توسط فایل‌های تست شما import شود.

در مورد ما، helperها برای تست کد در سراسر package cmd/web استفاده می‌شوند اما در جای دیگری نه، بنابراین منطقی به نظر می‌رسد که آن‌ها را در یک فایل جدید cmd/web/testutils_test.go قرار دهیم.

اگر همراه با ما پیش می‌روید، لطفاً همین حالا این را ایجاد کنید…

$ touch cmd/web/testutils_test.go

و سپس کد زیر را اضافه کنید:

File: cmd/web/testutils_test.go
package main

import (
    "bytes"
    "io"
    "log/slog"
    "net/http"
    "net/http/httptest"
    "testing"
)

// Create a newTestApplication helper which returns an instance of our
// application struct containing mocked dependencies.
func newTestApplication(t *testing.T) *application {
    return &application{
        logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
    }
}

// Define a custom testServer type which embeds a httptest.Server instance.
type testServer struct {
    *httptest.Server
}

// Create a newTestServer helper which initalizes and returns a new instance
// of our custom testServer type.
func newTestServer(t *testing.T, h http.Handler) *testServer {
    ts := httptest.NewTLSServer(h)
    return &testServer{ts}
}

// Implement a get() method on our custom testServer type. This makes a GET
// request to a given url path using the test server client, and returns the 
// response status code, headers and body.
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
    rs, err := ts.Client().Get(ts.URL + urlPath)
    if err != nil {
        t.Fatal(err)
    }

    defer rs.Body.Close()
    body, err := io.ReadAll(rs.Body)
    if err != nil {
        t.Fatal(err)
    }
    body = bytes.TrimSpace(body)

    return rs.StatusCode, rs.Header, string(body)
}

در اصل، این فقط یک تعمیم از کدی است که در این فصل برای راه‌اندازی یک سرور تست و ارسال یک درخواست GET به آن نوشته‌ایم.

بیایید به handler TestPing برگردیم و این helperهای جدید را به کار ببریم:

File: cmd/web/handlers_test.go
package main

import (
    "net/http"
    "testing"

    "snippetbox.alexedwards.net/internal/assert"
)

func TestPing(t *testing.T) {
    app := newTestApplication(t)

    ts := newTestServer(t, app.routes())
    defer ts.Close()

    code, _, body := ts.get(t, "/ping")

    assert.Equal(t, code, http.StatusOK)
    assert.Equal(t, body, "OK")
}

و دوباره، اگر تست‌ها را دوباره اجرا کنید، همه چیز باید همچنان پاس شود.

$ go test ./cmd/web/
ok      snippetbox.alexedwards.net/cmd/web    0.013s

این اکنون به خوبی شکل گرفته است. یک الگوی مرتب برای راه‌اندازی یک سرور تست و ارسال درخواست به آن داریم که routing، middleware و handlerهای ما را در یک تست end-to-end شامل می‌شود. همچنین بخشی از کد را به helperها تقسیم کرده‌ایم که نوشتن تست‌های آینده را سریع‌تر و آسان‌تر می‌کند.

کوکی‌ها و redirectها

تا اینجا در این فصل از تنظیمات پیش‌فرض کلاینت سرور تست استفاده کرده‌ایم. اما چند تغییر وجود دارد که می‌خواهم انجام دهم تا برای تست برنامه وب ما مناسب‌تر باشد. به طور خاص:

برای انجام این تغییرات، بیایید به فایل testutils_test.go که تازه ایجاد کردیم برگردیم و تابع newTestServer() را به این شکل به‌روزرسانی کنیم:

File: cmd/web/testutils_test.go
package main

import (
    "bytes"
    "io"
    "log/slog"
    "net/http"
    "net/http/cookiejar" // New import
    "net/http/httptest"
    "testing"
)

...

func newTestServer(t *testing.T, h http.Handler) *testServer {
    // Initialize the test server as normal.
    ts := httptest.NewTLSServer(h)

    // Initialize a new cookie jar.
    jar, err := cookiejar.New(nil)
    if err != nil {
        t.Fatal(err)
    }

    // Add the cookie jar to the test server client. Any response cookies will
    // now be stored and sent with subsequent requests when using this client.
    ts.Client().Jar = jar

    // Disable redirect-following for the test server client by setting a custom
    // CheckRedirect function. This function will be called whenever a 3xx
    // response is received by the client, and by always returning a
    // http.ErrUseLastResponse error it forces the client to immediately return
    // the received response.
    ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse
    }

    return &testServer{ts}
}

...