7.3 تست جدول‌محور (Table-Driven Test)

7.3 تست جدول‌محور (Table-Driven Test)

در زبان Go، یکی از الگوهای محبوب و بسیار قدرتمند برای نوشتن تست‌های واحد و رفتاری، الگوی «تست جدول‌محور» است. این سبک از تست‌نویسی نه‌تنها منجر به حذف تکرارهای زائد در کد تست می‌شود، بلکه ساختاری منسجم برای تعریف سناریوهای مختلف تست، به همراه ورودی و خروجی‌های مورد انتظار، فراهم می‌سازد.

در این الگو، مجموعه‌ای از تست‌ها به‌صورت یک جدول از structها تعریف می‌شود که شامل نام تست، ورودی‌ها، خروجی مورد انتظار، و گاهی انتظار وقوع خطا است. سپس با استفاده از یک حلقه و تابع t.Run، هر ردیف از جدول به‌صورت یک تست مستقل (subtest) اجرا می‌شود. این طراحی باعث می‌شود اضافه‌کردن یک تست جدید، تنها با افزودن یک struct به جدول امکان‌پذیر باشد—بدون نیاز به کپی‌کردن منطق کلی تست.

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

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

7.3.1 ساختار پایه تست جدول‌محور و مثال ساده #

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

در این الگو، هر ردیف جدول معمولاً یک struct است که شامل فیلدهایی مانند نام تست، ورودی‌ها، خروجی مورد انتظار، و گاهی هم انتظار وقوع خطاست. ساختار کلی آن شبیه به کد زیر است:



func TestAdd(t *testing.T) {
	tests := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"positive numbers", 2, 3, 5},
		{"negative numbers", -1, -2, -3},
		{"mixed signs", -1, 2, 1},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := Add(tt.a, tt.b)
			if got != tt.expected {
				t.Errorf("expected %d, got %d", tt.expected, got)
			}
		})
	}
}

در این مثال:

  • با استفاده از t.Run برای هر تست یک subtest تعریف شده است.
  • پیام‌های شکست تست شامل tt.name هستند که گزارش خطا را واضح‌تر و دقیق‌تر می‌کنند.
  • اضافه‌کردن یک سناریوی تست جدید بسیار ساده است: فقط کافی‌ست یک struct دیگر به tests اضافه کنید.

این سبک به‌عنوان راه‌حل رسمی جامعه‌ی Go برای تست‌های با مسیرهای متنوع در توابع معرفی شده است.

7.3.1.1 مزایای اصلی این الگو #

مزایای اصلی این الگو عبارت‌اند از:

  • کاهش قابل‌توجه تکرار کد (DRY)
  • ساده‌سازی تحلیل و نگهداری تست‌ها
  • قابلیت اجرای موازی آسان با t.Parallel()
  • مناسب برای پوشش مسیرهای شرطی و edge cases
  • امکان افزودن متغیرهای اضافی مثل wantErr, errorMessage, expectedStatusCode

7.3.1.2 جایگزینی حلقه ساده با map برای بهبود خوانایی #

در مثال قبل از یک slice از struct استفاده شد. اما می‌توان از map[string]testCase] هم استفاده کرد تا به‌طور مستقیم نام تست را به‌عنوان کلید بیاوریم و خوانایی را افزایش دهیم:

func TestMultiply(t *testing.T) {
	tests := map[string]struct {
		a, b     int
		expected int
	}{
		"zero":        {0, 5, 0},
		"positive":    {2, 3, 6},
		"negative":    {-2, 4, -8},
		"mixed signs": {-3, -2, 6},
	}

	for name, tt := range tests {
		tt := tt
		t.Run(name, func(t *testing.T) {
			got := Multiply(tt.a, tt.b)
			if got != tt.expected {
				t.Errorf("expected %d, got %d", tt.expected, got)
			}
		})
	}
}

نکته مهم در این مثال این است که حتماً باید از کپی tt := tt در ابتدای هر subtest استفاده کنیم، تا از مشکل closure جلوگیری شود.

7.3.1.3 مقایسه با تست‌های کلاسیک #

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

7.3.1.4 زمانی که Table Test مناسب نیست #

گرچه این الگو بسیار مفید است، اما همیشه انتخاب درست نیست. طبق منابع:

  • اگر تست فقط یک حالت دارد، استفاده از جدول ممکن است کد را بی‌جهت پیچیده کند.
  • در برخی تست‌های سطح بالا یا تست‌های تعامل‌محور (مثل تست UI یا سرویس‌های REST)، بهتر است تست‌ها به‌صورت مستقل و سناریو محور نوشته شوند، نه جدول‌محور.

