در این آموزش میخواهیم در مورد تعریف تست واحد( Unit Test)، ویژگی های آن و بایدها و نبایدهایی که هنگام نوشتن تست واحد در نظر گرفت صحبت کنیم.
مفهوم تست واحد (Unit Test) در گولنگ
تست واحد یعنی بررسی درستکارکردن کوچکترین بخش قابل تست کد (واحد = Unit) بهصورت ایزوله، یعنی بدون وابستگی واقعی به پایگاه داده، شبکه، فایلسیستم یا بخشهای دیگر برنامه. تعریف تست واحد در زبان برنامه نویسی گولنگ میتونه یک:
-
تابع (مثلاً
Sum،ValidateEmail) -
متد یک struct
-
ماژول کوچک (فایل/پکیج مستقل با یک API کوچک)
باشد، که بتواند بدون درگیرشدن با سایر قسمتها، منطق خودش را انجام دهد.
ویژگیهای یک Unit Test
هر Unit Test باید همیشه این ۴ ویژگی را داشته باشد:
-
ایزوله بودن → اگر تابع شما به دیتابیس وصل میشود، در Unit Test باید mock یا fake جایگزین کنید. با این کار، بخش هایی که مربوط به منطق کد شما نیستند را شبیه سازی میکنید و به این طریق میتوانید عملکرد کدتان را بسنجید.
-
سریع بودن → تست شما نباید کند باشد (برخلاف integration یا end-to-end). یعنی تستها باید به گونه ای طراحی شده باشند که در کمترین زمان ممکن اجرا شوند. در یک پروژه واقعی ممکن است هزاران تست واحد وجود داشته باشد. اگر این تست ها سریع اجرا نشوند، زمان زیادی را هدر خواهند داد.
-
قطعی بودن → هر بار با همان ورودیها، خروجی یکسان بدهد. در واقع قطعی بودن (Deterministic) یعنی اگر تابعی را با یک ورودی مشخص چند بار اجرا کنیم، همیشه همان خروجی را بدهد و نتیجه به عوامل تصادفی، زمان سیستم یا وضعیت محیط اجرا وابسته نباشد. این ویژگی باعث میشود تستها پایدار باشند و بدون تغییر کد، گاهی قبول(pass) و گاهی رد(fail) نشوند.
-
تمرکز روی یک رفتار → دقیقاً یک چیز را چک کند. یعنی هر تست باید فقط یک سناریو یا رفتار مشخص را بررسی کند، نه چندین موضوع مختلف را به طور همزمان. این کار باعث میشود وقتی تست fail شد، دقیقاً بدانیم مشکل در کدام رفتار بوده و رفع اشکال راحتتر شود.
چه توابعی را میتوان Unit Test کرد؟
بهترین کاندیدها:
توابع Pure (بدون side-effect)
مثل: محاسبه جمع، فرمتدهی رشته، پارس JSON
func Sum(a, b int) int {
return a + b
}
این نوع توابع عالیاند چون خروجیشان فقط به ورودی بستگی دارد.
توابع با منطق داخلی مهم ولی با وابستگیها قابل شبیهسازی
فرض کن تابعی داریم که مجموع قیمت سفارش را محاسبه میکند، ولی برای گرفتن قیمت هر کالا به یک سرویس خارجی وابسته است. در Unit Test، این سرویس خارجی را mock میکنیم تا وابستگی واقعی حذف شود و فقط منطق داخلی تابع را تست کنیم. به کد زیر توجه کنید:
package order
type PriceService interface {
GetPrice(productID int) float64
}
func CalculateOrderTotal(ps PriceService, productIDs []int) float64 {
total := 0.0
for _, id := range productIDs {
price := ps.GetPrice(id) // وابستگی خارجی
total += price
}
// منطق داخلی: اعمال تخفیف اگر جمع کل بالای 100 باشد
if total > 100 {
total *= 0.9
}
return total
}
در مثال بالا، تابع CalculateOrderTotal نیازمند یک struct است که توابع موجود در interface با نام PriceService را پیاده سازی کند. به بیان ساده، این تابع در ورودی یک struct میگیرد که باید حتما داخلش تابع GetPrice تعریف شده باشد. فرض کنید تابع GetPrice قیمت ها را از یک سرویس بیرونی میگیرد. برای اینکه بتوانیم برای تابع CalculateOrderTotal تست واحد بنویسیم، میتوانیم در داخل تست خودمان، نیازمندی تابع CalculateOrderTotal را با یک struct دیگر که همان تابع را دارد و رفتار struct اصلی را شبیه سازی میکند، جایگزین کنیم. به کد تست زیر دقت کنید:
package order
import "testing"
// Mock ساده برای PriceService
type mockPriceService struct{}
func (m mockPriceService) GetPrice(productID int) float64 {
return 50 // قیمت ثابت برای سادگی
}
func TestCalculateOrderTotal_DiscountApplied(t *testing.T) {
ps := mockPriceService{}
products := []int{1, 2, 3} // سه محصول با قیمت 50
got := CalculateOrderTotal(ps, products)
want := 135.0 // جمع = 150 → با 10% تخفیف = 135
if got != want {
t.Fatalf("got %.2f, want %.2f", got, want)
}
}
در این مثال:
-
منطق داخلی مهم: اعمال تخفیف وقتی جمع بالای 100 است.
-
وابستگی شبیهسازی شده: سرویس قیمت (
PriceService) که در تست با mock جایگزین شده.
متدهای struct که state داخلی را تغییر میدهند
مثل: Account.Deposit(amount) که موجودی را زیاد میکند. به کد زیر توجه کنید. یک struct داریم که کار حساب بانکی رو انجام میده و داخلش متغیری برای نگهداری موجودی داره. این موجودی همون state هست که میخواهیم تستش کنیم. دو تابع Deposit و Balance برای واریز به حساب و موجودی گرفتن استفاده میشن.
package bank
type Account struct {
balance float64
}
func NewAccount(initialBalance float64) *Account {
return &Account{balance: initialBalance}
}
func (a *Account) Deposit(amount float64) {
if amount > 0 {
a.balance += amount
}
}
func (a *Account) Balance() float64 {
return a.balance
}
حالا که میخواهیم برای این قسمت از پروژه تست بنویسیم، میدانیم که برداشت از حساب، موجودی حساب را تغییر میدهد. و از طرفی تابع Balance صرفا مقدار متغیر balance رو برمیگرداند. پس میتوانیم تستمان را اینطور بنویسیم که مبلغی به موجودی اضافه شود، بعد چک کنیم که آیا موجودی مقدار مورد نظر هست یا نه. به کد زیر توجه کنید:
package bank
import "testing"
func TestAccount_Deposit(t *testing.T) {
// Arrange
acc := NewAccount(100) // موجودی اولیه = 100
// Act
acc.Deposit(50)
// Assert
got := acc.Balance()
want := 150.0
if got != want {
t.Fatalf("got %.2f, want %.2f", got, want)
}
}
چه توابعی را بهتر است Unit Test نکنیم؟
توابعی که فقط wrapper یا عبوردهنده به لایه پایینتر هستند (بدون منطق واقعی)
ساده ترین و پرکاربردترین مثال برای این حالت وقتی است که شما از الگوی سرویس/ریپازیتوری استفاده میکنید. در این حالت ممکن است توابعی در سرویس وجود داشته باشند که عینا خروجی خود را از متدی در ریپازیتوری دریافت میکنند و خودشان صرفا نقش wrapper دارند. در این وضعیت تست اصلی باید روی متد repo نوشته شود (یا روی بخش بالادستی که این سرویس را استفاده میکند). حالا اگر repo تست شده باشد، تست مجدد این تابع تکراری و بدون ارزش افزوده است.
package user
type Repository interface {
FindByID(id int) (string, error)
}
type Service struct {
repo Repository
}
// GetUserName فقط متد Repository رو صدا میزنه و هیچ منطق اضافهای نداره
func (s *Service) GetUserName(id int) (string, error) {
return s.repo.FindByID(id) // عبور مستقیم بدون تغییر
}
توابعی که کاملاً به I/O خارجی متکیاند (مثل آپلود فایل روی S3)
اینها را بهتر است Integration Test کنیم، و برای Unit Test نسخه mock بنویسیم. توابعی که کاملاً به I/O خارجی وابستهاند، یعنی برای انجام کار حتماً باید به یک منبع خارجی وصل شوند، مثل:
-
خواندن/نوشتن فایل روی دیسک
-
درخواست به APIهای بیرونی (مثلاً S3, Stripe, Google Maps)
-
اتصال به دیتابیس واقعی
در Unit Test، این توابع مستقیم تست نمیشوند چون:
-
کند هستند
-
به عوامل بیرونی وابستهاند (شبکه، دسترسی، وضعیت سرویس)
-
نتیجهشان ممکن است هر بار تغییر کند (غیرقطعی شوند)
به جایش:
-
این وابستگیها را Mock/Fake میکنیم تا منطق اصلی کد را بدون I/O واقعی تست کنیم.
-
خود I/O واقعی را در Integration Test میآزماییم.
توابع private که فقط از public function ها صدا زده میشوند
در گولنگ، توابعی که با حرف کوچک شروع میشوند (مثل calculateTax) به صورت unexported یا اصطلاحاً private شناخته میشوند؛ یعنی فقط داخل همان پکیج قابل استفادهاند.
package tax
// تابع private — فقط داخل پکیج استفاده میشود
func calculateTax(amount float64) float64 {
return amount * 0.1
}
// تابع public
func FinalPrice(amount float64) float64 {
tax := calculateTax(amount)
return amount + tax
}
وقتی یک تابع private فقط از طریق یک تابع public صدا زده میشود و هیچ استفادهی دیگری ندارد،
نیازی نیست مستقیم برایش تست جداگانه بنویسیم.
بهتر است تابع public را تست کنیم، چون:
-
با تست تابع public، عملاً منطق تابع private هم پوشش داده میشود.
-
تغییرات داخلی (مثل تغییر نام یا تقسیم کد) باعث شکستن تستهای غیرضروری نمیشود.
-
تست متمرکز روی رفتار بیرونی (public API) باقی میماند، نه جزئیات داخلی.
در نتیجه برای توابع private نیازی به نوشتن تست واحد نداریم.
package tax
import "testing"
func TestFinalPrice(t *testing.T) {
got := FinalPrice(100)
want := 110.0
if got != want {
t.Fatalf("got %.2f, want %.2f", got, want)
}
}
حالا اگر روزی تصمیم گرفتی که درصد مالیات رو تغییر بدی، فقط کافیه توی تست بالا، عددی که انتظار داری ببینی رو تغییر بدی.
موفق و پیروز باشید.