1. مقدمه‌ای در مورد آزمون واحد

آزمون واحد به بررسی و اعتبارسنجی کوچک‌ترین واحد آزمون پذیر در یک برنامه اشاره دارد، مانند یک تابع یا متد در زبان Go. آزمون واحد اطمینان می‌دهد که کد به عنوان انتظار کار می‌کند و به توسعه‌دهندگان اجازه می‌دهد که تغییراتی در کد ایجاد کنند بدون اینکه به طور ناخواسته از کارایی موجود آن خراب شود.

در یک پروژه Golang، اهمیت آزمون واحد بدون اشاره‌ی مستقیم خودگونه است. اولاً، می‌تواند کیفیت کد را بهبود بخشد و به توسعه‌دهندگان اطمینان بیشتری بدهد تا تغییراتی در کد ایجاد کنند. ثانیاً، آزمون واحد می‌تواند به عنوان مستند برای کد عمل کند و رفتار انتظاری آن را توضیح دهد. علاوه بر این اجرای آزمون‌های واحد به شکل خودکار در محیط یکپارچه‌سازی پیوسته، می‌تواند به سرعت بازیابی خطاهای به تازگی معرفی شده را فراهم کند و این‌گونه پایداری نرم‌افزار را بهبود بخشد.

2. انجام آزمون‌های پایه با استفاده از بسته testing

کتابخانه استاندارد زبان Go، شامل بسته testing است که ابزارها و قابلیت‌هایی را برای نوشتن و اجرای آزمون‌ها فراهم می‌کند.

2.1 ایجاد یک مورد تست اولیه

برای نوشتن یک تابع آزمون، شما باید یک فایل با پسوند _test.go ایجاد کنید. به عنوان مثال، اگر فایل کد منبع شما نام calculator.go داشته باشد، فایل آزمون شما باید نام calculator_test.go داشته باشد.

حالا وقت آن است که به ایجاد تابع آزمون بپردازید. یک تابع آزمون باید بسته testing را وارد کرده و الگوی خاصی را دنبال کند. در ادامه یک مثال ساده آمده است:

// calculator_test.go
package calculator

import (
	"testing"
	"fmt"
)

// تست تابع جمع
func TestAdd(t *testing.T) {
	result := Add(1, 2)
	expected := 3

if result != expected {
		t.Errorf("از %v انتظار داشتم، اما %v را دریافت کردم", expected, result)
	}
}

در این مثال، TestAdd یک تابع آزمون است که یک تابع Add واقعی را آزمایش می‌کند. اگر نتیجه تابع Add با نتیجه انتظاری مطابقت داشته باشد، تست موفق خواهد بود؛ در غیر این صورت، t.Errorf فراخوانی می‌شود تا اطلاعات مربوط به شکست تست را ثبت کند.

2.2 درک قوانین نامگذاری و امضای توابع تست

توابع تست باید با Test شروع شده و دنبال شده توسط هر رشته غیر قلم‌کوچکی باشند و تنها پارامتر آن‌ها باید یک اشاره به testing.T باشد. همانطور که در مثال نشان داده شده است، TestAdd قوانین نامگذاری صحیح و امضای مناسب را دنبال می‌کند.

2.3 اجرای مورد‌های تست

می‌توانید مورد‌های تست خود را با استفاده از ابزار خط فرمان اجرا کنید. برای یک مورد تست خاص، دستور زیر را اجرا کنید:

go test -v // اجرای تست‌ها در دایرکتور فعلی و نمایش خروجی تفصیلی

اگر می‌خواهید یک مورد تست خاص را اجرا کنید، می‌توانید از پرچم -run دنبال شده توسط یک عبارت منظم استفاده کنید:

go test -v -run TestAdd // فقط اجرای تابع تست TestAdd

دستور go test به طور خودکار تمام فایل‌های _test.go را پیدا می‌کند و هر تابع تستی را که با معیارها مطابقت داشته باشد، اجرا می‌کند. اگر همه‌ی تست‌ها با موفقیت اجرا شوند، پیامی مشابه PASS را در خط فرمان مشاهده می‌کنید؛ اگر هر تستی شکست خورد، پیامی مشابه FAIL همراه با پیام خطای مربوطه مشاهده خواهید کرد.

