شبیهسازی وابستگیها (Mocking Dependencies)
در این بخش، نحوه شبیهسازی وابستگیها (Mocking Dependencies) را بررسی میکنیم. این شامل شبیهسازی پایگاه داده (Database Mocking) و شبیهسازی سرویسها (Service Mocking) میشود.
همچنین با رابطهای شبیهسازی (Mock Interfaces) و دادههای شبیهسازی (Mock Data) آشنا خواهیم شد.
اکنون که برخی الگوهای کلی برای تست برنامه وب خود را توضیح دادهایم، در این فصل قصد داریم کمی جدیتر شویم و برخی تستها را برای مسیر GET /snippet/view/{id} بنویسیم.
اما ابتدا، بیایید درباره وابستگیها صحبت کنیم.
در طول این پروژه، ما وابستگیها را از طریق ساختار application به هندلرها تزریق کردهایم، که در حال حاضر به این شکل است:
type application struct { logger *slog.Logger snippets *models.SnippetModel users *models.UserModel templateCache map[string]*template.Template formDecoder *form.Decoder sessionManager *scs.SessionManager }
هنگام تست، گاهی اوقات منطقی است که این وابستگیها را شبیهسازی کنیم به جای اینکه از همانهایی که در برنامه تولیدی خود استفاده میکنیم، استفاده کنیم.
برای مثال، در فصل قبلی ما وابستگی logger را با یک لاگر که پیامها را به io.Discard مینویسد، شبیهسازی کردیم، به جای os.Stdout و جریان مانند آنچه در برنامه تولیدی خود انجام میدهیم:
func newTestApplication(t *testing.T) *application { return &application{ logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } }
دلیل شبیهسازی این و نوشتن به io.Discard این است که از پر شدن خروجی تست ما با پیامهای لاگ غیرضروری هنگام اجرای go test -v (با حالت verbose فعال) جلوگیری کنیم.
دو وابستگی دیگر که منطقی است آنها را شبیهسازی کنیم، مدلهای پایگاه داده models.SnippetModel و models.UserModel هستند. با ایجاد شبیهسازیهای اینها، میتوانیم رفتار هندلرهای خود را بدون نیاز به راهاندازی یک نمونه تست کامل از پایگاه داده MySQL تست کنیم.
شبیهسازی مدلهای پایگاه داده
اگر در حال دنبال کردن هستید، یک بسته جدید internal/models/mocks ایجاد کنید که شامل فایلهای snippets.go و user.go برای نگهداری شبیهسازیهای مدل پایگاه داده باشد، به این صورت:
$ mkdir internal/models/mocks $ touch internal/models/mocks/snippets.go $ touch internal/models/mocks/users.go
بیایید با ایجاد یک شبیهسازی از models.SnippetModel خود شروع کنیم. برای انجام این کار، قصد داریم یک ساختار ساده ایجاد کنیم که همان متدهای مدل تولیدی models.SnippetModel را پیادهسازی کند، اما متدها دادههای ثابت و جعلی را برگردانند.
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 } }
راهاندازی شبیهسازیها
برای مرحله بعدی در ساخت، بیایید به فایل testutils_test.go برگردیم و تابع newTestApplication() را بهروزرسانی کنیم تا یک ساختار application با تمام وابستگیهای لازم برای تست ایجاد کند.
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
این اتفاق میافتد زیرا ساختار application ما انتظار دارد که اشارهگرهایی به نمونههای models.SnippetModel و models.UserModel داشته باشد، اما ما سعی داریم از اشارهگرهایی به نمونههای mocks.SnippetModel و mocks.UserModel استفاده کنیم.
راهحل ایدیوماتیک برای این مشکل این است که ساختار application خود را تغییر دهیم تا از رابطها استفاده کند که هم مدلهای پایگاه داده شبیهسازی شده و هم تولیدی ما را برآورده میکنند.
برای انجام این کار، بیایید به فایل internal/models/snippets.go برگردیم و یک نوع رابط جدید SnippetModelInterface ایجاد کنیم که متدهایی را که ساختار واقعی 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) } ...
و بیایید همین کار را برای ساختار 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) } ...
اکنون که آن نوعهای رابط را تعریف کردهایم، بیایید ساختار application خود را بهروزرسانی کنیم تا از آنها به جای انواع 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
به عنوان یک نکته جانبی، توجه کنید که چگونه نامهای تستهای فرعی به صورت استاندارد شدهاند؟ Go به طور خودکار هر فضای خالی در نام تست فرعی را با یک زیرخط جایگزین میکند (و هر کاراکتر غیرقابل چاپ نیز در خروجی تست فرار میکند).
تست هندلر snippetView
با این همه اکنون تنظیم شده، بیایید به نوشتن یک تست انتها به انتها برای هندلر snippetView خود که از این وابستگیهای شبیهسازی شده استفاده میکند، بپردازیم.
به عنوان بخشی از این تست، کد در هندلر snippetView ما متد mock.SnippetModel.Get() را فراخوانی خواهد کرد. فقط برای یادآوری، این متد مدل شبیهسازی شده یک models.ErrNoRecord را برمیگرداند مگر اینکه شناسه اسنیپت 1 باشد — که در آن صورت اسنیپت شبیهسازی شده زیر را برمیگرداند:
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با اسنیپت شبیهسازی شده مربوطه در بدنه پاسخ HTML دریافت میکنیم. - برای تمام درخواستهای دیگر به
GET /snippet/view/*باید یک پاسخ404 Not Foundدریافت کنیم.
برای بخش اول اینجا، میخواهیم بررسی کنیم که بدنه درخواست حاوی محتوای خاصی است، به جای اینکه دقیقاً برابر با آن باشد. بیایید سریعاً یک تابع جدید StringContains() به بسته 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) } }) } }
اگر دوباره تستها را با پرچم -v فعال اجرا کنید، باید اکنون تستهای فرعی جدید و موفق 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
به عنوان یک نکته جانبی، توجه کنید که چگونه نامهای تستهای فرعی به صورت استاندارد شدهاند؟ Go به طور خودکار هر فضای خالی در نام تست فرعی را با یک زیرخط جایگزین میکند (و هر کاراکتر غیرقابل چاپ نیز در خروجی تست فرار میکند).
واژهنامه اصطلاحات فنی
| اصطلاح فارسی | معادل انگلیسی | توضیح |
|---|---|---|
| شبیهسازی وابستگیها | Mocking Dependencies | ایجاد نسخههای مجازی |
| شبیهسازی پایگاه داده | Database Mocking | شبیهسازی پایگاه داده |
| شبیهسازی سرویسها | Service Mocking | شبیهسازی خدمات |
| رابطهای شبیهسازی | Mock Interfaces | رابطهای مجازی |
| دادههای شبیهسازی | Mock Data | دادههای مجازی |
| وابستگیهای خارجی | External Dependencies | سرویسهای خارجی |
| تست واحد | Unit Testing | آزمایش جداگانه |
| رفتار مورد انتظار | Expected Behavior | عملکرد پیشبینی شده |
| پیادهسازی مجازی | Mock Implementation | نسخه شبیهسازی شده |
| تزریق وابستگی | Dependency Injection | تزریق سرویسها |