Let's Go Testing › Unit testing and sub-tests
قبلی · فهرست · بعدی
فصل 13.1.

تست واحد و تست‌های فرعی (Unit Testing and Sub-Tests)

در این بخش، با تست واحد (Unit Testing) و تست‌های فرعی (Sub-Tests) در Go آشنا می‌شویم. این شامل تست جدول‌محور (Table-Driven Testing) و تست موازی (Parallel Testing) می‌شود.

همچنین با توابع کمکی تست (Test Helper Functions) و مقایسه نتایج (Result Comparison) آشنا خواهیم شد.

در این فصل، ما یک تست واحد ایجاد خواهیم کرد تا مطمئن شویم که تابع humanDate() (که در فصل توابع قالب سفارشی ایجاد کردیم) مقادیر time.Time را در قالب دقیق مورد نظر ما خروجی می‌دهد.

اگر به یاد نمی‌آورید، تابع humanDate() به این شکل است:

File: cmd/web/templates.go
package main

...

func humanDate(t time.Time) string {
    return t.Format("02 Jan 2006 at 15:04")
}

...

دلیل اینکه می‌خواهم با تست این تابع شروع کنم این است که تابع ساده‌ای است. ما می‌توانیم نحو و الگوهای پایه برای نوشتن تست‌ها را بدون گرفتار شدن در عملکردی که در حال تست آن هستیم، بررسی کنیم.

ایجاد یک تست واحد

بیایید مستقیماً وارد شویم و یک تست واحد برای این تابع ایجاد کنیم.

در Go، معمولاً تست‌های خود را در فایل‌های *_test.go می‌نویسید که مستقیماً در کنار کدی که در حال تست آن هستید قرار دارند. بنابراین، در این مورد، اولین کاری که انجام خواهیم داد ایجاد یک فایل جدید cmd/web/templates_test.go برای نگهداری تست است:

$ touch cmd/web/templates_test.go

و سپس می‌توانیم یک تست واحد جدید برای تابع humanDate به این شکل ایجاد کنیم:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"
)

func TestHumanDate(t *testing.T) {
    // Initialize a new time.Time object and pass it to the humanDate function.
    tm := time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC)
    hd := humanDate(tm)

    // Check that the output from the humanDate function is in the format we
    // expect. If it isn't what we expect, use the t.Errorf() function to
    // indicate that the test has failed and log the expected and actual
    // values.
    if hd != "17 Mar 2024 at 10:15" {
        t.Errorf("got %q; want %q", hd, "17 Mar 2024 at 10:15")
    }
}

این الگو، الگوی پایه‌ای است که برای تقریباً تمام تست‌هایی که در Go می‌نویسید استفاده خواهید کرد. نکات مهمی که باید به خاطر بسپارید عبارتند از:

بیایید این را امتحان کنیم. فایل را ذخیره کنید، سپس از دستور go test برای اجرای تمام تست‌ها در بسته cmd/web ما به این شکل استفاده کنید:

$ go test ./cmd/web
ok      snippetbox.alexedwards.net/cmd/web    0.005s

بنابراین، این چیز خوبی است. ok در این خروجی نشان می‌دهد که تمام تست‌ها در بسته (فعلاً، فقط تست TestHumanDate() ما) بدون هیچ مشکلی گذرانده شده‌اند.

اگر جزئیات بیشتری می‌خواهید، می‌توانید دقیقاً ببینید که کدام تست‌ها در حال اجرا هستند با استفاده از پرچم -v برای دریافت خروجی مفصل:

$ go test -v ./cmd/web
=== RUN   TestHumanDate
--- PASS: TestHumanDate (0.00s)
PASS
ok      snippetbox.alexedwards.net/cmd/web    0.007s

تست‌های مبتنی بر جدول

بیایید اکنون تابع TestHumanDate() خود را گسترش دهیم تا برخی موارد تست اضافی را پوشش دهیم. به طور خاص، ما قصد داریم آن را به‌روزرسانی کنیم تا همچنین بررسی کند که:

  1. اگر ورودی به humanDate() زمان صفر باشد، سپس رشته خالی "" را برمی‌گرداند.

  2. خروجی از تابع humanDate() همیشه از منطقه زمانی UTC استفاده می‌کند.

در Go، یک روش ایدئال برای اجرای چندین مورد تست استفاده از تست‌های مبتنی بر جدول است.

اساساً، ایده پشت تست‌های مبتنی بر جدول این است که یک ‘جدول’ از موارد تست حاوی ورودی‌ها و خروجی‌های مورد انتظار ایجاد کنید و سپس بر روی این‌ها حلقه بزنید و هر مورد تست را در یک زیرتست اجرا کنید. چندین روش برای تنظیم این وجود دارد، اما یک روش معمول این است که موارد تست خود را در یک برش از ساختارهای ناشناس تعریف کنید.

من نشان خواهم داد:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"
)

func TestHumanDate(t *testing.T) {
    // Create a slice of anonymous structs containing the test case name,
    // input to our humanDate() function (the tm field), and expected output
    // (the want field).
    tests := []struct {
        name string
        tm   time.Time
        want string
    }{
        {
            name: "UTC",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC),
            want: "17 Mar 2024 at 10:15",
        },
        {
            name: "Empty",
            tm:   time.Time{},
            want: "",
        },
        {
            name: "CET",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
            want: "17 Mar 2024 at 09:15",
        },
    }

    // Loop over the test cases.
    for _, tt := range tests {
        // Use the t.Run() function to run a sub-test for each test case. The
        // first parameter to this is the name of the test (which is used to
        // identify the sub-test in any log output) and the second parameter is
        // and anonymous function containing the actual test for each case.
        t.Run(tt.name, func(t *testing.T) {
            hd := humanDate(tt.tm)

            if hd != tt.want {
                t.Errorf("got %q; want %q", hd, tt.want)
            }
        })
    }
}

