Skip to content

الاختبار

بالنسبة للمطورين، الاختبار الجيد يمكن أن يكتشف الأخطاء في البرنامج مسبقًا، مما يتجنب العبء الذهني الناتج عن عدم الصيانة في الوقت المناسب وتسبب الأخطاء لاحقًا. لذا كتابة اختبارات جيدة أمر ضروري للغاية. توفر Go في جانب الاختبار أداة سطر أوامر مريحة وعملية للغاية go test، ويمكن رؤية الاختبارات في المكتبة القياسية والعديد من الأطر مفتوحة المصدر. هذه الأداة سهلة الاستخدام للغاية، وتدعم حاليًا الأنواع التالية من الاختبارات:

  • اختبار المثال
  • اختبار الوحدة
  • اختبار الأداء (Benchmark)
  • الاختبار الضبابي (Fuzzing)

في Go، معظم واجهات برمجة التطبيقات توفرها المكتبة القياسية testing.

TIP

في سطر الأوامر، نفذ أمر go help testfunc، يمكنك رؤية شرح Go الرسمي للأنواع الأربعة من الاختبارات المذكورة أعلاه.

مواصفات الكتابة

قبل البدء في كتابة الاختبارات، أولاً يجب الانتباه لبعض المواصفات، مما يجعل التعلم اللاحق أكثر راحة.

  • حزمة الاختبار: ملفات الاختبار يفضل وضعها في حزمة منفصلة، وعادة ما تسمى هذه الحزمة test.
  • ملفات الاختبار: ملفات الاختبار عادة تنتهي بـ _test.go، مثلًا لاختبار وظيفة معينة، يتم تسميتها function_test.go، وإذا أردت تقسيمها بشكل أدق حسب نوع الاختبار، يمكن أيضًا استخدام نوع الاختبار كبادئة للملف، مثل benchmark_marshaling_test.go أو example_marshaling_test.go.
  • دوال الاختبار: كل ملف اختبار سيحتوي على عدة دوال اختبار لاختبارات مختلفة. لأنواع الاختبارات المختلفة، تختلف تسمية دوال الاختبار. مثلًا اختبار المثال هو ExampleXXXX، واختبار الوحدة هو TestXXXX، واختبار الأداء هو BenchmarkXXXX، والاختبار الضبابي هو FuzzXXXX، وبذلك حتى بدون تعليقات يمكن معرفة نوع الاختبار.

TIP

عندما يكون اسم الحزمة testdata، هذه الحزمة عادة ما تكون لتخزين البيانات المساعدة للاختبار، وعند تنفيذ الاختبار، سيقوم Go بتجاهل الحزمة المسماة testdata.

اتباع المواصفات المذكورة أعلاه وتطوير أسلوب اختبار جيد يمكن أن يوفر الكثير من المتاعب في الصيانة المستقبلية.

تنفيذ الاختبار

تنفيذ الاختبار يستخدم بشكل أساسي أمر go test، فيما يلي مثال عملي. يوجد الآن ملف للاختبار /say/hello.go بالكود التالي:

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

وملف الاختبار /test/example_test.go بالكود التالي:

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

هناك عدة طرق لتنفيذ هذه الاختبارات، مثلًا إذا أردت تنفيذ جميع حالات الاختبار في حزمة test، يمكن تنفيذ الأمر التالي مباشرة في دليل test:

sh
$ go test ./
PASS
ok      golearn/test    0.422s

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

sh
$ go test ./ ../
ok      golearn/test
?       golearn [no test files]

TIP

عندما يكون هناك عدة حزم في معاملات التنفيذ، لن يقوم Go بإعادة تنفيذ حالات الاختبار التي مرت بنجاح بالفعل، وعند التنفيذ سيتم إضافة (cached) في نهاية السطر للإشارة إلى أن الناتج هو ذاكرة التخزين المؤقت من المرة السابقة. عندما تكون معاملات علامة الاختبار في المجموعة التالية، سيقوم Go بتخزين نتائج الاختبار مؤقتًا، وإلا فلن يفعل.

-benchtime, -cpu,-list, -parallel, -run, -short, -timeout, -failfast, -v

إذا أردت تعطيل التخزين المؤقت، يمكنك إضافة المعامل -count=1.

بالطبع يمكن أيضًا تحديد ملف اختبار معين لتنفيذه:

