تست واحد یکی از مهمترین و بنیادیترین ابزارهاییست که یک توسعهدهنده حرفهای در اختیار دارد. این نوع تست، تنها بر یک «واحد» مستقل از منطق برنامه تمرکز میکند—معمولاً یک تابع، یک متد، یا یک ساختار کوچک از کد که بدون وابستگی به منابع خارجی قابل بررسی است. هدف اصلی از نوشتن تست واحد، اطمینان از صحت رفتار دقیق و قابل پیشبینی کد در مواجهه با ورودیهای مشخص و شرایط کنترلشده است.
در زبان Go، فلسفه طراحی بر سادگی، سرعت و ابزارهای داخلی استوار است؛ این رویکرد بهوضوح در ساختار تستنویسی نیز دیده میشود. بدون نیاز به هیچگونه چارچوب یا ابزار جانبی، میتوان تنها با استفاده از فایلهای _test.go
و توابع TestXxx(t *testing.T)
تستهای کاملی برای هر ماژول نوشت. این تستها بهصورت یکپارچه با ابزار رسمی go test
اجرا میشوند و خروجیای دقیق، سریع و قابلدرک ارائه میدهند.
ویژگیهایی مانند اجرای تستهای جداگانه با -run
، دستهبندی تستها با t.Run
، پوشش کد با -cover
، تستهای موازی با t.Parallel()
و پشتیبانی کامل از ساختارهای ساده Go، باعث شدهاند که تست واحد نه فقط یک ابزار، بلکه یک عادت طبیعی در سبک توسعه Go محسوب شود. در عمل، یونیت تستها نهتنها در کشف باگها مؤثرند، بلکه مرجعی قابلاطمینان برای تعریف رفتار و مستندسازی سیستم نیز بهشمار میآیند.
در ادامه این بخش، ابتدا به ساختار پایه تست واحد در Go میپردازیم، سپس الگوهای رایج، ضدالگوها، نکات پیشرفته و مثالهایی از دنیای واقعی را بررسی خواهیم کرد.
به نقل از ویکی پدیا آزمون واحد (به انگلیسی: unit testing) در برنامهنویسی رایانهای، نوعی آزمون نرمافزار است که در آن «واحدهای منفرد کد منبع» مورد آزمون قرار میگیرند تا تعیین شود که آیا برای استفاده سازگار هستند یا نه. در اینجا «واحد منفرد کد منبع» یعنی مجموعهای از یک یا بیشتر پودمان برنامه رایانهای، همراه با داده کنترلی مرتبط، رویه استفاده، و رویه عملیاتی.[۱]
آزمونهای واحد معمولاً آزمونهایی خودکار هستند که توسط توسعهدهنده نرمافزار نوشته و اجرا میشوند، این آزمون برای آن انجام میشود تا اطمینان حاصل شود که بخشی از یک برنامهکاربردی (که «واحد» نام دارد) طراحی را برآورده کردهاست و رفتارش هم براساس انتظار است.[۲]
7.2.1 اصول نوشتن تستهای واحد در Go و ساختار آنها #
نوشتن تستهای واحد در زبان Go ساده، شفاف و کاملاً منطبق با فلسفهی طراحی زبان است: حداقل پیچیدگی، حداکثر خوانایی و وابستگی صفر به چارچوبهای جانبی. در Go، تستهای واحد در فایلهایی با پسوند _test.go
تعریف میشوند و باید در همان پکیجی باشند که کد اصلی قرار دارد یا در پکیجی مجزا با پسوند _test
برای جداسازی وابستگیها.
هر تست واحد باید با تابعی به فرم زیر آغاز شود:
func TestXxx(t *testing.T)
که در آن Xxx
میتواند هر نام معناداری باشد که با حرف بزرگ آغاز شده است (برای شناسایی توسط ابزار go test
) و پارامتر t
از نوع تی
بوده و برای مدیریت وضعیت تست بهکار میرود.
7.2.1.1 ساختار پایه تست واحد #
package main
import (
"fmt"
"testing"
)
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2,3) = %d; want %d", got, want)
}
}
در این مثال ساده:
- تابع
Add
بررسی میشود. - نتیجه با مقدار مورد انتظار مقایسه شده.
- در صورت مغایرت، پیامی با
t.Errorf
ثبت میشود که باعث شکست تست میگردد.
7.2.1.2 نقش تابع t.Fail
, t.Error
, و t.Fatal
#
در تستهای Go چندین روش برای ثبت خطا وجود دارد:
تابع | توضیح |
---|---|
t.Error | ثبت خطا و ادامه اجرای تست |
t.Errorf | مانند t.Error اما با فرمتدهی رشته |
t.Fail | فقط ثبت خطا بدون پیام |
t.Fatal | ثبت خطا و توقف فوری اجرای تابع تست |
t.Fatalf | مانند t.Fatal با امکان فرمتدهی |
نمونه:
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
7.2.1.3 الزامات ابزار go test #
برای اینکه go test
تستها را شناسایی و اجرا کند:
- تابع تست باید با
Test
شروع شود. - آرگومان آن باید دقیقاً
t *testing.T
باشد. - نباید مقدار برگشتی داشته باشد.
- فایل باید پسوند
_test.go
داشته باشد.
7.2.1.4 نحوه سازماندهی فایلها و پوشهها #
در Go، تستها معمولاً در کنار کد اصلی قرار میگیرند:
calculator/
├── add.go
└── add_test.go
اما برای تست رفتار خارجی بدون دسترسی به توابع یا متغیرهای داخلی، میتوان از پکیج mypkg_test
استفاده کرد که نسخهای مجزا از پکیج اصلی بدون دسترسی داخلی است:
package mypkg_test
این روش برای نوشتن تستهای سطح بالاتر یا رفتار مصرفکننده بسیار مناسب است.
7.2.1.5 اجرای تست #
اجرای ساده:
go test
اجرای یک تابع خاص:
go test -run=TestAdd
اجرا با جزئیات بیشتر (verbose):
go test -v
7.2.1.6 چرا این ساختار موفق است؟ #
دلایل موفقیت رویکرد Go در تستهای واحد:
- خوانایی بالا: هر تست بهراحتی قابل درک و تحلیل است.
- حداقل boilerplate: بدون نیاز به setup یا framework.
- اجرای سریع: تستها بلافاصله اجرا میشوند.
- پشتیبانی بومی ابزارها: بدون وابستگی خارجی.
7.2.2 روشهای مدیریت خطا در تستها #
در تستهای واحد، بررسی رفتار توابع در مواجهه با خطا یکی از حیاتیترین جنبههاست. بسیاری از توابع در Go بهجای پرتاب استثنا، مقادیر error
بازمیگردانند، بنابراین انتظار بروز خطا یا نبود خطا بخشی مهم از منطق تست است. تستی که فقط مقدار بازگشتی موفق را بررسی کند، ناقص است. یک تست کامل باید حالتهای failure را نیز پوشش دهد.
7.2.2.1 بررسی خطاهای مورد انتظار #
در بسیاری از مواقع، یک تابع در شرایط خاص باید خطا بازگرداند. تست صحیح باید این خطا را بررسی کرده و اطمینان حاصل کند که نوع خطا، پیام و زمان وقوع آن دقیقاً مطابق انتظار است.
package main
import (
"fmt"
"testing"
)
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "cannot divide by zero" {
t.Errorf("unexpected error message: %v", err)
}
}
7.2.2.2 تست موفقیت در غیاب خطا #
در طرف دیگر، باید اطمینان حاصل کنیم که در شرایط صحیح، تابع بدون خطا عمل میکند:
package main
import (
"fmt"
"testing"
)
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
func TestDivideSuccess(t *testing.T) {
res, err := Divide(10, 2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res != 5 {
t.Errorf("expected 5, got %d", res)
}
}
7.2.2.3 استفاده از errors.Is و errors.As #
در شرایط حرفهای، بهجای بررسی پیام خطا، بهتر است از توابع استاندارد کتابخانه errors
برای بررسی نوع خطا استفاده شود، خصوصاً زمانی که خطاها wrap میشوند.
package main
import (
"fmt"
"testing"
)
var ErrDivideByZero = errors.New("cannot divide by zero")
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, fmt.Errorf("wrap: %w", ErrDivideByZero)
}
return a / b, nil
}
func TestDivideByZeroTypedError(t *testing.T) {
_, err := Divide(1, 0)
if !errors.Is(err, ErrDivideByZero) {
t.Errorf("expected ErrDivideByZero, got %v", err)
}
}
7.2.2.4 پوشش تمامی مسیرهای منطقی #
هر تابعی که دارای چند مسیر شرطی است، باید در تستهای واحد بهصورت جداگانه در تمامی مسیرها آزمایش شود. عدم پوشش یکی از مسیرها میتواند منجر به بروز باگهای پنهان در آینده شود.
برای مثال، تابع زیر دو مسیر دارد:
package main
import (
"fmt"
"testing"
)
func Authenticate(token string) error {
if token == "" {
return errors.New("token required")
}
return nil
}
func TestAuthenticate(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"valid token", "abc123", false},
{"empty token", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Authenticate(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("unexpected error result. got error=%v", err)
}
})
}
}
این تکنیک با نام تست جدولمحور شناخته میشود و در بخش بعدی بهصورت مفصل بررسی خواهد شد.
7.2.3 تست توابع غیرصادرشده (Unexported) و کاربرد پکیج _test #
در Go، توابع یا انواعی که با حرف کوچک آغاز میشوند، تنها در محدوده همان پکیج قابل مشاهده هستند. این ویژگی در راستای اصل کپسولهسازی طراحی شده است. اما گاهی لازم است این توابع غیرصادرشده را نیز مورد تست قرار دهیم. برای این کار، چند رویکرد رایج وجود دارد که هرکدام بسته به هدف تست قابل انتخاب هستند.
7.2.3.1 نوشتن تست در همان پکیج #
در سادهترین حالت، فایل تست را در همان پکیجی مینویسیم که کد اصلی قرار دارد. این روش به تست دسترسی کامل به توابع، متغیرها و انواع داخلی را میدهد.
package main
import (
"fmt"
"testing"
)
func subtract(a, b int) int {
return a - b
}
func TestSubtract(t *testing.T) {
if subtract(5, 3) != 2 {
t.Errorf("expected 2, got %d", subtract(5, 3))
}
}
این روش توصیهشده و رسمیترین راه برای تست موارد غیرصادرشده است.
7.2.3.2 استفاده از پکیج تست جداگانه (_test) #
اگر بخواهیم تستها کاملاً از رفتار داخلی جدا باشند و فقط از رابط عمومی (public API) استفاده کنند، میتوانیم از یک پکیج با پسوند _test
استفاده کنیم. این روش برای نوشتن تستهای سطح بالاتر یا رفتاری مناسب است.
// file: api_test.go
package math_test
import (
"testing"
"mymodule/math"
)
func TestAddPublic(t *testing.T) {
got := math.Add(1, 2)
if got != 3 {
t.Errorf("expected 3, got %d", got)
}
}
در این حالت:
- توابع داخلی (مثل
subtract
) دیگر قابل دسترسی نیستند. - تنها توابع صادرشده در دسترس هستند.
- این یک تست “از بیرون” یا “black-box” محسوب میشود.
7.2.3.3 تست غیرمستقیم توابع داخلی #
اگر تابع غیرصادرشده بهصورت مستقیم قابل تست نیست ولی از طریق توابع صادرشده فراخوانی میشود، بهترین راه تست آن بهصورت غیرمستقیم و از طریق خروجی تابع صادرشده است. به این ترتیب، تست به ساختار داخلی وابسته نخواهد بود.
مزیت این روش:
- تست پایدارتر باقی میماند.
- وابستگی به جزییات پیادهسازی کاهش مییابد.
7.2.3.4 مزایا و معایب هر رویکرد #
روش | مزایا | معایب |
---|---|---|
پکیج داخلی (package x ) | دسترسی کامل به کد | وابستگی مستقیم به پیادهسازی |
پکیج _test | تست رفتاری و بدون وابستگی به داخل | عدم امکان تست مستقیم کد داخلی |
تست غیرمستقیم | افزایش انعطاف تست و پایداری طولانیمدت | پوشش دقیق همه مسیرها ممکن نیست |
7.2.3.5 نکات تجربی #
- تست توابع داخلی اگر رفتار پیچیدهای دارند، ضروری است.
- اگر یک تابع داخلی توسط تابع صادرشدهای فراخوانی نمیشود، بهتر است به صورت مستقل در همان پکیج تست شود.
- از پکیج
_test
در تستهای انتهایی یا برای نوشتن سناریوهای بلندمدت استفاده کنید.
7.2.4 نکات کاربردی و اشتباهات رایج در تستهای واحد #
یونیت تستنویسی در Go به دلیل سادگی و سرعت بالا، بسیار محبوب است؛ اما همین سادگی ممکن است باعث شود برخی نکات ظریف، اما مهم، نادیده گرفته شوند. در این بخش، مجموعهای از توصیههای عملی و اشتباهات رایج در یونیت تستها را مرور میکنیم که رعایت آنها منجر به تستهایی دقیقتر، پایدارتر و قابل نگهداریتر خواهد شد.
7.2.4.1 تفاوت بین t.Error و t.Fatal را بشناسید #
یکی از اشتباهات رایج، استفاده اشتباه از t.Fatal
بهجای t.Error
یا بالعکس است.
- از
t.Error
زمانی استفاده کنید که میخواهید خطا را ثبت کنید ولی اجازه دهید تست ادامه پیدا کند. - از
t.Fatal
زمانی استفاده کنید که ادامهی تست بیمعنا است و باید بلافاصله متوقف شود.
مثال:
if err != nil {
t.Fatal("cannot continue test, error:", err)
}
if result != expected {
t.Error("wrong result, got", result)
}
اگر با یک خطای بحرانی مثل failure در ورودی مواجه شدید،
Fatal
مناسبتر است.
7.2.4.2 از time.Sleep در یونیت تست استفاده نکنید #
استفاده از time.Sleep
برای منتظر ماندن در تست باعث ایجاد تستهای ناپایدار و کند میشود. بهجای آن، از تکنیکهای مبتنی بر کانال یا تکرارهای سریع (retry) استفاده کنید.
❌ بد:
time.Sleep(100 * time.Millisecond)
✅ بهتر:
for i := 0; i < 100; i++ {
if ready() {
break
}
time.Sleep(1 * time.Millisecond)
}
7.2.4.3 تستهای وابسته به زمان را کنترل کنید #
تستهایی که از time.Now()
یا time.Since()
استفاده میکنند باید طوری طراحی شوند که قابل پیشبینی باقی بمانند. راهکار:
- تزریق زمان (dependency injection)
- استفاده از clock mock
7.2.4.4 از t.TempDir()
برای ساخت فایل موقت استفاده کنید
#
اگر تست نیاز به فایل یا پوشه موقتی دارد، بهجای نوشتن مسیر دستی از t.TempDir()
استفاده کنید:
func TestWriteFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "file.txt")
_ = os.WriteFile(path, []byte("data"), 0644)
// فایل بعد از تست بهصورت خودکار پاک میشود
}
7.2.4.5 مراقب caching ناخواسته باشید #
هنگام اجرای مکرر تستها، اگر خروجی تستها در حال تغییر باشد، ممکن است به دلیل کش شدن نتیجه، نتیجه قبلی دوباره نمایش داده شود. برای جلوگیری:
go test -count=1
یا پاکسازی کامل کش:
go clean -testcache
7.2.4.6 تست نباید فقط تابع را صدا بزند #
یونیت تستهایی که تنها تابع را اجرا میکنند ولی هیچ بررسیای انجام نمیدهند، بیفایدهاند.
❌ بد:
func TestNoCheck(t *testing.T) {
DoSomething()
}
✅ درست:
func TestDoSomething(t *testing.T) {
result := DoSomething()
if result != expected {
t.Errorf("expected %v, got %v", expected, result)
}
}
7.2.4.7 گزارشدهی دقیق بنویسید #
در هنگام ثبت خطا با t.Errorf
یا t.Fatalf
، حتماً اطلاعات کامل بدهید:
t.Errorf("expected %d, got %d", want, got)
این کار در زمان تحلیل خروجی تستها بسیار مفید است.
7.2.4.8 نامگذاری مناسب تستها #
نام تست باید گویای هدف تست باشد:
func TestDivideByZeroReturnsError(t *testing.T) { ... }
func TestAddPositiveNumbers(t *testing.T) { ... }
از نامهایی مثل Test1
, TestA
, TestXYZ
اجتناب کنید.
7.2.5 تست واحد برای ساختارها و متدهای گیرنده #
در زبان Go، بسیاری از قابلیتهای سطح بالا از طریق متدهایی روی ساختارها (structs) پیادهسازی میشوند. این متدها بسته به نوع گیرندهشان (value receiver
یا pointer receiver
) و میزان وابستگیشان به وضعیت داخلی ساختار، نیاز به طراحی تست دقیقتری دارند.
در این بخش، بررسی میکنیم که چگونه میتوان به صورت مؤثر برای متدهای متصل به ساختارها تست واحد نوشت.
7.2.5.1 تست متدهای گیرنده مقداری (value receiver) #
اگر متدی گیرنده مقداری دارد، معمولاً روی یک نسخهی کپیشده از ساختار عمل میکند و تغییری در وضعیت اصلی ایجاد نمیکند. تست چنین متدهایی بسیار ساده است.
مثال:
type Point struct {
X, Y int
}
func (p Point) IsOrigin() bool {
return p.X == 0 && p.Y == 0
}
تست:
func TestPoint_IsOrigin(t *testing.T) {
p := Point{X: 0, Y: 0}
if !p.IsOrigin() {
t.Error("expected true, got false")
}
}
7.2.5.2 تست متدهای گیرنده اشارهگری (pointer receiver) #
اگر متد وضعیت داخلی ساختار را تغییر میدهد یا به صورت اشارهگری تعریف شده، در تست باید دقت بیشتری کرد.
مثال:
func (p *Point) Move(dx, dy int) {
p.X += dx
p.Y += dy
}
تست:
func TestPoint_Move(t *testing.T) {
p := &Point{X: 1, Y: 2}
p.Move(3, 4)
if p.X != 4 || p.Y != 6 {
t.Errorf("expected (4,6), got (%d,%d)", p.X, p.Y)
}
}
توجه: حتماً باید از اشارهگر (
&Point{...}
) استفاده شود، چون متد روی pointer تعریف شده است.
7.2.5.3 تست چند متد روی یک نمونه #
اگر چند متد متوالی روی یک نمونه اعمال میشود (تغییر وضعیت گامبهگام)، بهتر است تست در قالب subtest یا جدول تست طراحی شود تا خوانایی حفظ شود.
func TestPoint_Sequence(t *testing.T) {
p := &Point{}
t.Run("Move", func(t *testing.T) {
p.Move(2, 2)
})
t.Run("Check", func(t *testing.T) {
if p.X != 2 || p.Y != 2 {
t.Errorf("expected (2,2), got (%d,%d)", p.X, p.Y)
}
})
}
7.2.5.4 جداسازی لایه logic از state #
در پروژههای واقعی، اگر متد ساختار منطق پیچیده دارد، توصیه میشود محاسبات را از وضعیت جدا کرده و در توابع مستقل pure قرار دهید تا تستپذیرتر شود.
مثال بهینهشده:
func calculateNewPosition(x, y, dx, dy int) (int, int) {
return x + dx, y + dy
}
این تابع را میتوان به راحتی در یونیت تست مستقل بررسی کرد، بدون نیاز به ساختار.
7.2.5.5 رفتار پیشفرض در مقادیر تهی (zero value) #
در Go، مقدار پیشفرض (zero value) برای structها معتبر است و معمولاً باید بتوان از آن استفاده کرد. تست این رفتار برای ساختارهایی که متد دارند مهم است.
func TestZeroValueBehavior(t *testing.T) {
var p Point
if !p.IsOrigin() {
t.Error("expected origin from zero value")
}
}
7.2.5.6 ترکیب متد و خطا #
در صورتی که متدی روی struct خطا بازمیگرداند، حتماً باید شرایط موفق/ناموفق را تست کنید:
package main
import (
"fmt"
"testing"
)
type User struct {
Email string
}
func (u *User) Validate() error {
if u.Email == "" {
return errors.New("email required")
}
return nil
}
func TestUser_Validate(t *testing.T) {
tests := []struct {
email string
wantErr bool
}{
{"", true},
{"hello@example.com", false},
}
for _, tt := range tests {
u := &User{Email: tt.email}
err := u.Validate()
if (err != nil) != tt.wantErr {
t.Errorf("unexpected error state for email %q", tt.email)
}
}
}
7.2.6 تفکیک تستهای سریع و آهسته با -short #
در پروژههای واقعی، برخی تستها بسیار سریع هستند و بلافاصله اجرا میشوند، اما برخی دیگر—بهدلایل مختلفی مانند تعامل با فایل، شبکه، زمانسنجی، یا دیتابیس—کندتر هستند و ممکن است زمانبر یا شکننده (flaky) باشند. ابزار go test
راهکاری ساده برای تفکیک این دو نوع تست فراهم کرده است: استفاده از فلگ -short
.
هنگامی که دستور زیر اجرا میشود:
go test -short
آرگومان -short
به تمامی توابع تست بهصورت خودکار ارسال میشود. سپس داخل کد میتوان با استفاده از متد testing.Short()
بررسی کرد که آیا این تست باید اجرا شود یا رد شود.
7.2.6.1 مثال کاربردی #
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("skipping slow test in short mode")
}
time.Sleep(5 * time.Second) // تست کند
t.Log("test completed")
}
در اجرای معمولی:
go test
تست اجرا میشود. اما در حالت short:
go test -short
خروجی:
--- SKIP: TestSlowOperation (0.00s)
slow_test.go:4: skipping slow test in short mode
PASS
7.2.6.2 مزایا #
- اجرای سریعتر تستها در حالت پیشفرض
- مناسب برای CI pipelines سبک یا اجراهای local
- حذف تستهایی که به منابع خارجی یا شرایط خاص نیاز دارند
7.2.6.3 نکته مهم #
بهتر است تستهای کند را با شرط testing.Short()
کنترل کنید نه اینکه بهطور کلی حذفشان کنید یا در فایل جداگانه بگذارید. این کار نگهداری و اجرای تستها را انعطافپذیرتر میکند.
7.2.6.4 ترکیب با ابزارهای دیگر #
در سیستمهای CI/CD، میتوان دو مرحله اجرای تست داشت:
go test -short ./... # فقط تستهای سریع
go test ./... # همه تستها (مثلاً فقط در زمان انتشار نسخه)