Let's Go تست › تست یکپارچگی
قبلی · فهرست · بعدی
فصل ۱۳.۷.

تست یکپارچگی

اجرای تست‌های end-to-end با dependencyهای mock شده کار خوبی است، اما می‌توانیم اطمینان بیشتری به برنامه خود داشته باشیم اگر همچنین تأیید کنیم که modelهای پایگاه داده MySQL واقعی ما به درستی کار می‌کنند.

برای انجام این کار می‌توانیم تست‌های یکپارچه‌سازی را در برابر یک نسخه تست از پایگاه داده MySQL خود اجرا کنیم که پایگاه داده production ما را تقلید می‌کند اما فقط برای اهداف تست وجود دارد.

به عنوان یک نمایش، در این فصل یک تست یکپارچگی تنظیم می‌کنیم تا اطمینان حاصل کنیم که متد models.UserModel.Exists() ما به درستی کار می‌کند.

راه‌اندازی و teardown پایگاه داده تست

اولین قدم ایجاد نسخه تست از پایگاه داده MySQL ما است.

اگر همراه با ما پیش می‌روید، از پنجره ترمینال خود به عنوان کاربر root به MySQL متصل شوید و دستورات SQL زیر را برای ایجاد یک پایگاه داده جدید test_snippetbox و کاربر test_web اجرا کنید:

CREATE DATABASE test_snippetbox CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'test_web'@'localhost';
GRANT CREATE, DROP, ALTER, INDEX, SELECT, INSERT, UPDATE, DELETE ON test_snippetbox.* TO 'test_web'@'localhost';
ALTER USER 'test_web'@'localhost' IDENTIFIED BY 'pass';

پس از انجام این کار، بیایید دو اسکریپت SQL بسازیم:

  1. یک اسکریپت setup برای ایجاد جداول پایگاه داده (تا پایگاه داده production ما را تقلید کنند) و درج یک مجموعه داده تست شناخته شده که بتوانیم در تست‌های خود با آن کار کنیم.

  2. یک اسکریپت teardown که جداول و داده‌های پایگاه داده را حذف می‌کند.

ایده این است که این اسکریپت‌ها را در ابتدا و انتهای هر تست یکپارچگی فراخوانی کنیم، تا پایگاه داده تست هر بار به طور کامل reset شود. این کمک می‌کند تا اطمینان حاصل کنیم که هر تغییری که در طول یک تست ایجاد می‌کنیم ‘نشت’ نمی‌کند و نتایج تست دیگر را تحت تأثیر قرار نمی‌دهد.

بیایید پیش برویم و این اسکریپت‌ها را در یک دایرکتوری جدید internal/models/testdata به این شکل ایجاد کنیم:

$ mkdir internal/models/testdata
$ touch internal/models/testdata/setup.sql
$ touch internal/models/testdata/teardown.sql
File: internal/models/testdata/setup.sql
CREATE TABLE snippets (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(100) NOT NULL,
    content TEXT NOT NULL,
    created DATETIME NOT NULL,
    expires DATETIME NOT NULL
);

CREATE INDEX idx_snippets_created ON snippets(created);

CREATE TABLE users (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    hashed_password CHAR(60) NOT NULL,
    created DATETIME NOT NULL
);

ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);

INSERT INTO users (name, email, hashed_password, created) VALUES (
    'Alice Jones',
    'alice@example.com',
    '$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG',
    '2022-01-01 09:18:24'
);
File: internal/models/testdata/teardown.sql
DROP TABLE users;

DROP TABLE snippets;

خوب، حالا که اسکریپت‌ها در جای خود قرار دارند، بیایید یک فایل جدید برای نگه‌داری برخی توابع helper برای تست‌های یکپارچه‌سازی خود بسازیم:

$ touch internal/models/testutils_test.go

در این فایل بیایید یک تابع helper newTestDB() ایجاد کنیم که:

File: internal/models/testutils_test.go
package models

import (
    "database/sql"
    "os"
    "testing"
)

