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

تست یکپارچه‌سازی (Integration Testing)

در این بخش، نحوه تست یکپارچه‌سازی (Integration Testing) را بررسی می‌کنیم. این شامل تست پایگاه داده (Database Testing) و تست API (API Testing) می‌شود.

همچنین با محیط تست (Test Environment) و داده‌های آزمایشی (Test Data) آشنا خواهیم شد.

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

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

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

راه‌اندازی و تخریب پایگاه داده آزمایشی

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

اگر در حال دنبال کردن هستید، از پنجره ترمینال خود به MySQL به عنوان کاربر root متصل شوید و دستورات 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. یک اسکریپت راه‌اندازی برای ایجاد جداول پایگاه داده (به طوری که پایگاه داده تولیدی ما را شبیه‌سازی کنند) و وارد کردن مجموعه‌ای از داده‌های آزمایشی که می‌توانیم در تست‌های خود با آن کار کنیم.

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

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

بیایید این اسکریپت‌ها را در یک دایرکتوری جدید 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;

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

$ touch internal/models/testutils_test.go

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

تست متد UserModel.Exists

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

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

بیایید ابتدا به بسته internal/assert خود برویم و یک NilError() جدید ایجاد کنیم، که از آن برای بررسی اینکه مقدار خطا 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) {
    // Skip the test if the "-short" flag is provided when running the test.
    if testing.Short() {
        t.Skip("models: skipping integration test")
    }

    ...
}

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

$ 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 ثانیه زمان کاملاً قابل قبولی برای انتظار برای این تست به تنهایی است، اگر صدها تست یکپارچه‌سازی مختلف را در برابر پایگاه داده خود اجرا کنید، ممکن است به طور معمول منتظر دقیقه‌ها — به جای ثانیه‌ها — برای اتمام تست‌های خود باشید.

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

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

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

بیایید سریعاً 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")
    }

    ...
}

و سپس می‌توانید سعی کنید تمام تست‌های پروژه را با پرچم -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 ما را در طول این اجرا رد کرده است.

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

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
تست یکپارچه‌سازی Integration Testing آزمایش تعامل اجزا
تست پایگاه داده Database Testing آزمایش ذخیره‌سازی
تست API API Testing آزمایش رابط برنامه‌نویسی
محیط تست Test Environment محیط اجرای آزمایش
داده‌های آزمایشی Test Data داده‌های نمونه
پایگاه داده تست Test Database پایگاه داده آزمایشی
تراکنش‌ها Transactions عملیات پایگاه داده
بازیابی داده Data Recovery برگرداندن داده‌ها
اتصال پایگاه داده Database Connection ارتباط با پایگاه داده
مهاجرت داده Data Migration انتقال داده‌ها