7.3.2 تست خطا و ورودی‌های نادرست در Table Tests #

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

در طراحی جدول تست، معمولاً از فیلدی به‌نام wantErr یا expectErr برای تعیین انتظار بروز خطا استفاده می‌شود. این متغیر به تست‌نویس امکان می‌دهد تا به‌صورت واضح اعلام کند آیا در هر سناریو وقوع خطا انتظار می‌رود یا خیر.

7.3.2.1 ساختار تست شامل انتظار خطا #

func Divide(a, b int) (int, error) {
	if b == 0 {
		return 0, fmt.Errorf("division by zero")
	}
	return a / b, nil
}

تست جدول‌محور برای پوشش رفتار صحیح و خطا:

func TestDivide(t *testing.T) {
	tests := []struct {
		name    string
		a, b    int
		want    int
		wantErr bool
	}{
		{"valid division", 6, 3, 2, false},
		{"zero divisor", 5, 0, 0, true},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			got, err := Divide(tt.a, tt.b)
			if (err != nil) != tt.wantErr {
				t.Fatalf("unexpected error state: %v", err)
			}
			if !tt.wantErr && got != tt.want {
				t.Errorf("expected %d, got %d", tt.want, got)
			}
		})
	}
}

در این ساختار:

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

7.3.2.2 نکاتی در مورد مقایسه خطا #

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

مقایسه پیام خطا #

if err != nil && err.Error() != "division by zero" {
	t.Errorf("unexpected error message: %v", err)
}

استفاده از errors.Is یا errors.As #

اگر خطا wrap شده باشد:

var ErrDivideByZero = errors.New("division by zero")

if !errors.Is(err, ErrDivideByZero) {
	t.Errorf("expected ErrDivideByZero, got %v", err)
}

7.3.2.3 ترکیب با subtest برای مدیریت بهتر #

حتی در سناریوهای شامل خطا هم می‌توان از t.Parallel() برای اجرای موازی تست‌ها استفاده کرد، به شرطی که مقدار tt را در scope هر subtest کپی کرده باشیم:

t.Run(tt.name, func(t *testing.T) {
	t.Parallel()
	// اجرای تست
})

7.3.2.4 طراحی سناریوهای پیچیده‌تر #

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

مثال:

tests := []struct {
	name    string
	input   Request
	want    Response
	wantErr bool
}{
	{"valid input", Request{ID: 1}, Response{Success: true}, false},
	{"invalid input", Request{}, Response{}, true},
}

7.3.3 اجرای موازی در تست‌های جدول‌محور با t.Parallel #

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

تابع t.Parallel() به Go اعلام می‌کند که تست جاری می‌تواند به‌صورت همزمان با سایر تست‌ها اجرا شود. اما برای استفاده صحیح از این ویژگی در حلقه‌ی تست‌های جدول‌محور، باید نکته‌ای کلیدی را رعایت کرد: متغیر loop (مانند tt) باید داخل scope هر subtest مجدداً shadow شود. در غیر این صورت، همه goroutineها ممکن است به مقدار یکسانی از tt اشاره کنند و باعث بروز نتایج اشتباه شوند.

7.3.3.1 مثال صحیح استفاده از t.Parallel #

func TestMultiply(t *testing.T) {
	tests := []struct {
		name     string
		a, b     int
		expected int
	}{
		{"positive", 2, 3, 6},
		{"zero", 0, 4, 0},
		{"negative", -1, 3, -3},
	}

	for _, tt := range tests {
		tt := tt // کپی کردن متغیر برای جلوگیری از race
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			got := Multiply(tt.a, tt.b)
			if got != tt.expected {
				t.Errorf("expected %d, got %d", tt.expected, got)
			}
		})
	}
}

در این مثال:

  • t.Parallel() در ابتدای هر subtest فراخوانی شده.
  • متغیر tt := tt باعث شده هر goroutine مقدار اختصاصی خود را داشته باشد.

7.3.3.2 چه زمانی نباید از t.Parallel استفاده کنیم #