3. نوشتن مورد‌های تست

3.1 گزارش دادن خطاها با استفاده از t.Errorf و t.Fatalf

در زبان Go، چارچوب آزمون ابزارهای مختلفی برای گزارش خطاها فراهم می‌کند. دو تابع متداول استفاده شده، Errorf و Fatalf هستند که هر دوی آن‌ها متدی از شی testing.T هستند. Errorf برای گزارش خطاها در تست استفاده می‌شود اما اجازه هم نمی‌دهد که مورد تست فعلی متوقف شود، در حالی که Fatalf همانند این کار، تست را فوراً پس از گزارش خطا متوقف می‌کند. مهم است که بسته به الزامات آزمون، روش مناسب را انتخاب کنید.

مثال استفاده از Errorf:

func TestAdd(t *testing.T) {
    got := Add(1, 2)
    want := 3
    if got != want {
        t.Errorf("Add(1, 2) = %d; می‌خواستم %d", got, want)
    }
}

اگر می‌خواهید تست فوراً پس از شناسایی خطا متوقف شود، می‌توانید از Fatalf استفاده کنید:

func TestSubtract(t *testing.T) {
    got := Subtract(5, 3)
    if got != 2 {
        t.Fatalf("Subtract(5, 3) = %d; می‌خواستم 2", got)
    }
}

در کل، اگر خطا باعث اجرای صحیح کد بعدی یا شکست تست قابل تأیید در پیش‌روی است، توصیه می‌شود که از Fatalf استفاده کنید. در غیر این صورت، توصیه می‌شود که از Errorf برای گرفتن یک نتیجه آزمون جامعتر استفاده کنید.

3.2 سازماندهی زیرآزمون‌ها و اجرای زیرآزمون‌ها

در Go، ما می‌توانیم از t.Run برای سازماندهی زیرآزمون‌ها استفاده کنیم که به ما کمک می‌کند که کد‌های تست را به صورت ساختارمند‌تری بنویسیم. زیرآزمون‌ها می‌توانند دارای Setup و Teardown خود باشند و می‌توانند به صورت جداگانه اجرا شوند و این امکان را فراهم می‌کنند. این ویژگی به ویژه برای انجام تست‌های پیچیده یا تست‌های پارامتریک بسیار مفید است.

مثال استفاده از t.Run:

func TestMultiply(t *testing.T) {
    testcases := []struct {
        name           string
        a, b, expected int
    }{
        {"2x3", 2, 3, 6},
        {"-1x-1", -1, -1, 1},
        {"0x4", 0, 4, 0},
    }

    for _, tc := range testcases {
        t.Run(tc.name, func(t *testing.T) {
            if got := Multiply(tc.a, tc.b); got != tc.expected {
                t.Errorf("ضرب(%d, %d) = %d; مقدار مورد انتظار %d", tc.a, tc.b, got, tc.expected)
            }
        })
    }
}

اگر بخواهیم زیرآزمون موسوم به "2x3" را به صورت جداگانه اجرا کنیم، می‌توانیم دستور زیر را در خط فرمان اجرا کنیم:

go test -run TestMultiply/2x3

لطفاً توجه داشته باشید که نام‌های زیرآزمون‌ها حساس به حروف بزرگ و کوچک هستند.

4. آماده‌سازی قبل و بعد از تست

4.1 تنظیم و پاک‌سازی

وقتی تست‌ها را انجام می‌دهیم، اغلب نیاز داریم که برای تست‌ها یک وضعیت اولیه را آماده کنیم (مانند اتصال به پایگاه داده، ایجاد فایل و غیره) و به همین ترتیب، نیاز داریم کارهای پاک‌سازی را بعد از اتمام تست‌ها انجام دهیم. در Go، ما معمولاً Setup و Teardown را مستقیماً در توابع تست انجام می‌دهیم، و تابع t.Cleanup امکان ثبت توابع بازگردانی را برای ما فراهم می‌کند.

در ادامه یک مثال ساده را مشاهده می‌کنید:

