الاختبارات
الدرس 23: الاختبارات
تشبيه من الحياة
تخيل أنك فتحت مطعماً. قبل تقديم كل طبق، يتذوقه الطاهي أولاً — للتأكد من أن التتبيلة صحيحة والطبخ تم بشكل سليم. اختبار البرمجيات مثل هذه "التذوق": قبل تسليم الكود للمستخدمين، يتم التحقق منه تلقائياً لضمان عمله كما هو متوقع. الإطلاق بدون اختبار مثل تقديم طعام غير متذوق للعملاء — عاجلاً أم آجلاً ستظهر مشاكل.
المفاهيم الأساسية
لدي Go سلسلة أدوات اختبار مدمجة كاملة، بدون الحاجة لأطر عمل طرف ثالث. المفاهيم الأساسية كالتالي:
| المفهوم | الوصف |
|---|---|
testing.T |
سياق اختبار الوحدة، يُستخدم للإبلاغ عن فشل الاختبار |
testing.B |
سياق اختبار المعيار، يُستخدم لقياس الأداء |
testing.M |
كائن نقطة دخول الاختبار، يُستخدم لإعداد TestMain العام |
*_test.go |
اصطلاح تسمية ملفات الاختبار، يتم تجميعها فقط أثناء الاختبار |
go test |
أداة سطر الأوامر لتشغيل الاختبارات |
| الاختبارات المدفوعة بالجدول | نمط قياسي لقيادة حالات اختبار متعددة بجدول بيانات |
httptest |
حزمة مساعد اختبار لطلبات HTTP |
الصياغة الأساسية والاستخدام
اصطلاحات ملفات الاختبار
يجب أن تنتهي ملفات الاختبار بـ _test.go وتوضع في نفس حزمة الكود المُختبر:
myapp/
├── math.go
└── math_test.go
توقيع دالة الاختبار
func TestXxx(t *testing.T) {
// منطق الاختبار
}
يجب أن يبدأ اسم الدالة بـ Test، والمعامل هو *testing.T.
طرق التحقق
ليس في Go دالة assert مدمجة — يجب التحقق يدوياً:
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() |
تحديد كدالة مساعدة، الإبلاغ عن الأخطاء يشير إلى المتصل |
💡 نصيحة 2: استخدم t.Helper() لتحديد دوال التحقق المساعدة، بحيث يشير الإبلاغ عن الفشل إلى موقع المتصل بدلاً من داخل الدالة المساعدة.
💡 نصيحة 3: go test -v يُظهر مخرجات مفصلة، go test -run=regex يشغل فقط الاختبارات المطابقة، -count=1 يعطل التخزين المؤقت.
تشغيل الاختبارات
# تشغيل جميع الاختبارات في الحزمة الحالية
go test
# مخرجات مفصلة
go test -v
# تشغيل اختبارات مطابقة للاسم
go test -run TestAdd
# تشغيل في المجلد الحالي والمجلدات الفرعية
go test ./...
# عرض التغطية
go test -cover
أمثلة
مثال: اختبارات وحدة أساسية (الصعوبة ⭐)
هيكل الملفات:
calculator/
├── calc.go
└── calc_test.go
calc.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:
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("القسمة على صفر يجب أن تُرجع خطأ")
}
}
المخرجات:
$ 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:
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:
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)
}
})
}
}
تشغيل اختبار فرعي محدد:
# تشغيل حالة المعكوس الصيني فقط
$ go test -v -run=TestIsPalindrome/معكوس_صيني
=== RUN TestIsPalindrome/معكوس_صيني
--- PASS: TestIsPalindrome/معكوس_صيني (0.00s)
PASS
مثال: Benchmark، TestMain، و httptest (الصعوبة ⭐⭐⭐)
handler.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:
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 في نفس الحزمة
}
}
تشغيل الاختبارات المعيارية:
$ 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
عرض التغطية:
# إنشاء تقرير تغطية
$ 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 مثل المصادقة والتسجيل تحتاج للاختبار بشكل مستقل:
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)
})
}
الاختبار:
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: اختبار عمليات قاعدة البيانات (عزل الواجهات)
عزل اعتمادات قاعدة البيانات عبر الواجهات، حقن المحاكين أثناء الاختبار:
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)
}
المحاكاة والاختبار:
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:
# تشغيل اختبار محدد بالاسم
go test -run TestDivideByZero -v
# إذا كنت تستخدم go test -v، ستُعرض أسماء الاختبارات الفاشلة في المخرجات
يمكنك أيضاً الدمج مع -count=1 لتعطيل التخزين المؤقت وضمان إعادة تشغيل الاختبارات فعلياً:
go test -run TestSomething -count=1 -v
س3: ما الفرق بين t.Fatal و t.Error؟
t.Error()/t.Errorf(): يُبلّغ عن خطأ، يتابع تنفيذ الكود المتبقي في دالة الاختبار الحالية.t.Fatal()/t.Fatalf(): يُبلّغ عن خطأ، ينهي فوراً دالة الاختبار الحالية.
المبدأ العام: إذا كانت التحققات اللاحقة تعتمد على نتائج سابقة (مثل التحقق من err قبل إلغاء تعيينمؤشر)، استخدم Fatal؛ للتحققات المستقلة المتعددة، استخدم Error.
س4: كيف أتخطى الاختبارات التي تستغرق وقتاً طويلاً؟
func TestSlowOperation(t *testing.T) {
if testing.Short() {
t.Skip("تخطي الاختبار الذي يستغرق وقتاً طويلاً (استخدم علامة -short)")
}
// عملية تستغرق وقتاً طويلاً...
}
شغل بعلامة -short للتخطي:
go test -short
📖 ملخص
غطى هذا الدرس المحتوى الأساسي لاختبارات Go:
- دوال الاختبار: تبدأ بـ
Test، المعامل*testing.T، استخدمError/Fatalللإبلاغ عن الفشل - الاختبارات المدفوعة بالجدول: تعريف الحالات بشريحة، حلقة +
t.Runللتشغيل — النمط القياسي لمجتمع Go - الاختبارات الفرعية:
t.Run(name, func)تسمح للحالات بالعمل بشكل مستقل، مما يسهل تحديد المشاكل - اختبارات Benchmark: تبدأ بـ
Benchmark، المعامل*testing.B، حلقةb.Nمرة لقياس الأداء - TestMain: نقطة دخول عامة للإعداد/التنظيف، تستدعي
m.Run()لتنفيذ جميع الاختبارات - httptest:
httptest.NewRequest+httptest.NewRecorderلاختبار معالجات HTTP - التغطية:
go test -coverللعرض،-coverprofileلإنشاء تقارير مفصلة
📝 تمارين
التمرين 1: كتابة اختبارات لدوال معالجة النصوص
اكتب اختبارات للدالة التالية تحتوي على 5 حالات مدفوعة بالجدول على الأقل:
// 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:
// 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)
}
المتطلبات:
- اكتب Benchmarks واحد باستخدام
json.Marshalوآخر باستخدامjson.NewEncoder - استخدم
-benchmemلتخصيصات الذاكرة
التمرين 3: استخدام TestMain لتنفيذ قاعدة بيانات اختبار
صمم حل اختبار باستخدام TestMain:
- إنشاء قاعدة بيانات SQLite مؤقتة في
TestMain - تشغيل الترحيل لإنشاء الجداول
- تنفيذ جميع الاختبارات
- حذف قاعدة البيانات المؤقتة بعد اكتمال الاختبارات
تلميح: استخدم القيمة المرجعة لـ m.Run() كمعامل لـ os.Exit().