sh
$ go test example_test.go
ok      command-line-arguments  0.457s

أو يمكن تحديد حالة اختبار معينة في ملف اختبار معين، مثل:

sh
$ go test -run ExampleSay
PASS
ok      golearn/test    0.038s

الحالات الثلاث المذكورة أعلاه رغم أنها أكملت الاختبار، لكن الناتج بسيط جدًا، في هذه الحالة يمكن إضافة المعامل -v لجعل الناتج أكثر تفصيلاً، مثل:

sh
$ go test ./ -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.040s

الآن يمكن رؤية ترتيب تنفيذ كل حالة اختبار بوضوح، والوقت المستغرق، وحالة التنفيذ، والوقت الإجمالي.

TIP

أمر go test افتراضيًا ينفذ جميع اختبارات الوحدة واختبارات المثال والاختبارات الضبابية، وإذا أضيف معامل -bench فسيتم تنفيذ جميع أنواع الاختبارات، مثل الأمر التالي:

sh
$ go test -bench .

لذا نحتاج لاستخدام معامل -run للتحديد، مثل الأمر التالي لتشغيل جميع اختبارات الأداء فقط:

sh
$ go test -bench . -run ^$

المعاملات الشائعة

اختبارات Go لديها العديد من معاملات العلامات، فيما يلي سنقدم المعاملات الشائعة فقط، ولمعرفة المزيد من التفاصيل يُنصح باستخدام أمر go help testflag للبحث.

المعاملالشرح
-o fileتحديد اسم الملف الثنائي المجمّع
-cتجميع ملفات الاختبار فقط، بدون تشغيل
-jsonإخراج سجلات الاختبار بصيغة json
-exec xprogاستخدام xprog لتشغيل الاختبار، مكافئ لـ go run
-bench regexpتحديد اختبارات الأداء المطابقة لـ regexp
-fuzz regexpتحديد الاختبارات الضبابية المطابقة لـ regexp
-fuzztime tوقت الانتهاء التلقائي للاختبار الضبابي، t هو فاصل زمني، عندما تكون الوحدة x، تعني عدد المرات، مثل 200x
-fuzzminimizetime tالحد الأدنى لوقت تشغيل الاختبار الضبابي، نفس القواعد أعلاه
-count nتشغيل الاختبار n مرات، افتراضيًا مرة واحدة
-coverتفعيل تحليل تغطية الاختبار
-covermode set,count,atomicتعيين وضع تحليل التغطية
-cpuتنفيذ GOMAXPROCS للاختبار
-failfastبعد فشل الاختبار الأول، لن تبدأ اختبارات جديدة
-list regexpسرد حالات الاختبار المطابقة لـ regexp
-parallel nالسماح لحالات الاختبار التي تستدعي t.Parallel بالتشغيل بالتوازي، قيمة n هي الحد الأقصى للتوازي
-run regexpتشغيل حالات الاختبار المطابقة لـ regexp فقط
-skip regexpتخطي حالات الاختبار المطابقة لـ regexp
-timeout dإذا تجاوز وقت تنفيذ الاختبار الفردي الفاصل الزمني d، سيحدث panic. d هو فاصل زمني، مثل 1s,1ms,1ns إلخ
-shuffle off,on,Nخلط ترتيب تنفيذ الاختبارات، N هو البذرة العشوائية، البذرة الافتراضية هي وقت النظام
-vإخراج سجلات اختبار أكثر تفصيلاً
-benchmemإحصاء تخصيص الذاكرة في اختبارات الأداء
-blockprofile block.outإحصاء حالة حجب الكوروتينات في الاختبار والكتابة في ملف
-blockprofilerate nالتحكم في تكرار إحصاء حجب الكوروتينات، راجع أمر go doc runtime.SetBlockProfileRate لمزيد من التفاصيل
-coverprofile cover.outإحصاء حالة اختبار التغطية والكتابة في ملف
-cpuprofile cpu.outإحصاء حالة CPU والكتابة في ملف
-memprofile mem.outإحصاء حالة تخصيص الذاكرة والكتابة في ملف
-memprofilerate nالتحكم في تكرار إحصاء تخصيص الذاكرة، راجع أمر go doc runtime.MemProfileRate لمزيد من التفاصيل
-mutexprofile mutex.outإحصاء حالة تنافس الأقفال والكتابة في ملف
-mutexprofilefraction nتعيين إحصاء حالة تنافس n كوروتين على قفل تعاقب واحد
-trace trace.outكتابة حالة تتبع التنفيذ في ملف
-outputdir directoryتحديد دليل الإخراج لملفات الإحصاء المذكورة أعلاه، افتراضيًا دليل تشغيل go test