func TestDatabase(t *testing.T) {
    db, err := SetupDatabase()
    if err != nil {
        t.Fatalf("آماده‌سازی ناموفق بود: %v", err)
    }

    // ثبت یک تابع بازگردانی برای اطمینان از بسته شدن اتصال به پایگاه داده پس از اتمام تست
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("بستن پایگاه داده ناموفق بود: %v", err)
        }
    })

    // انجام تست...
}

در تابع TestDatabase، ابتدا تابع SetupDatabase را برای راه‌اندازی محیط تست صدا می‌زنیم. سپس از t.Cleanup() برای ثبت یک تابعی استفاده می‌کنیم که پس از اتمام تست برای انجام کارهای پاک‌سازی، در این مثال بستن اتصال به پایگاه داده، فراخوانی می‌شود. این روش به ما امکان می‌دهد تا از اینکه منابع به درستی آزاد شوند، با وجود موفقیت یا عدم موفقیت تست مطمئن شویم.

5. بهبود کارایی تست

بهبود کارایی تست می‌تواند به ما کمک کند که به سرعت توسعه را تکرار کنیم، به سرعت مشکلات را کشف کنیم و از کیفیت کد اطمینان حاصل کنیم. در زیر، ما در مورد پوشش تست، تست‌های جدولی مبتنی بر داده و استفاده از موک برای بهبود کارایی تست خواهیم گفت.

5.1 پوشش تست و ابزارهای مربوطه

ابزار go test قابلیت بسیار مفیدی به نام پوشش تست را فراهم می‌کند که به ما کمک می‌کند که بفهمیم کدام بخش‌های کد توسط تست‌ها پوشش داده شده‌اند و از طریق آن نواحی کدی که توسط تست‌ها پوشش داده نشده‌اند را کشف کنیم.

با استفاده از دستور go test -cover، می‌توانید درصد پوشش تست فعلی را ببینید:

go test -cover

اگر می‌خواهید درک دقیق‌تری از اینکه کدام خطوط کد اجرا شده و کدام نشده‌اند داشته باشید، می‌توانید از پارامتر -coverprofile استفاده کنید که یک فایل داده‌ی پوشش را تولید می‌کند. سپس می‌توانید از دستور go tool cover برای تولید گزارش جزئیات پوشش تست استفاده کنید.

go test -coverprofile=coverage.out
go tool cover -html=coverage.out

دستور فوق یک گزارش وب را باز می‌کند و به صورت تصویری نشان می‌دهد که کدام خطوط از کد توسط تست‌ها پوشش داده شده‌اند و کدام نشده‌اند. رنگ سبز نشان‌دهنده کد پوشش‌داده‌شده تست است، در حالی که رنگ قرمز خطوط کدی نشان دهنده صحت تست نشده هستند.

5.2 استفاده از موک (Mocks)

در زمان تست‌گیری، اغلب با سناریوهایی روبه‌رو می‌شویم که نیاز به شبیه‌سازی وابستگی‌های خارجی داریم. موک‌ها می‌توانند به ما کمک کنند که این وابستگی‌ها را شبیه‌سازی کرده و نیاز به وابستگی به خدمات یا منابع خارجی خاص در محیط تست را از بین ببریم.

در جامعه Go، ابزارهای زیادی برای موک وجود دارند مانند testify/mock و gomock. این ابزارها معمولاً مجموعه‌ای از API‌ها را برای ایجاد و استفاده از شی موک فراهم می‌کنند.

در زیر مثال ابتدایی استفاده از testify/mock آمده است. اولین کار این است که یک رابط و نسخه موک آن را تعریف کنیم:

type DataService interface {
    FetchData() (int, error)
}

type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) FetchData() (int, error) {
    args := m.Called()
    return args.Int(0), args.Error(1)
}

در زمان تست‌گیری، می‌توانیم از MockDataService برای جایگزینی خدمات داده واقعی استفاده کنیم:

func TestSomething(t *testing.T) {
    mockDataSvc := new(MockDataService)
    mockDataSvc.On("FetchData").Return(42, nil) // پیکربندی رفتار مورد انتظار

    result, err := mockDataSvc.FetchData() // استفاده از شی موک
    assert.NoError(t, err)
    assert.Equal(t, 42, result)

    mockDataSvc.AssertExpectations(t) // تایید اینکه رفتار مورد انتظار رخ داده است
}

