الاختبارات

الدرس 23: الاختبارات

تشبيه من الحياة

تخيل أنك فتحت مطعماً. قبل تقديم كل طبق، يتذوقه الطاهي أولاً — للتأكد من أن التتبيلة صحيحة والطبخ تم بشكل سليم. اختبار البرمجيات مثل هذه "التذوق": قبل تسليم الكود للمستخدمين، يتم التحقق منه تلقائياً لضمان عمله كما هو متوقع. الإطلاق بدون اختبار مثل تقديم طعام غير متذوق للعملاء — عاجلاً أم آجلاً ستظهر مشاكل.

المفاهيم الأساسية

لدي Go سلسلة أدوات اختبار مدمجة كاملة، بدون الحاجة لأطر عمل طرف ثالث. المفاهيم الأساسية كالتالي:

المفهوم الوصف
testing.T سياق اختبار الوحدة، يُستخدم للإبلاغ عن فشل الاختبار
testing.B سياق اختبار المعيار، يُستخدم لقياس الأداء
testing.M كائن نقطة دخول الاختبار، يُستخدم لإعداد TestMain العام
*_test.go اصطلاح تسمية ملفات الاختبار، يتم تجميعها فقط أثناء الاختبار
go test أداة سطر الأوامر لتشغيل الاختبارات
الاختبارات المدفوعة بالجدول نمط قياسي لقيادة حالات اختبار متعددة بجدول بيانات
httptest حزمة مساعد اختبار لطلبات HTTP

الصياغة الأساسية والاستخدام

اصطلاحات ملفات الاختبار

يجب أن تنتهي ملفات الاختبار بـ _test.go وتوضع في نفس حزمة الكود المُختبر:

myapp/
├── math.go
└── math_test.go

توقيع دالة الاختبار

GO
func TestXxx(t *testing.T) {
    // منطق الاختبار
}

يجب أن يبدأ اسم الدالة بـ Test، والمعامل هو *testing.T.

طرق التحقق

ليس في Go دالة assert مدمجة — يجب التحقق يدوياً:

GO
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d، المتوقع 5", result)
    }
}

الطرق الشائعة:

الطريقة الغرض
t.Error() / t.Errorf() الإبلاغ عن خطأ، متابعة التنفيذ
t.Fatal() / t.Fatalf() الإبلاغ عن خطأ، إنهاء فوري للاختبار الحالي
t.Skip() / t.Skipf() تخطي الاختبار الحالي
t.Log() / t.Logf() إخراج السجل (يظهر فقط مع -v)
t.Run(name, func) تشغيل اختبار فرعي
t.Helper() تحديد كدالة مساعدة، الإبلاغ عن الأخطاء يشير إلى المتصل
💡 نصيحة 1: يجب أن تكون معاملات دالة الاختبار والقيم المرجعة بسيطة قدر الإمكان للتحقق السهل. استخدم الاختبارات المدفوعة بالجدول للمدخلات المعقدة.

💡 نصيحة 2: استخدم t.Helper() لتحديد دوال التحقق المساعدة، بحيث يشير الإبلاغ عن الفشل إلى موقع المتصل بدلاً من داخل الدالة المساعدة.

💡 نصيحة 3: go test -v يُظهر مخرجات مفصلة، go test -run=regex يشغل فقط الاختبارات المطابقة، -count=1 يعطل التخزين المؤقت.

تشغيل الاختبارات

BASH
# تشغيل جميع الاختبارات في الحزمة الحالية
go test

# مخرجات مفصلة
go test -v

# تشغيل اختبارات مطابقة للاسم
go test -run TestAdd

# تشغيل في المجلد الحالي والمجلدات الفرعية
go test ./...

# عرض التغطية
go test -cover

أمثلة

مثال: اختبارات وحدة أساسية (الصعوبة ⭐)

هيكل الملفات:

calculator/
├── calc.go
└── calc_test.go
▶ جرّب الكود

calc.go:

GO
package calculator

// Add تعيد مجموع عددين
func Add(a, b int) int {
    return a + b
}