اختبار المثال

اختبار المثال ليس مثل الأنواع الثلاثة الأخرى المخصصة لإكتشاف مشاكل البرنامج، بل هو أكثر لعرض طريقة استخدام وظيفة معينة، ويلعب دور التوثيق. اختبار المثال ليس مفهومًا معرفًا رسميًا، ولا مواصفة إلزامية، بل هو أشبه باتفاق غير مكتوب في الهندسة، ويعتمد على المطور ما إذا كان سيلتزم به. اختبار المثال يظهر كثيرًا في المكتبة القياسية، وعادة ما هي أمثلة كود للمكتبات القياسية التي كتبها المسؤولون، مثل دالة ExampleWithDeadline في ملف المكتبة القياسية context/example_test.go، هذه الدالة تعرض الاستخدام الأساسي لـ DeadlineContext:

go
// This example passes a context with an arbitrary deadline to tell a blocking
// function that it should abandon its work as soon as it gets to it.
func ExampleWithDeadline() {
   d := time.Now().Add(shortDuration)
   ctx, cancel := context.WithDeadline(context.Background(), d)

   // Even though ctx will be expired, it is good practice to call its
   // cancellation function in any case. Failure to do so may keep the
   // context and its parent alive longer than necessary.
   defer cancel()

   select {
   case <-time.After(1 * time.Second):
      fmt.Println("overslept")
   case <-ctx.Done():
      fmt.Println(ctx.Err())
   }

   // Output:
   // context deadline exceeded
}

من الظاهر، دالة الاختبار هذه هي دالة عادية، لكن اختبار المثال يتجلى بشكل أساسي من خلال تعليق Output. عندما يكون للدالة المراد اختبارها سطر إخراج واحد فقط، استخدم تعليق Output لاختبار الإخراج. أولاً، أنشئ ملفًا باسم hello.go، واكتب الكود التالي:

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

دالة SayHello هي الدالة المراد اختبارها، ثم أنشئ ملف اختبار example_test.go، واكتب الكود التالي:

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

تعليق Output في الدالة يوضح أن اختبار إخراج الدالة هو hello، دعنا نفذ أمر الاختبار لنرى النتيجة.

sh
$ go test -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.448s

من النتيجة يمكن ملاحظة أن جميع الاختبارات قد مرت، بالنسبة لـ Output هناك عدة طرق للكتابة:

الأولى هي سطر إخراج واحد فقط، وتعني اختبار ما إذا كان إخراج الدالة هو hello:

// Output:
// hello

الثانية هي إخراج متعدد الأسطر، أي اختبار تطابق الإخراج بالترتيب:

// Output:
// hello
// bye

الثالثة هي إخراج غير مرتب، أي اختبار تطابق الإخراج متعدد الأسطر بدون ترتيب:

// Unordered output:
// bye
// hello

يجب الانتباه إلى أنه بالنسبة لدوال الاختبار، فقط عندما تكون الأسطر القليلة الأخيرة هي تعليق Output سيتم اعتبارها اختبار مثال، وإلا فهي مجرد دالة عادية، ولن يتم تنفيذها بواسطة Go.

اختبار الوحدة

اختبار الوحدة هو اختبار أصغر وحدة قابلة للاختبار في البرنامج، ويتم تحديد حجم الوحدة من قبل المطور، وقد يكون هيكلاً (struct)، أو حزمة، أو قد يكون دالة، أو نوعًا. فيما يلي سنستمر في العرض من خلال مثال، أولاً أنشئ ملف /tool/math.go، واكتب الكود التالي:

go
package tool

type Number interface {
  ~int8 | ~int16 | ~int32 | ~int64 | ~int
}

func SumInt[T Number](a, b T) T {
  return a + b
}

func Equal[T Number](a, b T) bool {
  return a == b
}

ثم أنشئ ملف الاختبار /tool_test/unit_test.go، بالنسبة لاختبار الوحدة، يمكن تسمية الملف بـ unit_test أو استخدام الحزمة أو الوظيفة المراد اختبارها كبادئة للملف.

go
package test_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

