تست 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 را طوری بهروزرسانی کنید که به این شکل باشد:
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") }
چند نکته درباره این کد وجود دارد که باید به آن اشاره کنیم و بررسی کنیم.
وقتی
httptest.NewTLSServer()را برای مقداردهی اولیه سرور تست فراخوانی میکنیم، باید یکhttp.Handlerبه عنوان پارامتر ارسال کنیم — و این handler هر بار که سرور تست یک درخواست HTTPS دریافت میکند، فراخوانی میشود. در مورد ما، مقدار بازگشتی از متدapp.routes()را ارسال کردهایم، یعنی یک درخواست به سرور تست از همه routeها، middleware و handlerهای واقعی برنامه استفاده میکند.این مزیت بزرگی از کاری است که قبلاً در کتاب انجام دادیم تا همه routing برنامه را در متد
app.routes()جدا کنیم.اگر یک سرور HTTP (نه HTTPS) را تست میکنید، باید به جای آن از تابع
httptest.NewServer()برای ایجاد سرور تست استفاده کنید.متد
ts.Client()کلاینت سرور تست را برمیگرداند — که نوع آنhttp.Clientاست — و باید همیشه از این کلاینت برای ارسال درخواستها به سرور تست استفاده کنیم. میتوان کلاینت را برای تنظیم رفتار آن پیکربندی کرد و در پایان این فصل نحوه انجام آن را توضیح میدهیم.ممکن است تعجب کنید که چرا فیلد
loggerاز structapplicationرا تنظیم کردهایم، اما هیچ یک از فیلدهای دیگر را نه. دلیل این است که logger توسط middlewareهایlogRequestوrecoverPanicمورد نیاز است که توسط برنامه ما در هر route استفاده میشوند. تلاش برای اجرای این تست بدون تنظیم این دو dependency منجر به panic میشود.
به هر حال، بیایید تست جدید را امتحان کنیم:
$ 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 خود ثبت نکردهایم.
بیایید آن را اصلاح کنیم:
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
و سپس کد زیر را اضافه کنید:
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های جدید را به کار ببریم:
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ها
تا اینجا در این فصل از تنظیمات پیشفرض کلاینت سرور تست استفاده کردهایم. اما چند تغییر وجود دارد که میخواهم انجام دهم تا برای تست برنامه وب ما مناسبتر باشد. به طور خاص:
میخواهیم کلاینت به طور خودکار هر کوکی ارسال شده در یک پاسخ HTTPS را ذخیره کند، تا بتوانیم آنها را (در صورت مناسب بودن) در هر درخواست بعدی به سرور تست شامل کنیم. این بعداً در کتاب زمانی که نیاز داریم کوکیها در چندین درخواست پشتیبانی شوند تا اقدامات ضد CSRF خود را تست کنیم، مفید خواهد بود.
نمیخواهیم کلاینت به طور خودکار redirectها را دنبال کند. در عوض میخواهیم اولین پاسخ HTTPS ارسال شده توسط سرور ما را برگرداند تا بتوانیم پاسخ برای آن درخواست خاص را تست کنیم.
برای انجام این تغییرات، بیایید به فایل testutils_test.go که تازه ایجاد کردیم برگردیم و تابع newTestServer() را به این شکل بهروزرسانی کنیم:
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} } ...