Mock کردن وابستگیها
حالا که الگوهای کلی برای تست برنامه وب خود را توضیح دادهایم، در این فصل کمی جدیتر میشویم و چند تست برای route GET /snippet/view/{id} مینویسیم.
اما ابتدا، بیایید درباره dependencyها صحبت کنیم.
در سراسر این پروژه، dependencyها را از طریق struct application به handlerهای خود تزریق کردهایم که اکنون به این شکل است:
type application struct { logger *slog.Logger snippets *models.SnippetModel users *models.UserModel templateCache map[string]*template.Template formDecoder *form.Decoder sessionManager *scs.SessionManager }
هنگام تست، گاهی منطقی است که این dependencyها را mock کنیم به جای استفاده از دقیقاً همانهایی که در برنامه production خود استفاده میکنیم.
برای مثال، در فصل قبل dependency logger را با یک logger که پیامها را به io.Discard مینویسد mock کردیم، به جای os.Stdout و stream که در برنامه production خود استفاده میکنیم:
func newTestApplication(t *testing.T) *application { return &application{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } }
دلیل mock کردن این و نوشتن به io.Discard این است که از پر شدن خروجی تست با پیامهای log غیرضروری هنگام اجرای go test -v (با حالت verbose فعال) جلوگیری کنیم.
دو dependency دیگر که منطقی است mock کنیم، modelهای پایگاه داده models.SnippetModel و models.UserModel هستند. با ایجاد mockهای اینها میتوانیم رفتار handlerهای خود را بدون نیاز به راهاندازی یک نمونه تست کامل از پایگاه داده MySQL تست کنیم.
Mock کردن modelهای پایگاه داده
اگر همراه با ما پیش میروید، یک package جدید internal/models/mocks حاوی فایلهای snippets.go و user.go برای نگهداری mockهای model پایگاه داده ایجاد کنید، به این شکل:
$ mkdir internal/models/mocks $ touch internal/models/mocks/snippets.go $ touch internal/models/mocks/users.go
بیایید با ایجاد یک mock از models.SnippetModel شروع کنیم. برای انجام این کار، یک struct ساده ایجاد میکنیم که همان متدهای models.SnippetModel تولیدی ما را پیادهسازی میکند، اما متدها به جای آن دادههای dummy ثابتی را برمیگردانند.
package mocks import ( "time" "snippetbox.alexedwards.net/internal/models" ) var mockSnippet = models.Snippet{ ID: 1, Title: "An old silent pond", Content: "An old silent pond...", Created: time.Now(), Expires: time.Now(), } type SnippetModel struct{} func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) { return 2, nil } func (m *SnippetModel) Get(id int) (models.Snippet, error) { switch id { case 1: return mockSnippet, nil default: return models.Snippet{}, models.ErrNoRecord } } func (m *SnippetModel) Latest() ([]models.Snippet, error) { return []models.Snippet{mockSnippet}, nil }
و بیایید همین کار را برای models.UserModel انجام دهیم، به این شکل:
package mocks import ( "snippetbox.alexedwards.net/internal/models" ) type UserModel struct{} func (m *UserModel) Insert(name, email, password string) error { switch email { case "dupe@example.com": return models.ErrDuplicateEmail default: return nil } } func (m *UserModel) Authenticate(email, password string) (int, error) { if email == "alice@example.com" && password == "pa$$word" { return 1, nil } return 0, models.ErrInvalidCredentials } func (m *UserModel) Exists(id int) (bool, error) { switch id { case 1: return true, nil default: return false, nil } }
مقداردهی اولیه mockها
برای مرحله بعدی در build خود، بیایید به فایل testutils_test.go برگردیم و تابع newTestApplication() را بهروزرسانی کنیم تا یک struct application با همه dependencyهای لازم برای تست ایجاد کند.
package main import ( "bytes" "io" "log/slog" "net/http" "net/http/cookiejar" "net/http/httptest" "testing" "time" // New import "snippetbox.alexedwards.net/internal/models/mocks" // New import "github.com/alexedwards/scs/v2" // New import "github.com/go-playground/form/v4" // New import ) func newTestApplication(t *testing.T) *application { // Create an instance of the template cache. templateCache, err := newTemplateCache() if err != nil { t.Fatal(err) } // And a form decoder. formDecoder := form.NewDecoder() // And a session manager instance. Note that we use the same settings as // production, except that we *don't* set a Store for the session manager. // If no store is set, the SCS package will default to using a transient // in-memory store, which is ideal for testing purposes. sessionManager := scs.New() sessionManager.Lifetime = 12 * time.Hour sessionManager.Cookie.Secure = true return &application{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), snippets: &mocks.SnippetModel{}, // Use the mock. users: &mocks.UserModel{}, // Use the mock. templateCache: templateCache, formDecoder: formDecoder, sessionManager: sessionManager, } } ...
اگر پیش بروید و سعی کنید تستها را اکنون اجرا کنید، با خطاهای زیر کامپایل نمیشود:
$ go test ./cmd/web
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:40:19: cannot use &mocks.SnippetModel{} (value of type *mocks.SnippetModel) as type *models.SnippetModel in struct literal
cmd/web/testutils_test.go:41:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type *models.UserModel in struct literal
FAIL snippetbox.alexedwards.net/cmd/web [build failed]
FAIL
این اتفاق میافتد چون struct application ما انتظار pointer به نمونههای models.SnippetModel و models.UserModel را دارد، اما ما سعی میکنیم به جای آن از pointer به نمونههای mocks.SnippetModel و mocks.UserModel استفاده کنیم.
راه حل idiomatic برای این، تغییر struct application است تا از interfaceها استفاده کند که توسط هر دو model پایگاه داده mock و production ما برآورده میشوند.
برای انجام این کار، بیایید به فایل internal/models/snippets.go برگردیم و یک نوع interface جدید SnippetModelInterface ایجاد کنیم که متدهایی را که struct واقعی SnippetModel ما دارد توصیف میکند.
package models import ( "database/sql" "errors" "time" ) type SnippetModelInterface interface { Insert(title string, content string, expires int) (int, error) Get(id int) (Snippet, error) Latest() ([]Snippet, error) } ...
و بیایید همین کار را برای struct UserModel هم انجام دهیم:
package models import ( "database/sql" "errors" "strings" "time" "github.com/go-sql-driver/mysql" "golang.org/x/crypto/bcrypt" ) type UserModelInterface interface { Insert(name, email, password string) error Authenticate(email, password string) (int, error) Exists(id int) (bool, error) } ...
حالا که آن نوعهای interface را تعریف کردهایم، بیایید struct application را بهروزرسانی کنیم تا به جای نوعهای concrete SnippetModel و UserModel از آنها استفاده کند. به این شکل:
package main import ( "crypto/tls" "database/sql" "flag" "html/template" "log/slog" "net/http" "os" "time" "snippetbox.alexedwards.net/internal/models" "github.com/alexedwards/scs/mysqlstore" "github.com/alexedwards/scs/v2" "github.com/go-playground/form/v4" _ "github.com/go-sql-driver/mysql" ) type application struct { logger *slog.Logger snippets models.SnippetModelInterface // Use our new interface type. users models.UserModelInterface // Use our new interface type. templateCache map[string]*template.Template formDecoder *form.Decoder sessionManager *scs.SessionManager } ...
و اگر اکنون دوباره تستها را اجرا کنید، همه چیز باید به درستی کار کند.
$ go test ./cmd/web/ ok snippetbox.alexedwards.net/cmd/web 0.008s
بیایید لحظهای توقف کنیم و درباره آنچه تازه انجام دادهایم تأمل کنیم.
struct application را بهروزرسانی کردهایم تا به جای اینکه فیلدهای snippets و users نوعهای concrete *models.SnippetModel و *models.UserModel داشته باشند، interface باشند.
تا زمانی که یک نوع متدهای لازم برای برآورده کردن interface را داشته باشد، میتوانیم از آنها در struct application استفاده کنیم. هم modelهای پایگاه داده ‘واقعی’ ما (مثل models.SnippetModel) و هم modelهای پایگاه داده mock (مثل mocks.SnippetModel) interfaceها را برآورده میکنند، بنابراین اکنون میتوانیم از آنها به صورت قابل تعویض استفاده کنیم.
تست handler snippetView
با اینکه همه چیز اکنون تنظیم شده است، بیایید شروع به نوشتن یک تست end-to-end برای handler snippetView کنیم که از این dependencyهای mock شده استفاده میکند.
به عنوان بخشی از این تست، کد در handler snippetView ما متد mock.SnippetModel.Get() را فراخوانی میکند. فقط برای یادآوری، این متد model mock شده یک models.ErrNoRecord برمیگرداند مگر اینکه ID snippet 1 باشد — در این صورت snippet mock زیر را برمیگرداند:
var mockSnippet = models.Snippet{ ID: 1, Title: "An old silent pond", Content: "An old silent pond...", Created: time.Now(), Expires: time.Now(), }
بنابراین به طور خاص، میخواهیم تست کنیم که:
- برای درخواست
GET /snippet/view/1یک پاسخ200 OKبا snippet mock مربوط شامل شده در بدنه پاسخ HTML دریافت میکنیم. - برای همه درخواستهای دیگر به
GET /snippet/view/*باید یک پاسخ404 Not Foundدریافت کنیم.
برای بخش اول اینجا، میخواهیم بررسی کنیم که بدنه درخواست شامل محتوای خاصی است، به جای اینکه دقیقاً برابر با آن باشد. بیایید به سرعت یک تابع جدید StringContains() به package assert خود اضافه کنیم تا به این کار کمک کند:
package assert import ( "strings" // New import "testing" ) ... func StringContains(t *testing.T, actual, expectedSubstring string) { t.Helper() if !strings.Contains(actual, expectedSubstring) { t.Errorf("got: %q; expected to contain: %q", actual, expectedSubstring) } }
و سپس فایل cmd/web/handlers_test.go را باز کنید و یک تست جدید TestSnippetView به این شکل ایجاد کنید:
package main ... func TestSnippetView(t *testing.T) { // Create a new instance of our application struct which uses the mocked // dependencies. app := newTestApplication(t) // Establish a new test server for running end-to-end tests. ts := newTestServer(t, app.routes()) defer ts.Close() // Set up some table-driven tests to check the responses sent by our // application for different URLs. tests := []struct { name string urlPath string wantCode int wantBody string }{ { name: "Valid ID", urlPath: "/snippet/view/1", wantCode: http.StatusOK, wantBody: "An old silent pond...", }, { name: "Non-existent ID", urlPath: "/snippet/view/2", wantCode: http.StatusNotFound, }, { name: "Negative ID", urlPath: "/snippet/view/-1", wantCode: http.StatusNotFound, }, { name: "Decimal ID", urlPath: "/snippet/view/1.23", wantCode: http.StatusNotFound, }, { name: "String ID", urlPath: "/snippet/view/foo", wantCode: http.StatusNotFound, }, { name: "Empty ID", urlPath: "/snippet/view/", wantCode: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { code, _, body := ts.get(t, tt.urlPath) assert.Equal(t, code, tt.wantCode) if tt.wantBody != "" { assert.StringContains(t, body, tt.wantBody) } }) } }
اگر تستها را دوباره با flag -v فعال اجرا کنید، اکنون باید sub-testهای جدید و پاس شده TestSnippetView را در خروجی ببینید:
$ go test -v ./cmd/web/
=== RUN TestPing
--- PASS: TestPing (0.00s)
=== RUN TestSnippetView
=== RUN TestSnippetView/Valid_ID
=== RUN TestSnippetView/Non-existent_ID
=== RUN TestSnippetView/Negative_ID
=== RUN TestSnippetView/Decimal_ID
=== RUN TestSnippetView/String_ID
=== RUN TestSnippetView/Empty_ID
--- PASS: TestSnippetView (0.01s)
--- PASS: TestSnippetView/Valid_ID (0.00s)
--- PASS: TestSnippetView/Non-existent_ID (0.00s)
--- PASS: TestSnippetView/Negative_ID (0.00s)
--- PASS: TestSnippetView/Decimal_ID (0.00s)
--- PASS: TestSnippetView/String_ID (0.00s)
--- PASS: TestSnippetView/Empty_ID (0.00s)
=== RUN TestCommonHeaders
--- PASS: TestCommonHeaders (0.00s)
=== RUN TestHumanDate
=== RUN TestHumanDate/UTC
=== RUN TestHumanDate/Empty
=== RUN TestHumanDate/CET
--- PASS: TestHumanDate (0.00s)
--- PASS: TestHumanDate/UTC (0.00s)
--- PASS: TestHumanDate/Empty (0.00s)
--- PASS: TestHumanDate/CET (0.00s)
PASS
ok snippetbox.alexedwards.net/cmd/web 0.015s
به عنوان یک نکته جانبی، متوجه شدید که نام sub-testها چگونه canonicalize شدهاند؟ Go به طور خودکار هر فاصلهای در نام sub-test را با یک underscore جایگزین میکند (و هر کاراکتر غیرقابل چاپ نیز escape میشود) در خروجی تست.