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

تست واحد و زیرتست‌ها

در این فصل یک تست واحد ایجاد می‌کنیم تا مطمئن شویم که تابع humanDate() ما (که در فصل توابع template سفارشی ساختیم) مقادیر 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 برای دریافت خروجی verbose:

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

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

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

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

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

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

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

نشان می‌دهم:

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

تابع‌های helper برای assertionهای تست

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

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

بیایید به سرعت این کد را به یک تابع helper تبدیل کنیم.

اگر همراه ما هستید، بروید و یک بسته جدید 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() یک تابع generic است؟ این یعنی که می‌توانیم از آن بدون توجه به نوع مقادیر 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...
    })
}