// Divide تعيد حاصل القسمة وما إذا كان هناك خطأ
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("المقسوم عليه لا يمكن أن يكون صفراً")
    }
    return a / b, nil
}

calc_test.go:

GO
package calculator

import (
    "testing"
)

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d، المتوقع %d", got, want)
    }
}

func TestDivide(t *testing.T) {
    got, err := Divide(10, 3)
    if err != nil {
        t.Fatalf("خطأ غير متوقع: %v", err)
    }
    want := 3.3333333333333335
    if got != want {
        t.Errorf("Divide(10, 3) = %f، المتوقع %f", got, want)
    }
}

func TestDivideByZero(t *testing.T) {
    _, err := Divide(10, 0)
    if err == nil {
        t.Error("القسمة على صفر يجب أن تُرجع خطأ")
    }
}

المخرجات:

BASH
$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestDivide
--- PASS: TestDivide (0.00s)
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok      calculator      0.003s

مثال: اختبارات مدفوعة بالجدول واختبارات فرعية (الصعوبة ⭐⭐)

الاختبارات المدفوعة بالجدول هي نمط الاختبار الأكثر إشادة في مجتمع Go — ضع المدخلات والمخرجات المتوقعة في شريحة، ثم حلقة وتحقق.

stringutil.go:

GO
package stringutil

import "unicode"

// Reverse تعكس نصاً
func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}

// IsPalindrome تتحقق مما إذا كان نصاً معكوساً (palindrome)
func IsPalindrome(s string) bool {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        if unicode.ToLower(runes[i]) != unicode.ToLower(runes[j]) {
            return false
        }
    }
    return true
}
▶ جرّب الكود

stringutil_test.go:

GO
package stringutil

import "testing"

func TestReverse(t *testing.T) {
    // تعريف جدول حالات الاختبار
    tests := []struct {
        name  string // اسم الحالة
        input string
        want  string
    }{
        {"نص فارغ", "", ""},
        {"حرف واحد", "a", "a"},
        {"نص عادي", "hello", "olleh"},
        {"عربي", "مرحبا", "ابحرم"},
        {"معكوس", "racecar", "racecar"},
    }

    for _, tt := range tests {
        // t.Run ينشئ اختباراً فرعياً، كل حالة تعمل بشكل مستقل
        t.Run(tt.name, func(t *testing.T) {
            got := Reverse(tt.input)
            if got != tt.want {
                t.Errorf("Reverse(%q) = %q، المتوقع %q", tt.input, got, tt.want)
            }
        })
    }
}

func TestIsPalindrome(t *testing.T) {
    tests := []struct {
        name  string
        input string
        want  bool
    }{
        {"معكوس إنجليزي", "racecar", true},
        {"معكوس عربي", "مرحبا بالعالم", true},
        {"ليس معكوساً", "hello", false},
        {"غير حساس لحالة الأحرف", "RaceCar", true},
        {"نص فارغ", "", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := IsPalindrome(tt.input)
            if got != tt.want {
                t.Errorf("IsPalindrome(%q) = %v، المتوقع %v", tt.input, got, tt.want)
            }
        })
    }
}

تشغيل اختبار فرعي محدد:

BASH
# تشغيل حالة المعكوس الصيني فقط
$ go test -v -run=TestIsPalindrome/معكوس_صيني
=== RUN   TestIsPalindrome/معكوس_صيني
--- PASS: TestIsPalindrome/معكوس_صيني (0.00s)
PASS
💡 فائدة الاختبارات المدفوعة بالجدول: إضافة حالة جديدة تتطلب فقط إضافة سطر إلى الشريحة، بدون الحاجة لكتابة دالة اختبار جديدة.


مثال: Benchmark، TestMain، و httptest (الصعوبة ⭐⭐⭐)

handler.go:

GO
package handler

import (
    "encoding/json"
    "net/http"
)

type Response struct {
    Message string `json:"message"`
    Code    int    `json:"code"`
}

// HelloHandler يعالج طلبات /hello
func HelloHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "World"
    }

    resp := Response{
        Message: "Hello, " + name,
        Code:    200,
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}
▶ جرّب الكود