بالنسبة لاختبار الوحدة، أسلوب تسمية كل حالة اختبار هو TestXXXX، ويجب أن يكون معامل الدالة t *testing.T. testing.T هو هيكل توفره حزمة testing لتسهيل الاختبار، ويوفر العديد من الطرق المتاحة. في المثال، t.Errorf مكافئة لـ t.Logf، وتستخدم لإخراج معلومات سجل فشل الاختبار بتنسيق. من الطرق الشائعة الأخرى t.Fail لتمييز حالة الاختبار الحالية كفاشلة، ووظيفة مشابهة هي t.FailNow التي تميز أيضًا كفاشلة، لكن الأولى تستمر في التنفيذ بعد الفشل، بينما الثانية تتوقف فورًا. كما في المثال التالي، يتم تعديل النتيجة المتوقعة إلى نتيجة خاطئة:

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 110

  actual := tool.SumInt(a, b)
  if actual != expected {
        // Errorf تستخدم داخليًا t.Fail()
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
  t.Log("test finished")
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := true

  actual := tool.Equal(a, b)
  if actual != expected {
        // Fatalf تستخدم داخليًا t.FailNow()
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
  t.Log("test finished")
}

تنفيذ الاختبار أعلاه، الإخراج كالتالي:

sh
$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
    tool_test.go:16: test finished
--- FAIL: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.037s

من سجل الاختبار يمكن ملاحظة أن حالة TestSum رغم فشلها إلا أنها أخرجت test finished، بينما TestEqual لم تفعل. وبالمثل، t.SkipNow ستميز حالة الاختبار الحالية كـ SKIP ثم تتوقف عن التنفيذ، وستستمر في التنفيذ في الجولة التالية.

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func TestSum(t *testing.T) {
   a, b := 10, 101
   expected := 110

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }
   t.Log("test finished")
}

func TestEqual(t *testing.T) {
   a, b := 10, 101
   expected := true

   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
   t.Log("test finished")
}

عند تنفيذ الاختبار، يتم تعديل عدد مرات الاختبار إلى 2:

go
$ go test tool_test.go -v -count=2
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
=== RUN   TestSum
    tool_test.go:14:  Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.468s

في الأمثلة أعلاه، تم إخراج test finished في السطر الأخير للإشارة إلى انتهاء الاختبار. في الواقع يمكن استخدام t.Cleanup لتسجيل دالة ختامية للقيام بهذا العمل خصيصًا، وهذه الدالة ستُنفذ عند انتهاء حالة الاختبار، كالتالي:

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func finished(t *testing.T) {
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

بعد تنفيذ الاختبار، الإخراج كالتالي:

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:9: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:9: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.462s

Helper

من خلال t.Helper() يمكن تمييز الدالة الحالية كدالة مساعدة، والدالة المساعدة لن تكون حالة اختبار منفصلة للتنفيذ، وعند تسجيل السجل يكون رقم السطر هو رقم سطر مستدعي الدالة المساعدة، مما يجعل تحليل السجل أكثر دقة، ويتجنب المعلومات الزائدة الأخرى. مثلًا يمكن تعديل مثال t.Cleanup أعلاه إلى دالة مساعدة، كالتالي:

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func CleanupHelper(t *testing.T) {
   t.Helper()
   t.Log("test finished")
}

func TestSum(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := 111

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }

}

func TestEqual(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := false

   t.Helper()
   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
}

بعد تنفيذ الاختبار، معلومات الإخراج كالتالي، الفرق عن السابق أن رقم سطر test finished أصبح رقم سطر المستدعي:

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:15: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:30: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.464s

TIP

جميع العمليات المذكورة أعلاه يمكن إجراؤها فقط في الاختبار الرئيسي، أي حالات الاختبار المنفذة مباشرة، إذا تم استخدامها في الاختبار الفرعي سيحدث panic.

الاختبار الفرعي

في بعض الحالات، نحتاج لاختبار حالة اختبار أخرى داخل حالة اختبار، وهذا النوع من الاختبارات المتداخلة يسمى عمومًا الاختبار الفرعي. من خلال طريقة t.Run()، توقيع هذه الطريقة كالتالي:

go
// طريقة Run ستفتح كوروتين جديد لتشغيل الاختبار الفرعي، وتنتظر حتى تنتهي الدالة f من التنفيذ قبل أن تعود
// القيمة المرجعة هي ما إذا كان الاختبار قد نجح
func (t *T) Run(name string, f func(t *T)) bool