func newTestDB(t *testing.T) *sql.DB {
    // Establish a sql.DB connection pool for our test database. Because our
    // setup and teardown scripts contains multiple SQL statements, we need
    // to use the "multiStatements=true" parameter in our DSN. This instructs
    // our MySQL database driver to support executing multiple SQL statements
    // in one db.Exec() call.
    db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true")
    if err != nil {
        t.Fatal(err)
    }

    // Read the setup SQL script from the file and execute the statements, closing
    // the connection pool and calling t.Fatal() in the event of an error.
    script, err := os.ReadFile("./testdata/setup.sql")
    if err != nil {
        db.Close()
        t.Fatal(err)
    }
    _, err = db.Exec(string(script))
    if err != nil {
        db.Close()
        t.Fatal(err)
    }

    // Use t.Cleanup() to register a function *which will automatically be
    // called by Go when the current test (or sub-test) which calls newTestDB() 
    // has finished*. In this function we read and execute the teardown script, 
    // and close the database connection pool.
    t.Cleanup(func() {
        defer db.Close()

        script, err := os.ReadFile("./testdata/teardown.sql")
        if err != nil {
            t.Fatal(err)
        }
        _, err = db.Exec(string(script))
        if err != nil {
            t.Fatal(err)
        }
    })

    // Return the database connection pool.
    return db
}

نکته مهمی که باید از اینجا بگیریم این است:

هر زمان که این تابع newTestDB() را در داخل یک تست (یا sub-test) فراخوانی کنیم، اسکریپت setup را در برابر پایگاه داده تست اجرا می‌کند. و وقتی تست یا sub-test تمام می‌شود، تابع cleanup به طور خودکار اجرا می‌شود و اسکریپت teardown اجرا می‌شود.

تست متد UserModel.Exists

حالا که کارهای مقدماتی انجام شده است، آماده‌ایم که در واقع تست یکپارچگی خود را برای متد models.UserModel.Exists() بنویسیم.

می‌دانیم که اسکریپت setup.sql ما یک جدول users حاوی یک رکورد ایجاد می‌کند (که باید ID کاربر 1 و آدرس ایمیل alice@example.com داشته باشد). بنابراین می‌خواهیم تست کنیم که:

ابتدا بیایید به package internal/assert خود برویم و یک assertion جدید NilError() ایجاد کنیم که از آن برای بررسی اینکه یک مقدار error nil است استفاده خواهیم کرد. به این شکل:

File: internal/assert/assert.go
package assert

...

func NilError(t *testing.T, actual error) {
    t.Helper()

    if actual != nil {
        t.Errorf("got: %v; expected: nil", actual)
    }
}

سپس بیایید از قراردادهای Go پیروی کنیم و یک فایل جدید users_test.go برای تست خود ایجاد کنیم، مستقیماً در کنار کدی که تست می‌شود:

$ touch internal/models/users_test.go

و یک تست TestUserModelExists حاوی کد زیر اضافه کنید:

File: internal/models/users_test.go
package models

import (
    "testing"

    "snippetbox.alexedwards.net/internal/assert"
)

func TestUserModelExists(t *testing.T) {
    // Set up a suite of table-driven tests and expected results.
    tests := []struct {
        name   string
        userID int
        want   bool
    }{
        {
            name:   "Valid ID",
            userID: 1,
            want:   true,
        },
        {
            name:   "Zero ID",
            userID: 0,
            want:   false,
        },
        {
            name:   "Non-existent ID",
            userID: 2,
            want:   false,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // Call the newTestDB() helper function to get a connection pool to
            // our test database. Calling this here -- inside t.Run() -- means
            // that fresh database tables and data will be set up and torn down
            // for each sub-test.
            db := newTestDB(t)

            // Create a new instance of the UserModel.
            m := UserModel{db}

            // Call the UserModel.Exists() method and check that the return
            // value and error match the expected values for the sub-test.
            exists, err := m.Exists(tt.userID)

            assert.Equal(t, exists, tt.want)
            assert.NilError(t, err)
        })
    }
}

اگر این تست را اجرا کنید، همه چیز باید بدون هیچ مشکلی پاس شود.

$ go test -v ./internal/models
=== RUN   TestUserModelExists
=== RUN   TestUserModelExists/Valid_ID
=== RUN   TestUserModelExists/Zero_ID
=== RUN   TestUserModelExists/Non-existent_ID
--- PASS: TestUserModelExists (1.02s)
    --- PASS: TestUserModelExists/Valid_ID (0.33s)
    --- PASS: TestUserModelExists/Zero_ID (0.29s)
    --- PASS: TestUserModelExists/Non-existent_ID (0.40s)
