Let's Go تست › Mock کردن وابستگی‌ها
قبلی · فهرست · بعدی
فصل ۱۳.۵.

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 ثابتی را برمی‌گردانند.

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
    }
}

مقداردهی اولیه mockها

برای مرحله بعدی در build خود، بیایید به فایل testutils_test.go برگردیم و تابع newTestApplication() را به‌روزرسانی کنیم تا یک struct application با همه dependencyهای لازم برای تست ایجاد کند.

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

این اتفاق می‌افتد چون 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 ما دارد توصیف می‌کند.

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)
}

...

و بیایید همین کار را برای struct 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)
}

...

حالا که آن نوع‌های interface را تعریف کرده‌ایم، بیایید struct application را به‌روزرسانی کنیم تا به جای نوع‌های concrete 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

بیایید لحظه‌ای توقف کنیم و درباره آنچه تازه انجام داده‌ایم تأمل کنیم.

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(),
}

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

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

برای بخش اول اینجا، می‌خواهیم بررسی کنیم که بدنه درخواست شامل محتوای خاصی است، به جای اینکه دقیقاً برابر با آن باشد. بیایید به سرعت یک تابع جدید StringContains() به package 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)
            }
        })
    }
}

اگر تست‌ها را دوباره با 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 می‌شود) در خروجی تست.