تست نویسی در گولنگ – بخش سوم

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

Table-Driven Test در Go: سریع، خوانا، مقیاس‌پذیر

به‌جای نوشتن چند تابع تست تکراری، یک آرایه از سناریوها می‌سازیم (ورودی‌ها + خروجی/انتظار)، و روی آن حلقه می‌زنیم؛ هر سناریو یک زیردست با t.Run می‌شود. این الگو هم‌زمان با AAA (Arrange-Act-Assert) کاملاً جور است و تست‌ها را کوتاه، شفاف و قابل‌گسترش می‌کند. بله همانطور که در ابتدای راه یاد گرفتیم، testing.T ابزاری برای برقرار ارتباط با موتور تست گولنگ بود اما فهم خود را به یک سری توابع برای اعلام خطا محدود کرده بودیم. حالا در این بخش می‌خواهیم با تابع Run آشنا شویم که به کمک آن می‌توانیم سبک تست نویسی Table-Driven را پیاده سازی کنیم.

t.Run(name, func(t *testing.T) {...})

ساختار کلی تست نویسی به این روش بصورت کد زیر است:

func TestXxx(t *testing.T) {
	tests := []struct {
		name string
		in   InputType
		want OutputType
		// wantErr error // اگر نیاز بود
	}{
		{"case-1", InputType{/*...*/}, OutputType{/*...*/}},
		{"case-2", InputType{/*...*/}, OutputType{/*...*/}},
	}

	for _, tt := range tests {
		tt := tt
		t.Run(tt.name, func(t *testing.T) {
			// t.Parallel() // در صورت نیاز
			// Arrange
			in := tt.in

			// Act
			got := FunctionUnderTest(in)

			// Assert
			if got != tt.want {
				t.Fatalf("got %#v; want %#v", got, tt.want)
			}
		})
	}
}

در کد بالا متغیر tests در واقع آرایه ای برای تعریف سناریوهای تست است. این struct را بسته به تابع خود می‌توانید تغییر دهید. اما بهتر است همیشه شامل ۳ بخش باشد. نام تست که سناریوی تست را به وضوح بیان کند. پارامترهای ورودی تایع در این سناریو و پارامترهای خروجی تایع. به همین سادگی میتوانید با اضافه کردن سناریوهای جدید به آرایه‌ی tests حالت های جدید را روی همان تابع تست کنید.

بیاییم یک سناریوی «واقعاً پُرحالت» بسازیم: نرمال‌سازی شماره موبایل ایران به فرمت E.164 یعنی:

+989XXXXXXXXX

این تابع pure و قطعی است و کلی حالت مرزی دارد: ارقام فارسی/عربی، فاصله و dash، پیش‌شماره‌های مختلف (0، 98، +98، 0098)، طول اشتباه، کاراکترهای اضافه و… حالا کد زیر را در نظر بگیرید:

package phoneutil

import (
	"errors"
	"strings"
	"unicode"
)

var (
	ErrEmpty           = errors.New("empty input")
	ErrInvalidDigits   = errors.New("invalid digits")
	ErrInvalidLength   = errors.New("invalid length")
	ErrInvalidPrefix   = errors.New("invalid prefix")
	ErrNotMobileIran   = errors.New("not an iranian mobile msisdn")
)