PASS
ok      snippetbox.alexedwards.net/internal/models      1.023s

آخرین خط در خروجی تست اینجا ارزش ذکر دارد. زمان کل اجرا برای این تست (1.023 ثانیه در مورد من) بسیار طولانی‌تر از تست‌های قبلی ما است — که همه آن‌ها چند میلی‌ثانیه طول کشیدند. این افزایش بزرگ در زمان اجرا عمدتاً به دلیل تعداد زیاد عملیات پایگاه داده است که در طول تست‌ها نیاز داشتیم انجام دهیم.

در حالی که 1 ثانیه زمان کاملاً قابل قبولی برای انتظار برای این تست به صورت جداگانه است، اگر صدها تست یکپارچگی مختلف را در برابر پایگاه داده خود اجرا می‌کنید، ممکن است به طور معمول دقیقه‌ها — به جای ثانیه‌ها — منتظر بمانید تا تست‌های شما تمام شوند.

رد کردن تست‌های طولانی

وقتی تست‌های شما زمان زیادی می‌برند، ممکن است تصمیم بگیرید که می‌خواهید تست‌های طولانی خاص را در شرایط خاصی رد کنید. برای مثال، ممکن است تصمیم بگیرید که فقط تست‌های یکپارچه‌سازی خود را قبل از commit کردن یک تغییر اجرا کنید، به جای اینکه بیشتر در طول توسعه اجرا کنید.

یک راه رایج و idiomatic برای رد کردن تست‌های طولانی، استفاده از تابع testing.Short() برای بررسی وجود flag -short در دستور go test است، و سپس فراخوانی متد t.Skip() برای رد کردن تست در صورت وجود flag.

بیایید به سرعت TestUserModelExists را برای انجام این کار قبل از اجرای تست‌های واقعی آن به‌روزرسانی کنیم، به این شکل:

File: internal/models/users_test.go
package models

import (
    "testing"

    "snippetbox.alexedwards.net/internal/assert"
)

func TestUserModelExists(t *testing.T) {
    // Skip the test if the "-short" flag is provided when running the test.
    if testing.Short() {
        t.Skip("models: skipping integration test")
    }

    ...
}

و سپس می‌توانید سعی کنید همه تست‌های پروژه را با flag -short فعال اجرا کنید. خروجی باید مشابه این باشد:

$ go test -v -short ./...
=== 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   TestUserSignup
=== RUN   TestUserSignup/Valid_submission
=== RUN   TestUserSignup/Invalid_CSRF_Token
=== RUN   TestUserSignup/Empty_name
=== RUN   TestUserSignup/Empty_email
=== RUN   TestUserSignup/Empty_password
=== RUN   TestUserSignup/Invalid_email
=== RUN   TestUserSignup/Short_password
=== RUN   TestUserSignup/Long_password
=== RUN   TestUserSignup/Duplicate_email
--- PASS: TestUserSignup (0.01s)
    --- PASS: TestUserSignup/Valid_submission (0.00s)
    --- PASS: TestUserSignup/Invalid_CSRF_Token (0.00s)
    --- PASS: TestUserSignup/Empty_name (0.00s)
    --- PASS: TestUserSignup/Empty_email (0.00s)
    --- PASS: TestUserSignup/Empty_password (0.00s)
    --- PASS: TestUserSignup/Invalid_email (0.00s)
    --- PASS: TestUserSignup/Short_password (0.00s)
    --- PASS: TestUserSignup/Long_password (0.00s)
    --- PASS: TestUserSignup/Duplicate_email (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.023s
=== RUN   TestUserModelExists
    users_test.go:10: models: skipping integration test
--- SKIP: TestUserModelExists (0.00s)
PASS
ok      snippetbox.alexedwards.net/internal/models      0.003s
?       snippetbox.alexedwards.net/internal/models/mocks        [no test files]
?       snippetbox.alexedwards.net/internal/validator   [no test files]
?       snippetbox.alexedwards.net/ui   [no test files]

متوجه حاشیه‌نویسی SKIP در خروجی بالا شدید؟ این تأیید می‌کند که Go تست TestUserModelExists ما را در این اجرا رد کرد.

اگر دوست دارید، آزادانه این را دوباره بدون flag -short اجرا کنید، و باید ببینید که تست TestUserModelExists به طور عادی اجرا می‌شود.