handler_test.go:

GO
package handler

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "os"
    "testing"
)

// TestMain يعمل قبل جميع الاختبارات، يمكن استخدامه للإعداد والتنظيف العام
func TestMain(m *testing.M) {
    // هنا يمكن إعداد اتصالات قاعدة البيانات، تهيئة التكوين، إلخ
    setup()

    // تشغيل جميع الاختبارات
    code := m.Run()

    // تنظيف الموارد
    teardown()

    // استخدام نتيجة الاختبار كرمز خروج
    os.Exit(code)
}

func setup() {
    // كود التهيئة (مثل تحميل تكوين الاختبار)
}

func teardown() {
    // كود التنظيف (مثل إغلاق اتصال قاعدة البيانات)
}

func TestHelloHandler(t *testing.T) {
    tests := []struct {
        name       string
        queryParam string
        wantMsg    string
        wantCode   int
    }{
        {"الاسم الافتراضي", "", "Hello, World", 200},
        {"اسم مخصص", "Go", "Hello, Go", 200},
        {"اسم عربي", "أحمد", "Hello, أحمد", 200},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            // بناء الطلب
            url := "/hello"
            if tt.queryParam != "" {
                url += "?name=" + tt.queryParam
            }
            req := httptest.NewRequest(http.MethodGet, url, nil)

            // إنشاء مسجل استجابة
            rr := httptest.NewRecorder()

            // استدعاء المعالج
            HelloHandler(rr, req)

            // التحقق من رمز الحالة
            if rr.Code != tt.wantCode {
                t.Errorf("رمز الحالة = %d، المتوقع %d", rr.Code, tt.wantCode)
            }

            // التحقق من جسم الاستجابة
            var resp Response
            if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
                t.Fatalf("فشل تحليل الاستجابة: %v", err)
            }
            if resp.Message != tt.wantMsg {
                t.Errorf("الرسالة = %q، المتوقع %q", resp.Message, tt.wantMsg)
            }
        })
    }
}

// BenchmarkHelloHandler اختبار معياري، يقيس أداء المعالج
func BenchmarkHelloHandler(b *testing.B) {
    req := httptest.NewRequest(http.MethodGet, "/hello?name=Go", nil)

    // b.N يتم تعديله تلقائياً بواسطة إطار الاختبار لضمان نتائج مستقرة
    for i := 0; i < b.N; i++ {
        rr := httptest.NewRecorder()
        HelloHandler(rr, req)
    }
}

// BenchmarkReverse اختبار معياري لعكس النصوص
func BenchmarkReverse(b *testing.B) {
    s := "Hello, World! This is a test string"
    for i := 0; i < b.N; i++ {
        _ = Reverse(s) // افتراض أن Reverse في نفس الحزمة
    }
}

تشغيل الاختبارات المعيارية:

BASH
$ go test -bench=. -benchmem -count=3
goos: linux
goarch: amd64
pkg: handler
BenchmarkHelloHandler-8     200000    7523 ns/op    1248 B/op    18 allocs/op
BenchmarkHelloHandler-8     200000    7401 ns/op    1248 B/op    18 allocs/op
BenchmarkHelloHandler-8     200000    7350 ns/op    1248 B/op    18 allocs/op
PASS
ok      handler 4.832s

عرض التغطية:

BASH
# إنشاء تقرير تغطية
$ go test -coverprofile=coverage.out

# عرض التغطية لكل دالة في الطرفية
$ go tool cover -func=coverage.out
total:  (statements)    85.7%

# إنشاء تقرير HTML (افتح في المتصفح)
$ go tool cover -html=coverage.out -o coverage.html

تطبيقات سيناريوهاتية

السيناريو 1: اختبار Middleware

في تطوير الويب، middleware مثل المصادقة والتسجيل تحتاج للاختبار بشكل مستقل:

GO
package middleware

import (
    "net/http"
    "strings"
)