از طریق روی این نهج، می‌توانیم در تست‌گیری از وابستگی به خدمات خارجی، تماس‌های دیتابیس و غیره خودداری کنیم چرا که این کار می‌تواند اجرای تست‌ها را تسریع کرده و تست‌های ما را پایدارتر و قابل اطمینان‌تر کند.

6. تکنیک‌های پیشرفته تست

بعد از مسلط شدن بر مبانی تست واحد Go، می‌توانیم تکنیک‌های پیشرفته‌تری را بررسی کنیم که در ساخت نرم‌افزارهای محکمتر و بهبود کارایی تست‌ها مفید هستند.

6.1 تست کردن توابع خصوصی

در گولنگ، توابع خصوصی معمولاً به توابعی اشاره دارند که قابل دسترسی نیستند، یعنی توابعی که نام آن‌ها با حرف کوچک شروع می‌شود. معمولاً ما ترجیح می‌دهیم که رابط‌های عمومی را تست کنیم زیرا آن‌ها نشان‌دهنده قابلیت استفاده از کد هستند. با این حال، مواردی وجود دارند که تست مستقیم توابع خصوصی هم معنی پیدا می‌کند، مانند زمانی که تابع خصوصی منطق پیچیده‌ای دارد و توسط چند تابع عمومی فراخوانی می‌شود.

تست کردن توابع خصوصی از تست کردن توابع عمومی بخصوصیت‌های آن‌ها بخوبی متفاوت است چرا که از خارج از بسته قابل دسترسی نیستند. یک تکنیک معمول این است که کد تست را در همان بسته بنویسیم تا بتوانیم به صورت مستقیم به تابع خصوصی دسترسی داشته باشیم.

در زیر یک مثال ساده آمده است:

// calculator.go
package calculator

func add(a, b int) int {
    return a + b
}

فایل تست مربوطه به این صورت است:

// calculator_test.go
package calculator

import "testing"

func TestAdd(t *testing.T) {
    expected := 4
    actual := add(2, 2)
    if actual != expected {
        t.Errorf("انتظار داشتیم %d، دریافت شد %d", expected, actual)
    }
}

با قرار دادن فایل تست در همان بسته، می‌توانیم به صورت مستقیم تابع add را تست کنیم.

6.2 الگوهای تست مشترک و بهترین اصول

تست واحد در گولنگ الگوهای مشترکی دارد که به تسهیل تست کار کمک کرده و به حفظ وضوح کد و قابلیت نگهداری کمک می‌کند.

  1. تست‌های جدولی

    تست‌گیری مبتنی بر جدول یک روش برای سازماندهی ورودی‌ها و خروجی‌های مورد تست است. با تعریف یک مجموعه از موارد تست و سپس حلقه زدن از آن‌ها برای تست، این روش افزودن موارد تست جدید را بسیار آسان می‌کند و همچنین کد را خوانا و قابل نگهداری می‌کند.

// calculator_test.go
    package calculator

    import "testing"

    func TestAddTableDriven(t *testing.T) {
        var tests = []struct {
            a, b   int
            want   int
        }{
            {1, 2, 3},
            {2, 2, 4},
            {5, -1, 4},
        }

        for _, tt := range tests {
            testname := fmt.Sprintf("%d,%d", tt.a, tt.b)
            t.Run(testname, func(t *testing.T) {
                ans := add(tt.a, tt.b)
                if tt.want != ans {
                    t.Errorf("دریافت شد %d، انتظار داشتیم %d", ans, tt.want)
                }
            })
        }
    }
  1. استفاده از موک‌ها برای تست

    موک کردن یک تکنیک تست است که در آن وابستگی‌ها را با هدف تست اجزا مختلفی جایگزین می‌کند. در گولنگ، رابط‌ها راه اصلی برای پیاده‌سازی موک‌ها هستند. با استفاده از رابط‌ها، می‌توان یک اجرای موک ایجاد کرده و سپس آن را در تست استفاده کرد.