تست واحد و زیرتستها
در این فصل یک تست واحد ایجاد میکنیم تا مطمئن شویم که تابع humanDate() ما (که در فصل توابع template سفارشی ساختیم) مقادیر time.Time را در فرمت دقیقی که میخواهیم خروجی میدهد.
اگر یادتان نیست، تابع humanDate() به این صورت است:
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 به این صورت ایجاد کنیم:
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 معمولی است که تابع
humanDate()را فراخوانی میکند و بررسی میکند که نتیجه با آنچه انتظار داریم مطابقت دارد.تستهای واحد شما در یک تابع Go معمولی با امضای
func(*testing.T)قرار دارند.برای اینکه یک تست واحد معتبر باشد، نام این تابع باید با کلمه
Testشروع شود. معمولاً پس از آن نام تابع، متد یا نوعی که تست میکنید میآید تا در یک نگاه مشخص شود چه چیزی در حال تست است.میتوانید از تابع
t.Errorf()برای علامتگذاری یک تست به عنوان ناموفق و ثبت یک پیام توصیفی درباره شکست استفاده کنید. نکته مهم این است که فراخوانیt.Errorf()اجرای تست شما را متوقف نمیکند — پس از فراخوانی آن، 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() خود را گسترش دهیم تا برخی موردهای تست اضافی را پوشش دهد. به طور خاص، میخواهیم آن را بهروزرسانی کنیم تا همچنین بررسی کند که:
اگر ورودی به
humanDate()zero time است، سپس رشته خالی""را برمیگرداند.خروجی از تابع
humanDate()همیشه از time zone UTC استفاده میکند.
در Go، یک روش idiomatic برای اجرای چندین مورد تست استفاده از تستهای مبتنی بر جدول است.
اساساً، ایده پشت تستهای مبتنی بر جدول ایجاد یک ‘جدول’ از موردهای تست حاوی ورودیها و خروجیهای مورد انتظار است، و سپس حلقه زدن روی اینها، اجرای هر مورد تست در یک زیرتست. چند روش برای تنظیم این وجود دارد، اما یک رویکرد رایج تعریف موردهای تست خود در یک slice از structهای anonymous است.
نشان میدهم:
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() خود برگردیم و آن را بهروزرسانی کنیم تا این دو مشکل را برطرف کنیم:
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
و سپس کد زیر را اضافه کنید:
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() خود را به این صورت ساده کنیم:
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... }) }