تستنویسی یکی از ارکان اصلی توسعه نرمافزار است که نقش کلیدی در تضمین کیفیت و عملکرد صحیح برنامهها در چرخه عمر توسعه نرمافزار (SDLC) ایفا میکند. تستها اطمینان میدهند که کد شما بهدرستی کار میکند و نیازهای کاربران را برآورده میسازد. در این مقاله، بهطور جامع به تستنویسی در زبان برنامهنویسی گو (Golang) میپردازیم. از یک تست واحد ساده شروع میکنیم و به ابزارها و استراتژیهای پیشرفتهتر مانند تستهای جدولمحور، بنچمارک، فازینگ و استفاده از کتابخانه Testify میرسیم.
شما در این مقاله با موارد زیر آشنا خواهید شد:
- تستهای واحد: برای بررسی بخشهای کوچک کد.
- تستهای جدولمحور: برای سازماندهی بهتر موارد تست.
- تستهای بنچمارک: برای ارزیابی عملکرد کد.
- تستهای فازینگ: برای کشف باگها و موارد حاشیهای.
- پوشش کد (Code Coverage): برای ارزیابی میزان تستشده کد.
- کتابخانه Testify: برای بهبود خوانایی و موکینگ در تستها.
چرا تستنویسی مهم است؟
تستنویسی، بهویژه تستهای واحد، به شما امکان میدهد تا:
- اعتماد به کد: هنگام بازسازی (Refactoring) کد، اطمینان داشته باشید که عملکرد آن حفظ میشود.
- مستندسازی: تستها بهعنوان مستنداتی از رفتار مورد انتظار کد عمل میکنند.
- کیفیت بهتر API: طراحی تستها باعث میشود APIهای بهتری طراحی کنید.
- جلوگیری از رگرسیون: تستهای خودکار در خطوط لوله CI/CD از ایجاد باگهای جدید جلوگیری میکنند.
نوشتن یک تست واحد ساده
تستهای واحد روشی برای بررسی بخشهای کوچک کد، مانند توابع و متدها، هستند. این تستها به دلیل مستقل و کوچک بودن، نگهداری آسانی دارند و به کشف زودهنگام باگها کمک میکنند. برای آشنایی با مزایا و رویکرد ها و سرمایه گذاری در تست های واحد این مقاله را بررسی کنید.
مثال: تابع Fooer
تابعی به نام Fooer ایجاد میکنیم که یک عدد صحیح دریافت میکند و اگر بر ۳ بخشپذیر باشد، رشته "Foo" و در غیر این صورت، عدد را بهصورت رشته برمیگرداند. این یک نسخه سادهشده از مسئله FooBarQix است.
1// fooer.go 2package main 3 4import "strconv" 5 6// اگر عدد بر 3 بخشپذیر باشد، "Foo" را برمیگرداند، در غیر این صورت عدد را بهصورت رشته برمیگرداند 7func Fooer(input int) string { 8 isfoo := (input % 3) == 0 9 if isfoo { 10 return "Foo" 11 } 12 return strconv.Itoa(input) 13}
نوشتن تست واحد
تستهای واحد در گو در همان پکیج تابع موردنظر قرار میگیرند. طبق قرارداد، اگر تابع در فایل fooer.go باشد، فایل تست آن باید fooer_test.go نامگذاری شود.
تست زیر بررسی میکند که تابع Fooer برای ورودی ۳، رشته "Foo" را برمیگرداند:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooer(t *testing.T) { 7 result := Fooer(3) 8 if result != "Foo" { 9 t.Errorf("نتیجه نادرست است، دریافت شده: %s، انتظار میرفت: %s.", result, "Foo") 10 } 11}
تابع تست با پیشوند Test شروع میشود و تنها پارامتر آن از نوع *testing.T است. بسته testing ابزارهایی مانند t.Errorf را برای گزارش خطاها ارائه میدهد.
اجرای تستها
برای اجرای تستها، از دستور زیر استفاده کنید:
1go test
خروجی مشابه این خواهد بود:
1PASS 2ok example.com/fooer 0.002s
در GoLand، میتوانید با کلیک روی فلش سبز کنار تابع تست، آن را اجرا کنید. نتایج در پنجره ابزار Run نمایش داده میشود. برای اجرای همه تستهای پکیج، روی آیکون دو مثلث سبز در بالای فایل کلیک کنید.
آشنایی با اپ سرور مدرن PHP به نام PHPFranken
7 اصول تست نویسی که هر توسعه دهنده باید بداند
امنیت در داکر: آشنایی با 14 تا از بهترین روش های امنیتی
تستهای جدولمحور (Table-Driven Tests)
تکرار کد در تستها برای پوشش همه موارد میتواند وقتگیر و ناکارآمد باشد. رویکرد تست جدولمحور این مشکل را با سازماندهی موارد تست در یک ساختار جدولی حل میکند.
مزایای تستهای جدولمحور
- کاهش تکرار: منطق تأیید یکبار نوشته میشود.
- شفافیت: ورودیها و خروجیهای مورد انتظار بهصورت واضح در جدول قابلمشاهده هستند.
- نامگذاری معنادار: هر تست میتواند نامی منحصربهفرد داشته باشد تا هدف آن مشخص شود.
مثال تست جدولمحور
تابع تست جدولمحور برای Fooer:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooerTableDriven(t *testing.T) { 7 var tests = []struct { 8 name string 9 input int 10 want string 11 }{ 12 {"9 باید Foo باشد", 9, "Foo"}, 13 {"3 باید Foo باشد", 3, "Foo"}, 14 {"1 نباید Foo باشد", 1, "1"}, 15 {"0 باید Foo باشد", 0, "Foo"}, 16 } 17 18 for _, tt := range tests { 19 t.Run(tt.name, func(t *testing.T) { 20 ans := Fooer(tt.input) 21 if ans != tt.want { 22 t.Errorf("دریافت شده: %s، انتظار میرفت: %s", ans, tt.want) 23 } 24 }) 25 } 26}
این تست چهار زیرتست (subtest) ایجاد میکند که هرکدام یک مورد را بررسی میکنند. در GoLand، میتوانید با راستکلیک روی تابع و انتخاب "Generate | Test for function"، قالب تست جدولمحور را بهصورت خودکار تولید کنید.
بسته Testing در گو
بسته testing ابزارهای قدرتمندی برای نوشتن تستها فراهم میکند. نوع testing.T امکاناتی مانند اجرای موازی، نادیده گرفتن تستها، و تمیزکاری را ارائه میدهد.
مدیریت خطاها و لاگها
t.Errorf برای گزارش خطاها و t.Fatalf برای توقف فوری تست استفاده میشود. تابع t.Logf برای چاپ اطلاعات در حین اجرا مفید است:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooer2(t *testing.T) { 7 input := 3 8 result := Fooer(3) 9 t.Logf("ورودی: %d", input) 10 if result != "Foo" { 11 t.Errorf("نتیجه نادرست است، دریافت شده: %s، انتظار میرفت: %s.", result, "Foo") 12 } 13 t.Fatalf("تست متوقف شد") 14 t.Error("این خط اجرا نمیشود") 15}
اجرای تستهای موازی
تستها بهصورت پیشفرض بهصورت ترتیبی اجرا میشوند، اما با متد Parallel() میتوانید تستها را موازی اجرا کنید. این کار با متوقف کردن تستها و ازسرگیری آنها بهصورت موازی انجام میشود:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooerParallel(t *testing.T) { 7 t.Run("تست 3 بهصورت موازی", func(t *testing.T) { 8 t.Parallel() 9 result := Fooer(3) 10 if result != "Foo" { 11 t.Errorf("نتیجه نادرست است، دریافت شده: %s، انتظار میرفت: %s.", result, "Foo") 12 } 13 }) 14 t.Run("تست 7 بهصورت موازی", func(t *testing.T) { 15 t.Parallel() 16 result := Fooer(7) 17 if result != "7" { 18 t.Errorf("نتیجه نادرست است، دریافت شده: %s، انتظار میرفت: %s.", result, "7") 19 } 20 }) 21}
GoLand وضعیت تستها (RUN، PAUSE، CONT) را در حین اجرا نمایش میدهد.
نادیده گرفتن تستها
متد Skip() برای جداسازی تستهای واحد از تستهای ادغام (Integration) استفاده میشود. برای مثال، با پرچم -test.short میتوانید تستهای زمانبر را نادیده بگیرید:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooerSkiped(t *testing.T) { 7 if testing.Short() { 8 t.Skip("نادیده گرفتن تست در حالت کوتاه") 9 } 10 result := Fooer(3) 11 if result != "Foo" { 12 t.Errorf("نتیجه نادرست است، دریافت شده: %s، انتظار میرفت: %s.", result, "Foo") 13 } 14}
برای اجرای تستهای کوتاه:
1go test -v -test.short
تمیزکاری تستها
متد Cleanup() برای مدیریت تمیزکاری پس از تستها استفاده میشود. این روش نسبت به استفاده از defer خواناتر است:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestWithCleanup(t *testing.T) { 7 t.Cleanup(func() { 8 // منطق تمیزکاری 9 }) 10 // کد تست 11}
همچنین، متد TempDir() یک دایرکتوری موقت برای تست ایجاد میکند و پس از اتمام تست آن را حذف میکند:
1// fooer_test.go 2package main 3 4import "testing" 5 6func TestFooerTempDir(t *testing.T) { 7 tmpDir := t.TempDir() 8 // تستها 9}
پوشش کد (Code Coverage)
پوشش کد معیاری برای ارزیابی میزان تستشده کد است. برای ایجاد گزارش پوشش کد، از پرچم -cover استفاده کنید:
1go test -cover
خروجی نمونه:
1PASS 2coverage: 80.0% of statements 3ok example.com/fooer 0.002s
برای گزارش دقیقتر، از پرچم -coverprofile استفاده کنید:
1go test -coverprofile=coverage.out 2go tool cover -html=coverage.out
این دستور یک گزارش HTML تولید میکند که خطوط تستشده (سبز) و تستنشده (قرمز) را نمایش میدهد. در GoLand، با انتخاب "Run with Coverage"، گزارش پوشش کد بهصورت گرافیکی نمایش داده میشود.
تستهای بنچمارک
تستهای بنچمارک برای ارزیابی عملکرد کد استفاده میشوند. این تستها زمان اجرا و مصرف حافظه را با اجرای مکرر تابع بررسی میکنند.
مثال بنچمارک
1// fooer_test.go 2package main 3 4import "testing" 5 6func BenchmarkFooer(b *testing.B) { 7 for i := 0; i < b.N; i++ { 8 Fooer(i) 9 } 10}
خروجی نمونه:
1BenchmarkFooer-8 59969790 19 ns/op
t.N تعداد تکرارها را بهصورت خودکار تنظیم میکند تا نتایج پایدار باشند.
تستهای فازینگ
تستهای فازینگ ورودیهای تصادفی را برای کشف باگها و موارد حاشیهای استفاده میکنند. الگوریتم فازینگ گو سعی میکند تمام شاخههای کد را پوشش دهد.
مثال فازینگ
1// fooer_test.go 2package main 3 4import "testing" 5 6func FuzzFooer(f *testing.F) { 7 f.Add(3) 8 f.Fuzz(func(t *testing.T, a int) { 9 Fooer(a) 10 }) 11}
برای اجرای تست فازینگ در GoLand، روی فلش سبز کلیک کنید و گزینه Run | go test -fuzz را انتخاب کنید.
استفاده از Testify
Testify یک کتابخانه محبوب برای بهبود خوانایی تستها و ارائه قابلیتهای موکینگ است. برای نصب:
1go get github.com/stretchr/testify
مثال با Testify
1// fooer_test.go 2package main 3 4import ( 5 "testing" 6 "github.com/stretchr/testify/assert" 7) 8 9func TestFooerWithTestify(t *testing.T) { 10 assert.Equal(t, "Foo", Fooer(0), "0 بر 3 بخشپذیر است، باید Foo برگرداند") 11 assert.NotEqual(t, "Foo", Fooer(1), "1 بر 3 بخشپذیر نیست، نباید Foo برگرداند") 12}
بسته assert اجازه میدهد خطاها جمعآوری شوند، در حالی که require تست را در صورت شکست فوراً متوقف میکند.
پروژه نمونه: تستنویسی برای مدیریت دسترسیها
برای مثال عملی، یک پروژه ساده برای مدیریت دسترسیها (Entitlements) بررسی میکنیم. این پروژه شامل ساختاری برای مدیریت تعداد اعضای تیم در یک برنامه وب با محدودیتهای کاربران رایگان (حداکثر ۳ عضو) و پریمیوم (حداکثر ۱۰ عضو) است.
کد پروژه
1// entitlement.go 2package main 3 4import "errors" 5 6type Entitlement struct { 7 Teams struct { 8 Enabled bool `json:"enabled,omitempty"` 9 Size *Counter `json:"size,omitempty"` 10 } `json:"teams,omitempty"` 11} 12 13var ( 14 ErrCounterExhausted = errors.New("شمارنده تمام شده است") 15 ErrNegativeCounter = errors.New("شمارنده نمیتواند منفی باشد") 16) 17 18type Counter int64 19 20func NewCounter(i int64) *Counter { 21 c := Counter(i) 22 return &c 23} 24 25func (c *Counter) Value() int64 { 26 if *c == 0 { 27 return 0 28 } 29 return int64(*c) 30} 31 32func (c *Counter) Take() error { return c.TakeN(1) } 33 34func (c *Counter) TakeN(i int64) error { 35 if *c <= 0 { 36 return ErrCounterExhausted 37 } 38 if remaining := *c - Counter(i); remaining < 0 { 39 return ErrNegativeCounter 40 } 41 *c = remaining 42 return nil 43}
تست پروژه
1// entitlement_test.go 2package main 3 4import ( 5 "errors" 6 "testing" 7) 8 9func TestCounter_Take(t *testing.T) { 10 testTable := []struct { 11 name string 12 startingValue int64 13 expectedValue int64 14 valueToRemove int64 15 gotError error 16 }{ 17 {"یک آیتم با موفقیت حذف میشود", 10, 9, 1, nil}, 18 {"همه آیتمها با موفقیت حذف میشوند", 10, 0, 10, nil}, 19 {"نمیتوان بیشتر از مقدار شمارنده حذف کرد", 10, 0, 16, ErrNegativeCounter}, 20 {"گزارش خطای اتمام شمارنده", 0, 0, 1, ErrCounterExhausted}, 21 } 22 23 for _, v := range testTable { 24 t.Run(v.name, func(t *testing.T) { 25 c := NewCounter(v.startingValue) 26 var err error 27 if v.valueToRemove == 1 { 28 err = c.Take() 29 } else { 30 err = c.TakeN(v.valueToRemove) 31 } 32 if v.gotError != nil { 33 if !errors.Is(err, v.gotError) { 34 t.Fatalf("انتظار خطا: %v، دریافت شد: %v", v.gotError, err) 35 } 36 return 37 } 38 if c.Value() != v.expectedValue { 39 t.Fatalf("انتظار: %d، دریافت شد: %d", v.expectedValue, c.Value()) 40 } 41 }) 42 } 43}
اجرای تستها
1go test -v
خروجی:
1=== RUN TestCounter_Take 2=== RUN TestCounter_Take/یک_آیتم_با_موفقیت_حذف_میشود 3=== RUN TestCounter_Take/همه_آیتمها_با_موفقیت_حذف_میشوند 4=== RUN TestCounter_Take/نمیتوان_بیشتر_از_مقدار_شمارنده_حذف_کرد 5=== RUN TestCounter_Take/گزارش_خطای_اتمام_شمارنده 6--- PASS: TestCounter_Take (0.00s) 7 --- PASS: TestCounter_Take/یک_آیتم_با_موفقیت_حذف_میشود (0.00s) 8 --- PASS: TestCounter_Take/همه_آیتمها_با_موفقیت_حذف_میشوند (0.00s) 9 --- PASS: TestCounter_Take/نمیتوان_بیشتر_از_مقدار_شمارنده_حذف_کرد (0.00s) 10 --- PASS: TestCounter_Take/گزارش_خطای_اتمام_شمارنده (0.00s) 11PASS 12ok example.com/unittests 0.001s
نتیجه
تستنویسی در گو با استفاده از بسته استاندارد testing و ابزارهایی مانند Testify ساده و قدرتمند است. شما میتوانید تستهای واحد، جدولمحور، بنچمارک و فازینگ را بهراحتی پیادهسازی کنید. همچنین، ابزارهایی مانند GoLand با قابلیتهایی مانند تولید خودکار تستها، گزارش پوشش کد و ادغام با Testify، بهرهوری شما را افزایش میدهند.
برای مطالعه بیشتر، به مستندات بسته testing و مخزن نمونه این مقاله مراجعه کنید. تستنویسی را به بخشی از جریان توسعه خود تبدیل کنید تا کدی با کیفیت و قابلاعتماد تولید کنید.