// AuthMiddleware يتحقق من Token في رؤوس الطلبات
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !strings.HasPrefix(token, "Bearer ") {
            http.Error(w, "غير مصرح به", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

الاختبار:

GO
package middleware

import (
    "net/http"
    "net/http/httptest"
    "testing"
)

func TestAuthMiddleware(t *testing.T) {
    // إنشاء معالج تابع بسيط
    nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("تماجاز"))
    })

    middleware := AuthMiddleware(nextHandler)

    t.Run("بدون token يجب أن يُرجع 401", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusUnauthorized {
            t.Errorf("رمز الحالة = %d، المتوقع %d", rr.Code, http.StatusUnauthorized)
        }
    })

    t.Run("token صالح يجب أن يمر", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        req.Header.Set("Authorization", "Bearer my-secret-token")
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusOK {
            t.Errorf("رمز الحالة = %d، المتوقع %d", rr.Code, http.StatusOK)
        }
    })

    t.Run("بادئة غير صالحة يجب أن تُرجع 401", func(t *testing.T) {
        req := httptest.NewRequest(http.MethodGet, "/api/data", nil)
        req.Header.Set("Authorization", "Basic abc123")
        rr := httptest.NewRecorder()
        middleware.ServeHTTP(rr, req)

        if rr.Code != http.StatusUnauthorized {
            t.Errorf("رمز الحالة = %d، المتوقع %d", rr.Code, http.StatusUnauthorized)
        }
    })
}

السيناريو 2: اختبار عمليات قاعدة البيانات (عزل الواجهات)

عزل اعتمادات قاعدة البيانات عبر الواجهات، حقن المحاكين أثناء الاختبار:

GO
package user

import "context"

// UserRepository يحدد واجهة الوصول إلى بيانات المستخدم
type UserRepository interface {
    GetByID(ctx context.Context, id int) (*User, error)
    Create(ctx context.Context, user *User) error
}

// User نموذج المستخدم
type User struct {
    ID   int
    Name string
}

// Service منطق أعمال المستخدم
type Service struct {
    repo UserRepository
}

// NewService ينشئ خدمة مستخدم
func NewService(repo UserRepository) *Service {
    return &Service{repo: repo}
}

// GetUser يحصل على مستخدم، يُرجع خطأ إذا لم يتم العثور عليه
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("معرف مستخدم غير صالح: %d", id)
    }
    return s.repo.GetByID(ctx, id)
}

المحاكاة والاختبار:

GO
package user

import (
    "context"
    "testing"
)

// mockRepo ينفذ واجهة UserRepository للاختبار
type mockRepo struct {
    users map[int]*User
}

func (m *mockRepo) GetByID(_ context.Context, id int) (*User, error) {
    if u, ok := m.users[id]; ok {
        return u, nil
    }
    return nil, fmt.Errorf("المستخدم %d غير موجود", id)
}

func (m *mockRepo) Create(_ context.Context, user *User) error {
    m.users[user.ID] = user
    return nil
}

func TestGetUser(t *testing.T) {
    mock := &mockRepo{
        users: map[int]*User{
            1: {ID: 1, Name: "Alice"},
            2: {ID: 2, Name: "Bob"},
        },
    }
    svc := NewService(mock)

    t.Run("مستخدم موجود", func(t *testing.T) {
        user, err := svc.GetUser(context.Background(), 1)
        if err != nil {
            t.Fatalf("خطأ غير متوقع: %v", err)
        }
        if user.Name != "Alice" {
            t.Errorf("الاسم = %q، المتوقع %q", user.Name, "Alice")
        }
    })

    t.Run("مستخدم غير موجود", func(t *testing.T) {
        _, err := svc.GetUser(context.Background(), 99)
        if err == nil {
            t.Error("يُتوقع إرجاع خطأ")
        }
    })

    t.Run("معرف غير صالح", func(t *testing.T) {
        _, err := svc.GetUser(context.Background(), -1)
        if err == nil {
            t.Error("يُتوقع إرجاع خطأ")
        }
    })
}

❓ أسئلة شائعة

س1: ماذا لو لم يتم التعرف على ملفات الاختبار؟