با وجود مزایای بالا، استفاده از اجرای موازی در همه‌ی تست‌ها توصیه نمی‌شود. در موارد زیر باید با احتیاط یا اصلاً از t.Parallel() استفاده نکنید:

  1. تست‌هایی که به منابع مشترک دسترسی دارند مانند فایل سیستم، دیتابیس، متغیرهای global یا سرویس خارجی.
  2. تست‌هایی که به ترتیب اجرا وابسته‌اند
  3. تست‌هایی که در زمان اجرا وضعیت را تغییر می‌دهند مثلاً حذف یا ایجاد فایل، تغییر در داده‌های اشتراکی.

در این موارد، یا تست‌ها را به صورت ترتیبی اجرا کنید، یا منابع را ایزوله کنید (مثلاً از t.TempDir() برای مسیرهای جداگانه استفاده کنید).

7.3.3.3 ترکیب تست موازی با زیرساخت CI #

اجرای موازی تست‌ها در CI/CD، به‌ویژه برای پروژه‌های بزرگ، مزیت مهمی محسوب می‌شود. اما لازم است:

  • تست‌ها ایزوله باشند.
  • از حافظه یا دیسک مشترک استفاده نکنند.
  • تست‌ها به‌صورت مستقل از محیط اجرا شوند.

7.3.4 ساخت تست‌های قابل نگهداری و خوانا در جدول‌ها #

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

7.3.4.1 استفاده از نام‌های گویا برای هر تست #

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

مثال خوب:

{
	name: "returns error when divisor is zero",
	a: 10, b: 0, wantErr: true,
}

اجتناب شود از نام‌هایی مانند:

"name": "test1"

7.3.4.2 مرتب‌سازی منطقی تست‌ها #

در جدول تست، بهتر است تست‌ها را به ترتیب معنایی یا گروه‌بندی شده قرار دهید:

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

7.3.4.3 تعریف type مجزا برای تست‌کیس‌ها (در پروژه‌های بزرگ) #

برای جلوگیری از تکرار تعریف struct در چندین تابع تست و ارتقای وضوح کد، می‌توان type اختصاصی برای تست‌کیس‌ها تعریف کرد:

type divideTestCase struct {
	name    string
	a, b    int
	want    int
	wantErr bool
}

و سپس در جدول تست:

tests := []divideTestCase{ ... }

این کار به‌ویژه در تست‌های پیچیده یا تکراری در چند فایل بسیار مفید است.

7.3.4.4 تعریف توابع کمکی برای assertions و آماده‌سازی #

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

مثال:

func assertEqual(t *testing.T, got, want int) {
	if got != want {
		t.Errorf("expected %d, got %d", want, got)
	}
}

و در تست:

t.Run(tc.name, func(t *testing.T) {
	result := Add(tc.a, tc.b)
	assertEqual(t, result, tc.expected)
})

7.3.4.5 الگوی توصیه‌شده برای تست‌های قابل نگهداری #

type mathTest struct {
	name    string
	a, b    int
	want    int
	wantErr bool
}

func TestDivide(t *testing.T) {
	cases := []mathTest{
		{"valid input", 8, 2, 4, false},
		{"zero divisor", 10, 0, 0, true},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got, err := Divide(tc.a, tc.b)
			if (err != nil) != tc.wantErr {
				t.Errorf("unexpected error state: %v", err)
			}
			if !tc.wantErr && got != tc.want {
				t.Errorf("expected %d, got %d", tc.want, got)
			}
		})
	}
}

در این الگو:

  • t.Parallel() برای سرعت بالا
  • پیام‌های دقیق و واضح
  • struct مشخص و تایپ‌شده برای هر تست

7.3.5 استفاده از تست‌های تو در تو (Subtests) در Table Test #

در بسیاری از مواقع، هر سناریوی تست خودش شامل چند حالت بررسی‌شدنی است. به‌جای اینکه این موارد را به تست‌های مجزای بزرگ و تودرتو تبدیل کنیم، می‌توان با استفاده از t.Run برای هر بخش از منطق، تست‌های تو در تو (Subtests) تعریف کرد. این قابلیت که از Go 1.7 به بعد اضافه شده، ابزار قدرتمندی برای سازمان‌دهی بهتر تست‌ها، گزارش‌گیری دقیق‌تر و امکان اجرای هدفمند تست‌ها فراهم می‌کند.

7.3.5.1 مثال ساده از Subtest در Table Test #

فرض کنیم تابعی داریم که عملیات روی کاربر انجام می‌دهد و در هر حالت باید چند ویژگی خروجی را بررسی کنیم. برای هر حالت، چند subtest تعریف می‌کنیم:

