Let's Go تست › شبیه‌سازی وابستگی‌ها
قبلی · فهرست · بعدی
فصل 13.5.

شبیه‌سازی وابستگی‌ها (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 را پیاده‌سازی کند، اما متدها داده‌های ثابت و جعلی را برگردانند.

File: internal/models/mocks/snippets.go
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 خود انجام دهیم، به این صورت:

File: internal/models/mocks/users.go
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 با تمام وابستگی‌های لازم برای تست ایجاد کند.

File: cmd/web/testutils_test.go
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 ما دارد، توصیف کند.

File: internal/models/snippets.go
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 خود نیز انجام دهیم، به این صورت:

File: internal/models/users.go
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 استفاده کند. به این صورت:

File: cmd/web/main.go
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(),
}

بنابراین به طور خاص، می‌خواهیم تست کنیم که:

  1. برای درخواست GET /snippet/view/1 یک پاسخ 200 OK با اسنیپت شبیه‌سازی شده مربوطه در بدنه پاسخ HTML دریافت می‌کنیم.
  2. برای تمام درخواست‌های دیگر به GET /snippet/view/* باید یک پاسخ 404 Not Found دریافت کنیم.

برای بخش اول اینجا، می‌خواهیم بررسی کنیم که بدنه درخواست حاوی محتوای خاصی است، به جای اینکه دقیقاً برابر با آن باشد. بیایید سریعاً یک تابع جدید StringContains() به بسته assert خود اضافه کنیم تا در این مورد کمک کند:

File: internal/assert/assert.go
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 ایجاد کنید به این صورت:

File: cmd/web/handlers_test.go
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 تزریق سرویس‌ها