تأكد من أن اسم الملف ينتهي بـ _test.go واسم الحزمة صحيح. ملفات الاختبار يتم تجاهلها أثناء go build وتُجمع فقط أثناء go test. إذا كان ملف الاختبار في حزمة _test (أي package foo_test)، يمكنه فقط اختبار المُعرّفات المُصدّرة.

س2: كيف أشغل فقط الاختبارات الفاشلة؟

استخدم المعامل -regex مع -regex:

BASH
# تشغيل اختبار محدد بالاسم
go test -run TestDivideByZero -v

# إذا كنت تستخدم go test -v، ستُعرض أسماء الاختبارات الفاشلة في المخرجات

يمكنك أيضاً الدمج مع -count=1 لتعطيل التخزين المؤقت وضمان إعادة تشغيل الاختبارات فعلياً:

BASH
go test -run TestSomething -count=1 -v

س3: ما الفرق بين t.Fatal و t.Error؟

المبدأ العام: إذا كانت التحققات اللاحقة تعتمد على نتائج سابقة (مثل التحقق من err قبل إلغاء تعيينمؤشر)، استخدم Fatal؛ للتحققات المستقلة المتعددة، استخدم Error.

س4: كيف أتخطى الاختبارات التي تستغرق وقتاً طويلاً؟

GO
func TestSlowOperation(t *testing.T) {
    if testing.Short() {
        t.Skip("تخطي الاختبار الذي يستغرق وقتاً طويلاً (استخدم علامة -short)")
    }
    // عملية تستغرق وقتاً طويلاً...
}

شغل بعلامة -short للتخطي:

BASH
go test -short

📖 ملخص

غطى هذا الدرس المحتوى الأساسي لاختبارات Go:

  1. دوال الاختبار: تبدأ بـ Test، المعامل *testing.T، استخدم Error/Fatal للإبلاغ عن الفشل
  2. الاختبارات المدفوعة بالجدول: تعريف الحالات بشريحة، حلقة + t.Run للتشغيل — النمط القياسي لمجتمع Go
  3. الاختبارات الفرعية: t.Run(name, func) تسمح للحالات بالعمل بشكل مستقل، مما يسهل تحديد المشاكل
  4. اختبارات Benchmark: تبدأ بـ Benchmark، المعامل *testing.B، حلقة b.N مرة لقياس الأداء
  5. TestMain: نقطة دخول عامة للإعداد/التنظيف، تستدعي m.Run() لتنفيذ جميع الاختبارات
  6. httptest: httptest.NewRequest + httptest.NewRecorder لاختبار معالجات HTTP
  7. التغطية: go test -cover للعرض، -coverprofile لإنشاء تقارير مفصلة

📝 تمارين

التمرين 1: كتابة اختبارات لدوال معالجة النصوص

اكتب اختبارات للدالة التالية تحتوي على 5 حالات مدفوعة بالجدول على الأقل:

GO
// Truncate تقطع النص إلى الطول المحدد، واستبدال الزائد بـ "..."
func Truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen-3]) + "..."
}

التمرين 2: كتابة Benchmarks لمعالجات HTTP

اكتب Benchmarks لـ API يُرجع مصفوفة JSON،قارن الفرق في الأداء بين json.Marshal و json.Encoder:

GO
// ListHandler يُرجع قائمة مستخدمين
func ListHandler(w http.ResponseWriter, r *http.Request) {
    users := []User{{1, "Alice"}, {2, "Bob"}, {3, "Charlie"}}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(users)
}

المتطلبات:

التمرين 3: استخدام TestMain لتنفيذ قاعدة بيانات اختبار

صمم حل اختبار باستخدام TestMain:

  1. إنشاء قاعدة بيانات SQLite مؤقتة في TestMain
  2. تشغيل الترحيل لإنشاء الجداول
  3. تنفيذ جميع الاختبارات
  4. حذف قاعدة البيانات المؤقتة بعد اكتمال الاختبارات

تلميح: استخدم القيمة المرجعة لـ m.Run() كمعامل لـ os.Exit().


الدرس التالي

الدرس التالي: التعبيرات النمطية والتاريخ ←

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%