func TestUserValidation(t *testing.T) {
	tests := []struct {
		name     string
		user     User
		wantErr  bool
		wantRole string
	}{
		{"valid user", User{Email: "a@b.com"}, false, "user"},
		{"missing email", User{}, true, ""},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Run("validate email", func(t *testing.T) {
				err := tt.user.Validate()
				if (err != nil) != tt.wantErr {
					t.Errorf("error mismatch: %v", err)
				}
			})

			t.Run("check role", func(t *testing.T) {
				if tt.user.Role() != tt.wantRole {
					t.Errorf("expected role %q, got %q", tt.wantRole, tt.user.Role())
				}
			})
		})
	}
}

در این مثال:

  • هر tt یک مورد جدول تست است.
  • دو زیرتست برای هر tt اجرا می‌شود:
    • بررسی اعتبار ایمیل
    • بررسی نقش پیش‌فرض

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

7.3.5.2 مزایای استفاده از Subtestها در Table Test #

  • دسته‌بندی معنایی تست‌ها: هر مرحله از تست می‌تواند subtest جداگانه داشته باشد.
  • گزارش دقیق‌تر خطا: نام تست‌های تودرتو در گزارش CLI و CI به صورت کامل دیده می‌شود.
  • امکان اجرای selective: می‌توان فقط یک subtest خاص را با flag -run اجرا کرد:
go test -run="TestUserValidation/valid_user/check_role"

7.3.5.3 اجرای موازی Subtestها #

در صورتی که هر subtest ایزوله باشد، می‌توان از t.Parallel() نیز داخل آن استفاده کرد:

t.Run("parallel section", func(t *testing.T) {
	t.Parallel()
	// عملیات تست
})

7.3.5.4 نمونه‌ای پیشرفته‌تر: Table + Subtest + Parallel #

func TestCalculator(t *testing.T) {
	tests := []struct {
		name string
		a, b int
	}{
		{"positive", 1, 2},
		{"negative", -1, -3},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			t.Run("Add", func(t *testing.T) {
				t.Parallel()
				if Add(tt.a, tt.b) != tt.a+tt.b {
					t.Fail()
				}
			})
			t.Run("Multiply", func(t *testing.T) {
				t.Parallel()
				if Multiply(tt.a, tt.b) != tt.a*tt.b {
					t.Fail()
				}
			})
		})
	}
}

7.3.6 خطاهای رایج در پیاده‌سازی Table Tests #

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

7.3.6.1 استفاده اشتباه از متغیر loop (closure bug) #

یکی از خطرناک‌ترین و رایج‌ترین اشتباهات، استفاده مستقیم از متغیر loop (tt) درون تابع t.Run است. به دلیل اینکه tt در هر iteration یک متغیر مشترک است، همه goroutineها ممکن است به آخرین مقدار آن دسترسی داشته باشند.

از نسخه 1.22 این مشکل کاملا حل شده است و نیازی به shadowing نیست.

❌ مثال اشتباه:

for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		doTest(tt)
	})
}

✅ راه حل صحیح:

for _, tt := range tests {
	tt := tt // shadowing → ایجاد نسخه مجزا از tt
	t.Run(tt.name, func(t *testing.T) {
		t.Parallel()
		doTest(tt)
	})
}

7.3.6.2 عدم گزارش نام تست در پیام خطا #

در گزارش خطاها، اگر از نام تست استفاده نشود، تشخیص خطای رخ‌داده دشوار می‌شود؛ مخصوصاً وقتی چندین تست پشت سر هم fail می‌شوند.

❌ اشتباه رایج:

t.Errorf("expected %d, got %d", want, got)

✅ شکل بهتر:

t.Errorf("%s: expected %d, got %d", tt.name, want, got)

یا با subtestها، نیازی به این کار نیست چون t.Run خودش context لازم را دارد.

7.3.6.3 بررسی نکردن خطا وقتی wantErr مشخص است #

در بسیاری از تست‌ها فیلدی به نام wantErr تعریف می‌شود اما بررسی درستی روی آن انجام نمی‌گیرد یا فقط وجود/عدم وجود err بررسی می‌شود، بدون توجه به نوع خطا.

✅ پیشنهاد بهتر:

if (err != nil) != tt.wantErr {
	t.Fatalf("unexpected error state: %v", err)
}

در صورت نیاز، بررسی نوع خطا با errors.Is یا errors.As نیز می‌تواند اضافه شود.

7.3.6.4 تست نکردن مقدار خروجی وقتی خطا انتظار نمی‌رود #

❌ اشتباه رایج:

