تست انتها به انتها (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 را به گونهای بهروزرسانی کنید که به این شکل باشد:
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به عنوان پارامتر ارسال کنیم — و این هندلر هر بار که سرور تست یک درخواست HTTPS دریافت میکند، فراخوانی میشود. در مورد ما، ما مقدار بازگشتی از متدapp.routes()خود را ارسال کردهایم، به این معنی که یک درخواست به سرور تست از تمام مسیرها، میانافزارها و هندلرهای واقعی برنامه ما استفاده خواهد کرد.این یک مزیت بزرگ از کاری است که ما قبلاً در کتاب انجام دادیم تا تمام مسیریابی برنامه خود را در متد
app.routes()جدا کنیم.اگر در حال تست یک سرور HTTP (نه HTTPS) هستید، باید از تابع
httptest.NewServer()برای ایجاد سرور تست استفاده کنید.متد
ts.Client()کلاینت سرور تست را برمیگرداند — که نوعhttp.Clientدارد — و ما باید همیشه از این کلاینت برای ارسال درخواستها به سرور تست استفاده کنیم. امکان پیکربندی کلاینت برای تنظیم رفتار آن وجود دارد، و ما در پایان این فصل توضیح خواهیم داد که چگونه این کار را انجام دهیم.ممکن است بپرسید چرا ما فیلد
loggerساختارapplicationخود را تنظیم کردهایم، اما هیچیک از فیلدهای دیگر را نه. دلیل این است که logger توسط میانافزارهایlogRequestوrecoverPanicکه در هر مسیر برنامه ما استفاده میشوند، نیاز است. تلاش برای اجرای این تست بدون تنظیم این دو وابستگی منجر به یک 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 که انتظار داشتیم. و این به این دلیل است که ما هنوز یک مسیر GET /ping را با روتر خود ثبت نکردهایم.
بیایید اکنون این را درست کنیم:
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
و سپس کد زیر را اضافه کنید:
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 | مشکلات در آزمایش |