// NormalizeIranMobile نرمال‌سازی شماره موبایل ایران به فرمت +989xxxxxxxxx
// ورودی‌های قابل قبول: 09xxxxxxxxx | 9xxxxxxxxx | +989xxxxxxxxx | 00989xxxxxxxxx | 98xxxxxxxxx
// ارقام فارسی/عربی و جداکننده‌ها (space, -, _, ., (), …) حذف و تبدیل می‌شوند.
func NormalizeIranMobile(in string) (string, error) {
	if strings.TrimSpace(in) == "" {
		return "", ErrEmpty
	}

	// 1) تبدیل ارقام فارسی/عربی به لاتین و حذف هر چیز غیررقمی بجز +
	var b strings.Builder
	for _, r := range in {
		switch r {
		// Persian digits
		case '۰': r = '0'
		case '۱': r = '1'
		case '۲': r = '2'
		case '۳': r = '3'
		case '۴': r = '4'
		case '۵': r = '5'
		case '۶': r = '6'
		case '۷': r = '7'
		case '۸': r = '8'
		case '۹': r = '9'
		// Arabic-Indic digits
		case '٠': r = '0'
		case '١': r = '1'
		case '٢': r = '2'
		case '٣': r = '3'
		case '٤': r = '4'
		case '٥': r = '5'
		case '٦': r = '6'
		case '٧': r = '7'
		case '٨': r = '8'
		case '٩': r = '9'
		}
		// نگه داشتن فقط ارقام و +
		if unicode.IsDigit(r) || r == '+' {
			b.WriteRune(r)
		}
	}
	s := b.String()
	if s == "" {
		return "", ErrInvalidDigits
	}

	// 2) نرمال‌سازی پیش‌شماره‌ها به فرم 9xxxxxxxxx (10 رقم)
	// حالت‌های قابل قبول:
	// +989xxxxxxxxx | 00989xxxxxxxxx | 989xxxxxxxxx | 09xxxxxxxxx | 9xxxxxxxxx
	if strings.HasPrefix(s, "+") {
		if !strings.HasPrefix(s, "+98") {
			return "", ErrInvalidPrefix
		}
		s = strings.TrimPrefix(s, "+98")
	} else if strings.HasPrefix(s, "0098") {
		s = strings.TrimPrefix(s, "0098")
	} else if strings.HasPrefix(s, "98") {
		s = strings.TrimPrefix(s, "98")
	} else if strings.HasPrefix(s, "09") {
		s = strings.TrimPrefix(s, "0")
	}

	// الان باید s به شکل 9xxxxxxxxx باشد (10 رقم و با 9 شروع شود)
	if len(s) != 10 {
		return "", ErrInvalidLength
	}
	if s[0] != '9' {
		return "", ErrNotMobileIran
	}

	// 3) خروجی E.164
	return "+98" + s, nil
}

حالا برای تابع فوق می‌خواهیم تمام حالت های مختلف را تست کنیم. برای این کار به نمونه تست زیر توجه کنید:

package phoneutil

import "testing"

func TestNormalizeIranMobile(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		in   string
		want string
		err  error
	}{
		// حالت‌های درست
		{"plain-09", "09191234567", "+989191234567", nil},
		{"plain-9", "9191234567", "+989191234567", nil},
		{"plus-98", "+989191234567", "+989191234567", nil},
		{"double-zero-98", "00989191234567", "+989191234567", nil},
		{"no-sep-98", "989191234567", "+989191234567", nil},

		// جداکننده‌ها
		{"with-spaces", "0919 123 4567", "+989191234567", nil},
		{"with-dashes", "0919-123-4567", "+989191234567", nil},
		{"with-dots", "0919.123.4567", "+989191234567", nil},
		{"with-parens", "(0919) 123-4567", "+989191234567", nil},

		// ارقام فارسی/عربی
		{"persian-digits", "۰۹۱۹۱۲۳۴۵۶۷", "+989191234567", nil},
		{"arabic-indic", "٠٩١٩۱۲۳٤٥٦۷", "+989191234567", nil},
		{"mixed-digits", "۰9١9۱2۲4۵6۷", "+989191234567", nil},

		// پیش‌شماره‌های اشتباه
		{"wrong-plus-prefix", "+9719191234567", "", ErrInvalidPrefix},
		{"wrong-country-plain", "9719191234567", "", ErrInvalidLength}, // بعد از برش 98 چیزی نمی‌ماند → طول غلط

		// طول‌های غلط
		{"too-short", "0919123456", "", ErrInvalidLength},
		{"too-long", "091912345670", "", ErrInvalidLength},
		{"empty-trim", "   ", "", ErrEmpty},

		// کاراکترهای نامعتبر
		{"letters-inside", "0919-A123-4567", "", ErrInvalidDigits},

		// شماره غیرموبایل ایران (مثلاً با 9 شروع نمی‌شود)
		{"not-mobile-iran", "0211234567", "", ErrInvalidLength}, // چون به 9 تبدیل نشد و طول هم نامعتبر شد
		{"landline-after-trim", "+982112345678", "", ErrNotMobileIran}, // +98 211... → بعد از برش 98 با 2 شروع می‌شود
	}

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

			// Arrange
			in := tt.in

			// Act
			got, err := NormalizeIranMobile(in)

			// Assert
			if tt.err != nil {
				if err == nil || err != tt.err {
					t.Fatalf("expected err %v, got %v", tt.err, err)
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected err: %v", err)
			}
			if got != tt.want {
				t.Fatalf("got %q; want %q", got, tt.want)
			}
		})
	}
}

به همین سادگی توانستیم ۲۰ سناریوی مختلف را بر روی این تابع تست کنیم.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *