Let's Go تست › تست انتها به انتها
قبلی · فهرست · بعدی
فصل 13.3.

تست انتها به انتها (End-to-End Testing)

در این بخش، نحوه تست انتها به انتها (End-to-End Testing) را بررسی می‌کنیم. این شامل تست یکپارچه (Integration Testing) و تست سیستم (System Testing) می‌شود.

همچنین با سرور تست (Test Server) و پایگاه داده تست (Test Database) آشنا خواهیم شد.

در فصل گذشته الگوی کلی برای نحوه تست واحد هندلرهای HTTP شما به صورت مجزا را بررسی کردیم.

اما — بیشتر اوقات — هندلرهای HTTP شما در واقع به صورت مجزا استفاده نمی‌شوند. بنابراین در این فصل قصد داریم توضیح دهیم که چگونه می‌توان تست‌های انتها به انتها را بر روی برنامه وب خود اجرا کرد که شامل مسیریابی، میان‌افزار و هندلرهای شما باشد. در بیشتر موارد، تست انتها به انتها باید به شما اطمینان بیشتری بدهد که برنامه شما به درستی کار می‌کند نسبت به تست واحد به صورت مجزا.

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

اساساً، ما می‌خواهیم اطمینان حاصل کنیم که برنامه ما یک مسیر مانند این دارد:

الگوی مسیر هندلر عمل
GET /ping ping بازگشت یک پاسخ 200 OK

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

کلید تست انتها به انتها برنامه ما تابع 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 که انتظار داشتیم. و این به این دلیل است که ما هنوز یک مسیر GET /ping را با روتر خود ثبت نکرده‌ایم.

بیایید اکنون این را درست کنیم:

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

استفاده از کمک‌کننده‌های تست

تست TestPing ما اکنون به خوبی کار می‌کند. اما فرصت خوبی وجود دارد که بخشی از این کد را به توابع کمکی تقسیم کنیم، که می‌توانیم به عنوان تست‌های انتها به انتهای بیشتری به پروژه خود اضافه کنیم، از آن‌ها استفاده کنیم.

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

در مورد ما، کمک‌کننده‌ها برای تست کد در سراسر بسته cmd/web ما استفاده خواهند شد اما در جای دیگری نه، بنابراین به نظر می‌رسد منطقی است که آن‌ها را در یک بسته قابل استفاده مجدد به نام internal/testutils قرار دهیم.

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

$ touch cmd/web/testutils_test.go

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

File: cmd/web/testutils_test.go
package main

import (
    "bytes"
    "io"
    "log/slog"
    "net/http"
    "net/http/cookiejar" // New import
    "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)

    // 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}
}

// 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)
}

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
تست انتها به انتها End-to-End Testing آزمایش کامل سیستم
تست یکپارچه Integration Testing آزمایش تعامل اجزا
تست سیستم System Testing آزمایش کل سیستم
سرور تست Test Server سرور برای آزمایش
پایگاه داده تست Test Database پایگاه داده آزمایشی
محیط تست Test Environment محیط اجرای آزمایش
داده‌های تست Test Data داده‌های آزمایشی
سناریوی تست Test Scenario مراحل اجرای آزمایش
گزارش تست Test Report نتایج آزمایش
خطای تست Test Error مشکلات در آزمایش