فيما يلي مثال:

go
func TestTool(t *testing.T) {
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
}

النتيجة بعد التنفيذ:

sh
$ go test -run TestTool -v
=== RUN   TestTool
=== RUN   TestTool/tool.Sum(10,101)
    tool_test.go:15: test finished
=== RUN   TestTool/tool.Equal(10,101)
    tool_test.go:30: test finished
--- PASS: TestTool (0.00s)
    --- PASS: TestTool/tool.Sum(10,101) (0.00s)
    --- PASS: TestTool/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.449s

من الإخراج يمكن رؤية بوضوح بنية الأب والابن، في المثال أعلاه، الاختبار الفرعي الأول لن ينتهي من التنفيذ حتى يبدأ الاختبار الفرعي الثاني. يمكن استخدام t.Parallel() لتمييز حالة الاختبار كقابلة للتشغيل بالتوازي، وبذلك سيكون ترتيب الإخراج غير محدد.

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func CleanupHelper(t *testing.T) {
  t.Helper()
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
  t.Log("teardown")
}

الإخراج بعد تنفيذ الاختبار:

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/tool.Sum(10,101)
=== PAUSE TestToolParallel/tool.Sum(10,101)
=== RUN   TestToolParallel/tool.Equal(10,101)
=== PAUSE TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel
    tool_test.go:49: teardown
=== CONT  TestToolParallel/tool.Sum(10,101)
=== CONT  TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/tool.Equal(10,101)
    tool_test.go:32: test finished
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/tool.Sum(10,101) (0.00s)
    --- PASS: TestToolParallel/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.444s

من نتائج الاختبار يمكن ملاحظة بوضوح وجود عملية انتظار حجب، عند تنفيذ حالات الاختبار بالتوازي، المثال أعلاه بالتأكيد لن يعمل بشكل طبيعي، لأن الكود اللاحق لا يضمن التشغيل المتزامن، في هذه الحالة يمكن اختيار تداخل طبقة أخرى من t.Run()، كالتالي:

go
func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("process", func(t *testing.T) {
    t.Run("tool.Sum(10,101)", TestSum)
    t.Run("tool.Equal(10,101)", TestEqual)
  })
  t.Log("teardown")
}

نفذ مرة أخرى، ويمكن رؤية نتيجة التنفيذ الطبيعية:

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/process
=== RUN   TestToolParallel/process/tool.Sum(10,101)
=== PAUSE TestToolParallel/process/tool.Sum(10,101)
=== RUN   TestToolParallel/process/tool.Equal(10,101)
=== PAUSE TestToolParallel/process/tool.Equal(10,101)
=== CONT  TestToolParallel/process/tool.Sum(10,101)
=== CONT  TestToolParallel/process/tool.Equal(10,101)
=== NAME  TestToolParallel/process/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/process/tool.Equal(10,101)
    tool_test.go:32: test finished
=== NAME  TestToolParallel
    tool_test.go:51: teardown
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/process (0.00s)
        --- PASS: TestToolParallel/process/tool.Sum(10,101) (0.00s)
        --- PASS: TestToolParallel/process/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.450s

أسلوب الجدول

في اختبارات الوحدة المذكورة أعلاه، بيانات الإدخال للاختبار كلها متغيرات معرفة يدويًا، عندما تكون كمية البيانات صغيرة فلا مشكلة، لكن إذا أردنا اختبار مجموعات متعددة من البيانات، فلن يكون من العملي الإعلان عن متغيرات لإنشاء بيانات الاختبار. لذا بشكل عام يُفضل استخدام شريحة من الهياكل (struct slice)، والهيكل هو هيكل مجهول مُعلن عنه مؤقتًا، لأن هذا الأسلوب في كتابة الكود يبدو كجدول، لذا يُسمى table-driven. فيما يلي مثال، هذا مثال لإنشاء بيانات اختبار بالإعلان عن متغيرات متعددة يدويًا، إذا كان هناك مجموعات متعددة من البيانات فلن يبدو بديهيًا، لذا نعدّله إلى أسلوب الجدول:

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false
  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

