تست واحد و تستهای فرعی (Unit Testing and Sub-Tests)
در این بخش، با تست واحد (Unit Testing) و تستهای فرعی (Sub-Tests) در Go آشنا میشویم. این شامل تست جدولمحور (Table-Driven Testing) و تست موازی (Parallel Testing) میشود.
همچنین با توابع کمکی تست (Test Helper Functions) و مقایسه نتایج (Result Comparison) آشنا خواهیم شد.
در این فصل، ما یک تست واحد ایجاد خواهیم کرد تا مطمئن شویم که تابع humanDate() (که در فصل توابع قالب سفارشی ایجاد کردیم) مقادیر 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 برای دریافت خروجی مفصل:
$ go test -v ./cmd/web === RUN TestHumanDate --- PASS: TestHumanDate (0.00s) PASS ok snippetbox.alexedwards.net/cmd/web 0.007s
تستهای مبتنی بر جدول
بیایید اکنون تابع TestHumanDate() خود را گسترش دهیم تا برخی موارد تست اضافی را پوشش دهیم. به طور خاص، ما قصد داریم آن را بهروزرسانی کنیم تا همچنین بررسی کند که:
اگر ورودی به
humanDate()زمان صفر باشد، سپس رشته خالی""را برمیگرداند.خروجی از تابع
humanDate()همیشه از منطقه زمانی UTC استفاده میکند.
در 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() خود برگردیم و آن را بهروزرسانی کنیم تا این دو مشکل را برطرف کنیم:
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
و سپس کد زیر را اضافه کنید:
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() خود را به این شکل ساده کنیم:
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 | پاکسازی بعد از تست |