if err != nil {
	t.Fatalf("unexpected error: %v", err)
}
// هیچ بررسی‌ای روی خروجی انجام نشده

✅ راه صحیح:

if err != nil {
	t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
	t.Errorf("expected %v, got %v", tt.want, got)
}

7.3.6.5 تبدیل جدول تست به پیچیدگی غیرقابل‌خواندن #

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

  • جدول را به فایل جداگانه ببرید.
  • یا توابع assertion و کمکی تعریف کنید.
  • یا حتی آن تست خاص را از Table Test جدا کنید.

7.3.6.6 عدم پوشش شرایط لبه (Edge Cases) #

در جدول تست، اغلب فقط شرایط عادی پوشش داده می‌شوند و شرایط خاص یا مرزی نادیده گرفته می‌شوند؛ مثل:

  • ورودی‌های صفر، نال، یا تهی
  • بزرگ‌ترین یا کوچک‌ترین مقدار مجاز
  • رشته‌های با کاراکترهای خاص

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

7.3.6.7 وابستگی تست‌ها به یکدیگر #

در صورتی که هر iteration تست جدول‌محور، وضعیت مشترکی تغییر دهد (مثل فایل، دیتابیس، متغیر global)، وابستگی بین تست‌ها ایجاد می‌شود و اجرای موازی خطرناک خواهد بود. هر تست باید کاملاً ایزوله باشد.

7.3.7 جمع‌بندی و توصیه‌های نهایی برای طراحی تست‌های جدول‌محور #

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

7.3.7.1 مزایای کلیدی Table-Driven Tests #

  • کاهش تکرار در کد تست: دیگر نیازی به کپی کردن یک منطق با ورودی‌های مختلف ندارید.
  • خوانایی بالا: تست‌ها در قالب جدول struct به راحتی قابل درک‌اند.
  • افزودن ساده‌ی تست‌های جدید: فقط کافی‌ست یک struct جدید به جدول اضافه شود.
  • قابلیت اجرای موازی و ایزوله: در ترکیب با t.Parallel() امکان تسریع تست‌ها وجود دارد.
  • گزارش‌گیری ساخت‌یافته: با استفاده از t.Run() و نام‌گذاری دقیق هر تست.

7.3.7.2 توصیه‌های نهایی #

  1. همیشه از tt := tt در subtest استفاده کن حتی اگر در لحظه از t.Parallel() استفاده نمی‌کنی، برای اطمینان از ایزولاسیون متغیرها این دستور را بنویس.
  2. نام تست را معنی‌دار بنویس نام هر سناریوی تست باید هدف آن را به وضوح منتقل کند؛ این موضوع در CI/CD و گزارش‌های ترمینال بسیار مفید است.
  3. ورودی‌ها، خروجی‌ها و انتظار خطا را صریح بیان کن حتی اگر ساده به نظر برسد، وجود فیلدهایی مثل wantErr یا expectedCode ساختار تست را واضح‌تر و قابل گسترش می‌کند.
  4. برای هر لایه تستی از Subtest استفاده کن اگر درون هر سناریوی تست چند شرط باید بررسی شود، از t.Run() برای ساخت زیرتست استفاده کن.
  5. Edge Caseها را فراموش نکن ورودی‌های خاص، تهی، صفر، منفی یا ناصحیح را در جدول لحاظ کن تا تست‌ها فقط “خوش‌بینانه” نباشند.
  6. زمانی که الگو پیچیده شد، ساده کن اگر جدول بسیار بزرگ، پر از توابع تو در تو یا منطق شرطی شد، شاید وقت آن است که آن تست را جداگانه بنویسی یا تست را refactor کنی.
  7. برای پروژه‌های بزرگ، از type مجزا استفاده کن تعریف type مشخص برای struct تست‌ها خوانایی را بالا می‌برد، به‌ویژه اگر در چند فایل مشترک باشد.
  8. مطمئن شو هر تست ایزوله و بدون side effect است جدول تست نباید به یک ترتیب خاص یا shared state وابسته باشد. هر تست باید مستقل و بازتولیدپذیر باشد.

7.3.7.3 نتیجه‌گیری #

استفاده صحیح از تست‌های جدول‌محور در Go، نشانه‌ای از بلوغ تست‌نویسی در یک پروژه است. این الگو، در کنار ابزارهایی مانند t.Run, t.Parallel, t.Cleanup, و پکیج‌هایی مثل testify، به شما امکان می‌دهد تست‌هایی با کیفیت تولیدی و قابل اطمینان بنویسید.