الكود المعدّل كالتالي:

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  // table driven style
  testData := []struct {
    a, b int
    exp  bool
  }{
    {10, 101, false},
    {5, 5, true},
    {30, 32, false},
    {100, 101, false},
    {2, 3, false},
    {4, 4, true},
  }

  for _, data := range testData {
    if actual := tool.Equal(data.a, data.b); actual != data.exp {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", data.a, data.b, data.exp, actual)
    }
  }
}

بيانات الاختبار هذه تبدو أكثر بديهية.

اختبار الأداء

اختبار الأداء يُسمى أيضًا اختبار الأداء القياسي، وعادة ما يُستخدم لاختبار مؤشرات أداء البرنامج مثل استخدام الذاكرة، واستخدام CPU، ووقت التنفيذ وغيرها. بالنسبة لاختبار الأداء، عادة ما ينتهي ملف الاختبار بـ bench_test.go، ويجب أن تكون دالة الاختبار بتنسيق BenchmarkXXXX.

فيما يلي مثال لاختبار الأداء لمقارنة أداء ربط السلاسل النصية. أولاً، أنشئ ملف /tool/strConcat.go، وكما هو معروف، استخدام + مباشرة لربط السلاسل النصية له أداء منخفض جدًا، بينما استخدام strings.Builder أفضل بكثير. أنشئ دالتين في ملف /tool/strings.go لربط السلاسل النصية بطريقتين مختلفتين:

go
package tool

import "strings"


func ConcatStringDirect(longString string) {
   res := ""
   for i := 0; i < 100_000.; i++ {
      res += longString
   }
}

func ConcatStringWithBuilder(longString string) {
   var res strings.Builder
   for i := 0; i < 100_000.; i++ {
      res.WriteString(longString)
   }
}

ثم أنشئ ملف الاختبار /tool_test/bench_tool_test.go، الكود كالتالي:

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

var longString = "longStringlongStringlongStringlongStringlongStringlongStringlongStringlongString"

func BenchmarkConcatDirect(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringDirect(longString)
  }
}

func BenchmarkConcatBuilder(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringWithBuilder(longString)
  }
}

نفذ أمر الاختبار، مع تفعيل السجل المفصل وتحليل الذاكرة، وتحديد قائمة نوى CPU المستخدمة، وتنفيذ كل حالة اختبار مرتين، الإخراج كالتالي:

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=2
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         277771375 ns/op        4040056736 B/op    10000 allocs/op
BenchmarkConcatDirect-2                4         278500125 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                1        1153796000 ns/op        4040068784 B/op    10126 allocs/op
BenchmarkConcatDirect-4                1        1211017600 ns/op        4040073104 B/op    10171 allocs/op
BenchmarkConcatDirect-8                2         665460800 ns/op        4040077760 B/op    10219 allocs/op
BenchmarkConcatDirect-8                2         679774450 ns/op        4040080064 B/op    10243 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            3428            344530 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3579            351858 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            2448            736177 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1688            662993 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1958            550333 ns/op         4128199 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2174            552113 ns/op         4128196 B/op         29 allocs/op
PASS
ok      golearn/tool_test       21.381s

فيما يلي شرح لنتائج اختبار الأداء، goos تمثل نظام التشغيل، goarch تمثل معمارية CPU، pkg هي الحزمة التي يوجد بها الاختبار، cpu هي بعض المعلومات عن CPU. نتائج كل حالة اختبار مفصولة باسم كل اختبار أداء، العمود الأول BenchmarkConcatDirect-2 الرقم 2 يمثل عدد نوى CPU المستخدمة، العمود الثاني الرقم 4 يمثل حجم b.N في الكود، أي عدد دورات اختبار الأداء، العمود الثالث 277771375 ns/op يمثل الوقت المستغرق في كل دورة، ns هي نانوثانية، العمود الرابع 4040056736 B/op يمثل حجم الذاكرة المخصصة بالبايت في كل دورة، العمود الخامس 10000 allocs/op يمثل عدد مرات تخصيص الذاكرة في كل دورة.

من الواضح، وفقًا لنتائج الاختبار، أن استخدام strings.Builder أفضل بكثير من استخدام + لربط السلاسل النصية، ومقارنة الأداء من خلال بيانات بصرية هو هدف اختبار الأداء.

benchstat

benchstat هي أداة مفتوحة المصدر لتحليل اختبار الأداء، عينات اختبار الأداء أعلاه مجموعتان فقط، وبمجرد زيادة العينات، التحليل اليدوي سيستغرق وقتًا وجهدًا كبيرين. هذه الأداة وُلدت لحل مشاكل تحليل الأداء.