خوب، بیایید این را اجرا کنیم و ببینیم چه اتفاقی می‌افتد:

$ go test -v ./cmd/web
=== RUN   TestHumanDate
=== RUN   TestHumanDate/UTC
=== RUN   TestHumanDate/Empty
    templates_test.go:44: got "01 Jan 0001 at 00:00"; want ""
=== RUN   TestHumanDate/CET
    templates_test.go:44: got "17 Mar 2024 at 10:15"; want "17 Mar 2024 at 09:15"
--- FAIL: TestHumanDate (0.00s)
    --- PASS: TestHumanDate/UTC (0.00s)
    --- FAIL: TestHumanDate/Empty (0.00s)
    --- FAIL: TestHumanDate/CET (0.00s)
FAIL
FAIL    snippetbox.alexedwards.net/cmd/web      0.003s
FAIL

بنابراین در اینجا می‌توانیم خروجی فردی برای هر یک از زیرتست‌های خود را ببینیم. همانطور که ممکن است حدس زده باشید، مورد اول تست ما گذشت اما تست‌های Empty و CET هر دو شکست خوردند. توجه کنید که — برای موارد تست ناموفق — ما پیام شکست مربوطه و نام فایل و شماره خط را در خروجی دریافت می‌کنیم؟

بیایید به تابع humanDate() خود برگردیم و آن را به‌روزرسانی کنیم تا این دو مشکل را برطرف کنیم:

File: cmd/web/templates.go
package main

...

func humanDate(t time.Time) string {
    // Return the empty string if time has the zero value.
    if t.IsZero() {
        return ""
    }

    // Convert the time to UTC before formatting it.
    return t.UTC().Format("02 Jan 2006 at 15:04")
}

...

و هنگامی که دوباره تست‌ها را اجرا کنید، همه چیز باید اکنون بگذرد.

$ go test -v ./cmd/web
=== 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.003s

کمک‌کننده‌ها برای ادعاهای تست

همانطور که قبلاً در کتاب به طور مختصر اشاره کردم، در فصل‌های بعدی ما تعداد زیادی ادعای تست خواهیم نوشت که یک تغییر از این الگو هستند:

if actualValue != expectedValue {
    t.Errorf("got %v; want %v", actualValue, expectedValue)
}

بیایید به سرعت این کد را به یک تابع کمک‌کننده انتزاع کنیم.

اگر در حال دنبال کردن هستید، بروید و یک بسته جدید internal/assert ایجاد کنید:

$ mkdir internal/assert
$ touch internal/assert/assert.go

و سپس کد زیر را اضافه کنید:

File: internal/assert/assert.go
package assert

import (
    "testing"
)

func Equal[T comparable](t *testing.T, actual, expected T) {
    t.Helper()

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

توجه کنید که Equal() یک تابع عمومی است؟ این به این معنی است که ما قادر خواهیم بود از آن استفاده کنیم بدون توجه به اینکه نوع مقادیر actual و expected چیست. تا زمانی که هر دو actual و expected نوع یکسانی داشته باشند و بتوانند با استفاده از عملگر != مقایسه شوند (برای مثال، هر دو مقادیر string یا هر دو مقادیر int باشند) کد تست ما باید کامپایل شود و به خوبی کار کند وقتی که Equal() را فراخوانی می‌کنیم.

با این کار، ما می‌توانیم تست TestHumanDate() خود را به این شکل ساده کنیم:

File: cmd/web/templates_test.go
package main

import (
    "testing"
    "time"

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

func TestHumanDate(t *testing.T) {
    tests := []struct {
        name string
        tm   time.Time
        want string
    }{
        {
            name: "UTC",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.UTC),
            want: "17 Mar 2024 at 10:15",
        },
        {
            name: "Empty",
            tm:   time.Time{},
            want: "",
        },
        {
            name: "CET",
            tm:   time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
            want: "17 Mar 2024 at 09:15",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            hd := humanDate(tt.tm)

            // Use the new assert.Equal() helper to compare the expected and 
            // actual values.
            assert.Equal(t, hd, tt.want)
        })
    }
}

اطلاعات اضافی

زیرتست‌ها بدون جدول موارد تست

مهم است که اشاره کنیم که نیازی نیست که زیرتست‌ها را همراه با تست‌های مبتنی بر جدول (مانند آنچه تا کنون در این فصل انجام داده‌ایم) استفاده کنید. اجرای زیرتست‌ها با فراخوانی t.Run() به صورت متوالی در توابع تست شما کاملاً معتبر است، مشابه این:

func TestExample(t *testing.T) {
    t.Run("Example sub-test 1", func(t *testing.T) {
        // Do a test.
    })

    t.Run("Example sub-test 2", func(t *testing.T) {
        // Do another test.
    })

    t.Run("Example sub-test 3", func(t *testing.T) {
        // And another...
    })
}

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

اصطلاح فارسی معادل انگلیسی توضیح
تست واحد Unit Testing آزمایش بخش‌های کوچک کد
تست‌های فرعی Sub-Tests آزمایش‌های زیرمجموعه
تست جدول‌محور Table-Driven Testing آزمایش با داده‌های متعدد
تست موازی Parallel Testing اجرای همزمان تست‌ها
توابع کمکی تست Test Helper Functions توابع کمکی برای تست
مقایسه نتایج Result Comparison بررسی نتایج مورد انتظار
پیش‌نیازهای تست Test Prerequisites شرایط لازم برای تست
داده‌های تست Test Data داده‌های مورد نیاز تست
گزارش خطا Error Reporting گزارش مشکلات تست
پاکسازی تست Test Cleanup پاکسازی بعد از تست