الاختبارات الموجهة بالجدول المتوازية في Go
تسريع اختبارات Go باستخدام التنفيذ المتوازي
الاختبارات القائمة على الجداول هي الطريقة الأسلوبية في Go للقيام باختبار عدة سيناريوهات بكفاءة.
عند دمجها مع تنفيذ متوازي باستخدام t.Parallel()، يمكنك تقليل وقت تشغيل مجموعة الاختبارات بشكل كبير، خاصةً للعمليات المرتبطة بالمدخل/الإخراج.
ومع ذلك، فإن الاختبار المتوازي يطرح تحديات فريدة حول الظروف التنافسية وعزل الاختبارات التي تتطلب اهتمامًا دقيقًا.

فهم تنفيذ الاختبار المتوازي
يوفر حزمة الاختبار في Go دعمًا مدمجًا لتنفيذ الاختبار المتوازي عبر طريقة t.Parallel() . عندما يقوم الاختبار بإجراء t.Parallel()، فإنه يشير إلى مُنفذ الاختبار أن هذا الاختبار يمكن تنفيذه بشكل متزامن مع اختبارات متوازية أخرى. هذا أمر قوي بشكل خاص عند دمجه مع الاختبارات القائمة على الجداول، حيث يكون لديك العديد من حالات الاختبار المستقلة التي يمكن تنفيذها في وقت واحد.
العدد الافتراضي للتوافر المتوازي يُتحكم فيه عبر GOMAXPROCS، والذي عادةً ما يساوي عدد أجهزة المعالجة المركزية على جهازك. يمكنك تعديله باستخدام علم -parallel: go test -parallel 4 يحد من عدد الاختبارات المتزامنة إلى 4، بغض النظر عن عدد أجهزة المعالجة المركزية. هذا مفيد لضبط استخدام الموارد أو عندما تكون الاختبارات لديها متطلبات معينة للتوافر المتوازي.
للمطورين الجدد في اختبار Go، فهم المبادئ الأساسية أمر حيوي. دليلنا حول أفضل الممارسات لاختبار الوحدات في Go يغطي الاختبارات القائمة على الجداول، الفرعيات، والأساسيات لحزمة الاختبار التي تشكل الأساس لتنفيذ الاختبار المتوازي.
نمط الاختبار المتوازي القائم على الجداول الأساسي
هذا هو النمط الصحيح للاختبارات المتوازية القائمة على الجداول:
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 5, 3, "-", 2, false},
{"multiplication", 4, 3, "*", 12, false},
{"division", 10, 2, "/", 5, false},
{"division by zero", 10, 0, "/", 0, true},
}
for _, tt := range tests {
tt := tt // التقاط متغير الحلقة
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // تمكين التنفيذ المتوازي
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
السطر المهم هو tt := tt قبل t.Run() . هذا يلتقط القيمة الحالية لمتغير الحلقة، مما يضمن أن كل اختبار فرعي ينفذ على نسخة مستقلة من بيانات حالة الاختبار.
مشكلة التقاط متغير الحلقة
هذا أحد أكثر الأخطاء شيوعًا عند استخدام t.Parallel() مع الاختبارات القائمة على الجداول. في Go، متغير الحلقة tt مشار إليه عبر جميع التكرارات. عندما تُنفذ الفرعيات في وقت واحد، قد تشير جميعها إلى نفس متغير tt، والذي يتم تجاوزه مع استمرار الحلقة. هذا يؤدي إلى ظروف تنافسية وفشل اختبار غير متوقع.
غير صحيح (ظرف تنافسي):
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// قد ترى جميع الفرعيات نفس قيمة tt!
result := Calculate(tt.a, tt.b, tt.op)
})
}
صحيح (متغير ملتقط):
for _, tt := range tests {
tt := tt // التقاط متغير الحلقة
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// كل فرعي لديه نسخة مستقلة من tt
result := Calculate(tt.a, tt.b, tt.op)
})
}
التعيين tt := tt ينشئ متغيرًا جديدًا محدودًا بترتيب الحلقة، مما يضمن أن كل غوروتين لديه نسخة مستقلة من بيانات حالة الاختبار.
ضمان استقلالية الاختبار
لتعمل الاختبارات المتوازية بشكل صحيح، يجب أن يكون كل اختبار مستقل تمامًا. يجب ألا تفعل:
- مشاركة الحالة العالمية أو المتغيرات
- تعديل الموارد المشتركة دون تزامن
- الاعتماد على ترتيب التنفيذ
- الوصول إلى نفس الملفات، أو قواعد البيانات، أو الموارد الشبكية دون تنسيق
مثال على اختبارات متوازية مستقلة:
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid email", "user@example.com", false},
{"invalid format", "not-an-email", true},
{"missing domain", "user@", true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
كل حالة اختبار تعمل على بيانات إدخالها الخاصة دون مشاركة الحالة، مما يجعلها آمنة للاستخدام في التنفيذ المتوازي.
اكتشاف ظروف التنافس
يوفر Go مُكتشفًا قويًا للظواهر التنافسية للكشف عن التعارضات في الاختبارات المتوازية. قم دائمًا بتشغيل اختباراتك المتوازية مع علم -race أثناء التطوير:
go test -race ./...
سيقوم مكتشف التنافس بتقارير أي الوصول المتزامن إلى الذاكرة المشتركة دون تزامن مناسب. هذا ضروري للكشف عن الأخطاء الدقيقة التي قد تظهر فقط تحت ظروف محددة.
مثال على ظرف تنافس:
var counter int // الحالة المشتركة - الخطر!
func TestIncrement(t *testing.T) {
tests := []struct {
name string
want int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
counter++ // ظرف تنافس!
if counter != tt.want {
t.Errorf("counter = %d, want %d", counter, tt.want)
}
})
}
}
عند تشغيل هذا باستخدام -race سيكتشف الزيادة المتزامنة لـ counter . الحل هو جعل كل اختبار مستقل باستخدام متغيرات محلية بدلًا من الحالة المشتركة.
الفوائد الأداء
يمكن أن يؤدي التنفيذ المتوازي إلى تقليل وقت تشغيل مجموعة الاختبار بشكل كبير. يعتمد معدل التسارع على:
- عدد أجهزة المعالجة المركزية: كلما زاد العدد، يمكن تشغيل عدد أكبر من الاختبارات في وقت واحد
- خصائص الاختبار: الاختبارات المرتبطة بالمدخل/الإخراج تحقق فوائد أكثر من الاختبارات المرتبطة بالمعالجة
- عدد الاختبارات: المجموعات الأكبر تحقق مكاسب أكبر في الوقت المطلق
قياس الأداء:
# التنفيذ التسلسلي
go test -parallel 1 ./...
# التنفيذ المتوازي (الافتراضي)
go test ./...
# تنفيذ متوازي مخصص
go test -parallel 8 ./...
للمجموعات التي تحتوي على عدد كبير من العمليات المرتبطة بالمدخل/الإخراج (الاستعلامات إلى قواعد البيانات، طلبات HTTP، عمليات الملفات)، يمكنك تحقيق تسارع 2-4 مرات على الأنظمة الحديثة ذات أجهزة المعالجة المركزية متعددة. قد لا تحقق الاختبارات المرتبطة بالمعالجة فوائد كبيرة بسبب التنافس على موارد المعالجة.
التحكم في التوازي
لديك عدة خيارات للتحكم في تنفيذ الاختبار المتوازي:
1. تحديد الحد الأقصى للاختبارات المتوازية:
go test -parallel 4 ./...
2. تحديد GOMAXPROCS:
GOMAXPROCS=2 go test ./...
3. تنفيذ متوازي انتقائي:
قم بتسمية اختبارات محددة فقط مع t.Parallel() . الاختبارات بدون هذا الاتصال تنفذ تسلسليًا، وهو مفيد عندما يجب أن تنفذ بعض الاختبارات في ترتيب معين أو تشارك الموارد.
4. تنفيذ متوازي شرطي:
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("skipping expensive test in short mode")
}
t.Parallel()
// منطق الاختبار المكلف
}
الأنماط الشائعة والممارسات الأفضل
نمط 1: إعداد قبل التنفيذ المتوازي
إذا كنت بحاجة إلى إعداد مشترك عبر جميع حالات الاختبار، فقم به قبل الحلقة:
func TestWithSetup(t *testing.T) {
// يتم تنفيذه مرة واحدة قبل التنفيذ المتوازي
db := setupTestDatabase(t)
defer db.Close()
tests := []struct {
name string
id int
}{
{"user1", 1},
{"user2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// كل اختبار يستخدم db بشكل مستقل
user := db.GetUser(tt.id)
// منطق الاختبار...
})
}
}
نمط 2: إعداد لكل اختبار
للاختبارات التي تحتاج إلى إعداد مستقل، قم به داخل كل فرعي:
func TestWithPerTestSetup(t *testing.T) {
tests := []struct {
name string
data string
}{
{"test1", "data1"},
{"test2", "data2"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// كل اختبار يحصل على إعداد مستقل
tempFile := createTempFile(t, tt.data)
defer os.Remove(tempFile)
// منطق الاختبار...
})
}
}
نمط 3: مزيج تسلسلي ومتوازي
يمكنك مزج الاختبارات التسلسلية والمتوازية في نفس الملف:
func TestSequential(t *testing.T) {
// لا توجد t.Parallel() - تنفذ تسلسليًا
// مناسبة للاختبارات التي يجب أن تنفذ في ترتيب معين
}
func TestParallel(t *testing.T) {
tests := []struct{ name string }{{"test1"}, {"test2"}}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // هذه تنفذ متوازية
})
}
}
متى لا تستخدم التنفيذ المتوازي
ليس دائمًا مناسبًا استخدام التنفيذ المتوازي. تجنبه عندما:
- تشارك الاختبارات الحالة: المتغيرات العالمية، والوحدات الفريدة، أو الموارد المشتركة
- تُعدّل الاختبارات الملفات المشتركة: الملفات المؤقتة، قواعد بيانات الاختبار، أو الملفات التكوينية
- تتطلب الاختبارات ترتيب تنفيذ معين: بعض الاختبارات يجب أن تنفذ قبل أخرى
- الاختبارات سريعة بالفعل: قد يتجاوز التكلفة المرتبطة بالتوافر المتوازي الفوائد
- قيود الموارد: تستهلك الاختبارات الكثير من الذاكرة أو المعالجة عند التوازي
لل اختبارات مرتبطة بقواعد البيانات، فكر في استخدام تراجعات المعاملات أو قواعد بيانات اختبار مستقلة لكل اختبار. دليلنا حول أنماط قواعد البيانات متعددة المستأجرين في Go يغطي استراتيجيات العزل التي تعمل بشكل جيد مع الاختبار المتوازي.
متقدم: اختبار الكود المتوازي
عند اختبار الكود المتوازي نفسه (ليس فقط تنفيذ الاختبارات المتوازية)، تحتاج إلى تقنيات إضافية:
func TestConcurrentOperation(t *testing.T) {
tests := []struct {
name string
goroutines int
}{
{"2 goroutines", 2},
{"10 goroutines", 10},
{"100 goroutines", 100},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var wg sync.WaitGroup
results := make(chan int, tt.goroutines)
for i := 0; i < tt.goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- performOperation()
}()
}
wg.Wait()
close(results)
// التحقق من النتائج
count := 0
for range results {
count++
}
if count != tt.goroutines {
t.Errorf("expected %d results, got %d", tt.goroutines, count)
}
})
}
}
قم دائمًا بتشغيل هذه الاختبارات مع -race للكشف عن ظروف التنافس في الكود تحت الاختبار.
التكامل مع CI/CD
تتكامل الاختبارات المتوازية بسلاسة مع أنظمة CI/CD. توفر معظم أنظمة CI أجهزة معالجة مركزية متعددة، مما يجعل تنفيذ الاختبار المتوازي مفيدًا للغاية:
# مثال GitHub Actions
- name: Run tests
run: |
go test -race -coverprofile=coverage.out -parallel 4 ./...
go tool cover -html=coverage.out -o coverage.html
العلم -race مفيد بشكل خاص في CI للكشف عن الأخطاء التنافسية التي قد لا تظهر في التطوير المحلي.
تصحيح فشل الاختبارات المتوازية
عندما تفشل الاختبارات المتوازية بشكل متقطع، قد يكون التصحيح صعبًا:
- تشغيل مع
-race: تحديد ظروف التنافس - تقليل التوازي:
go test -parallel 1لرؤية ما إذا كانت الفشل تختفي - تشغيل اختبارات محددة:
go test -run TestNameلعزل المشكلة - إضافة سجلات: استخدام
t.Log()لتتبع ترتيب التنفيذ - التحقق من الحالة المشتركة: البحث عن المتغيرات العالمية، الوحدات الفريدة، أو الموارد المشتركة
إذا نجحت الاختبارات تسلسليًا ولكن فشلت متوازية، فمن المحتمل أنك تواجه ظرف تنافس أو مشكلة في الحالة المشتركة.
مثال عملي: اختبار معالجات HTTP
هنا مثال عملي لاختبار معالجات HTTP في وقت متوازي:
func TestHTTPHandlers(t *testing.T) {
router := setupRouter()
tests := []struct {
name string
method string
path string
statusCode int
}{
{"GET users", "GET", "/users", 200},
{"GET user by ID", "GET", "/users/1", 200},
{"POST user", "POST", "/users", 201},
{"DELETE user", "DELETE", "/users/1", 204},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.statusCode {
t.Errorf("expected status %d, got %d", tt.statusCode, w.Code)
}
})
}
}
كل اختبار يستخدم httptest.NewRecorder()، الذي ينشئ مسجل استجابة مستقل، مما يجعل هذه الاختبارات آمنة للاستخدام في التنفيذ المتوازي.
الخاتمة
التنفيذ المتوازي للاختبارات القائمة على الجداول هو تقنية قوية لتقليل وقت تشغيل مجموعة الاختبار في Go. المفتاح للنجاح هو فهم متطلبات التقاط متغير الحلقة، ضمان استقلالية الاختبار، واستخدام مكتشف التنافس للكشف عن المشكلات في وقت مبكر.
تذكّر:
- دائمًا التقاط متغير الحلقة:
tt := ttقبلt.Parallel() - تأكد من أن الاختبارات مستقلة بدون حالة مشتركة
- قم بتشغيل الاختبارات مع
-raceأثناء التطوير - قم بتحكم في التوازي باستخدام علم
-parallelعند الحاجة - تجنب تنفيذ الاختبارات المتوازية للاختبارات التي تشارك الموارد
من خلال اتباع هذه الممارسات، يمكنك الاستفادة بأمان من التنفيذ المتوازي لتسريع مجموعات الاختبار مع الحفاظ على الموثوقية. للمزيد من الأنماط في اختبار Go، راجع دليلنا الشامل حول اختبار الوحدات في Go، والذي يغطي الاختبارات القائمة على الجداول، المُزايَّات، والتقنيات الأخرى الأساسية للاختبار.
عند بناء تطبيقات Go الأكبر، تطبق هذه ممارسات الاختبار عبر مجالات مختلفة. على سبيل المثال، عند بناء تطبيقات سطر الأوامر مع Cobra & Viper، ستعمل على استخدام أنماط مماثلة لاختبار معالجات الأوامر وتحليل التكوين.
روابط مفيدة
- دليل مختصر لـ Go
- اختبار الوحدات في Go: الهيكلية والممارسات الأفضل
- بناء تطبيقات سطر الأوامر في Go مع Cobra & Viper
- أنماط قواعد البيانات متعددة المستأجرين مع أمثلة في Go
- ORMs لـ PostgreSQL في Go: GORM vs Ent vs Bun vs sqlc