أولاً، تحتاج لتنزيل هذه الأداة:

sh
$ go install golang.org/x/perf/benchstat

نفذ اختبار الأداء مرتين، هذه المرة عدّل عدد العينات إلى 5، وأخرج النتائج إلى ملفي old.txt و new.txt للمقارنة. نتيجة التنفيذ الأولى:

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a old.txt

نتيجة التنفيذ الثانية:

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a new.txt

ثم استخدم benchstat للمقارنة:

sh
$ benchstat old.txt new.txt

من النتائج يمكن ملاحظة أن benchstat قسمتها إلى ثلاث مجموعات: الوقت المستغرق، استخدام الذاكرة، وعدد مرات تخصيص الذاكرة. منها geomean هو المتوسط، p هو مستوى المعنوية للعينة، والفاصل الحرج عادة هو 0.05، أعلى من 0.05 لا يكون موثوقًا.

الاختبار الضبابي

الاختبار الضبابي هو ميزة جديدة أطلقتها Go 1.18، وهو نوع من التعزيز لاختبار الوحدة واختبار الأداء، والفرق أن البيانات للاختبارين السابقين تحتاج من المطور كتابتها يدويًا، بينما الاختبار الضبابي يمكنه توليد بيانات اختبار عشوائية من خلال مكتبة العينات. لمعرفة المزيد عن الاختبار الضبابي في Go، انتقل إلى Go Fuzzing. ميزة الاختبار الضبابي هي أنه مقارنة بالبيانات الثابتة، البيانات العشوائية يمكنها اختبار شروط الحدود للبرنامج بشكل أفضل.

فيما يلي مثال من الدرس الرسمي. هذه المرة سنختبر دالة لعكس السلسلة النصية. أولاً، أنشئ ملف /tool/strings.go، واكتب الكود التالي:

go
package tool

func Reverse(s string) string {
  b := []byte(s)
  for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
    b[i], b[j] = b[j], b[i]
  }
  return string(b)
}

أنشئ ملف الاختبار الضبابي /tool_test/fuzz_tool_test.go، واكتب الكود التالي:

go
package tool

import (
  "golearn/tool"
  "testing"
  "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
  testdata := []string{"hello world!", "nice to meet you", "good bye!"}
  for _, data := range testdata {
    f.Add(data)
  }

  f.Fuzz(func(t *testing.T, str string) {
    first := tool.Reverse(str)
    second := tool.Reverse(first)
    t.Logf("str:%q,first:%q,second:%q", str, first, second)
    if str != second {
      t.Errorf("before: %q, after: %q", str, second)
    }
    if utf8.ValidString(str) && !utf8.ValidString(first) {
      t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
    }
  })
}

في الاختبار الضبابي، أولاً نحتاج لإضافة بيانات إلى مكتبة العينات الأولية، في المثال استخدمنا f.Add() للإضافة، مما يساعد في توليد بيانات اختبار عشوائية لاحقًا. ثم نستخدم f.Fuzz(fn) لإجراء الاختبار، توقيع الدالة كالتالي:

go
func (f *F) Fuzz(ff any)

func (f *F) Add(args ...any)

fn تشبه منطق دالة اختبار الوحدة، المعامل الأول للدالة يجب أن يكون t *testing.T، ثم تليها المعاملات المراد توليدها. بما أن السلسلة النصية المُمررة غير متوقعة، هنا نستخدم طريقة العكس مرتين للتحقق. نفذ الأمر التالي:

sh
$ go test -run Fuzz -v

عندما لا يحتوي المعامل على -fuzz، لن يتم توليد بيانات اختبار عشوائية، وسيتم فقط تمرير البيانات من مكتبة العينات لدالة الاختبار.

أضف معامل -fuzz ونفذ مرة أخرى:

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v

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

عدّل الكود المصدري المراد اختباره إلى ما يلي، حوّل السلسلة النصية إلى []rune، وبذلك يمكن تجنب المشكلة المذكورة أعلاه:

go
func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

الأنواع المدعومة

الأنواع المدعومة في Go Fuzz كالتالي:

  • string, []byte
  • int, int8, int16, int32/rune, int64
  • uint, uint8/byte, uint16, uint32, uint64
  • float32, float64
  • bool

Golang تم تحريره بواسطة www.golangdev.cn