الاختبار
بالنسبة للمطورين، الاختبار الجيد يمكن أن يكتشف الأخطاء في البرنامج مسبقًا، مما يتجنب العبء الذهني الناتج عن عدم الصيانة في الوقت المناسب وتسبب الأخطاء لاحقًا. لذا كتابة اختبارات جيدة أمر ضروري للغاية. توفر 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 بالكود التالي:
package say
import "fmt"
func Hello() {
fmt.Println("hello")
}
func GoodBye() {
fmt.Println("bye")
}وملف الاختبار /test/example_test.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:
$ go test ./
PASS
ok golearn/test 0.422s./ تعني الدليل الحالي، سيقوم Go بإعادة تجميع جميع ملفات الاختبار في دليل test، ثم تنفيذ جميع حالات الاختبار. من النتيجة يمكن ملاحظة أن جميع حالات الاختبار قد مرت. يمكن أن يتبع المعامل أدلة متعددة، مثل الأمر التالي، من الواضح أن الدليل الرئيسي للمشروع لا يحتوي على ملفات اختبار لتنفيذها.
$ go test ./ ../
ok golearn/test
? golearn [no test files]TIP
عندما يكون هناك عدة حزم في معاملات التنفيذ، لن يقوم Go بإعادة تنفيذ حالات الاختبار التي مرت بنجاح بالفعل، وعند التنفيذ سيتم إضافة (cached) في نهاية السطر للإشارة إلى أن الناتج هو ذاكرة التخزين المؤقت من المرة السابقة. عندما تكون معاملات علامة الاختبار في المجموعة التالية، سيقوم Go بتخزين نتائج الاختبار مؤقتًا، وإلا فلن يفعل.
-benchtime, -cpu,-list, -parallel, -run, -short, -timeout, -failfast, -vإذا أردت تعطيل التخزين المؤقت، يمكنك إضافة المعامل -count=1.
بالطبع يمكن أيضًا تحديد ملف اختبار معين لتنفيذه:
$ go test example_test.go
ok command-line-arguments 0.457sأو يمكن تحديد حالة اختبار معينة في ملف اختبار معين، مثل:
$ go test -run ExampleSay
PASS
ok golearn/test 0.038sالحالات الثلاث المذكورة أعلاه رغم أنها أكملت الاختبار، لكن الناتج بسيط جدًا، في هذه الحالة يمكن إضافة المعامل -v لجعل الناتج أكثر تفصيلاً، مثل:
$ 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 فسيتم تنفيذ جميع أنواع الاختبارات، مثل الأمر التالي:
$ go test -bench .لذا نحتاج لاستخدام معامل -run للتحديد، مثل الأمر التالي لتشغيل جميع اختبارات الأداء فقط:
$ 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:
// 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، واكتب الكود التالي:
package say
import "fmt"
func Hello() {
fmt.Println("hello")
}
func GoodBye() {
fmt.Println("bye")
}دالة SayHello هي الدالة المراد اختبارها، ثم أنشئ ملف اختبار example_test.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، دعنا نفذ أمر الاختبار لنرى النتيجة.
$ 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، واكتب الكود التالي:
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 أو استخدام الحزمة أو الوظيفة المراد اختبارها كبادئة للملف.
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 التي تميز أيضًا كفاشلة، لكن الأولى تستمر في التنفيذ بعد الفشل، بينما الثانية تتوقف فورًا. كما في المثال التالي، يتم تعديل النتيجة المتوقعة إلى نتيجة خاطئة:
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")
}تنفيذ الاختبار أعلاه، الإخراج كالتالي:
$ 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 ثم تتوقف عن التنفيذ، وستستمر في التنفيذ في الجولة التالية.
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 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 لتسجيل دالة ختامية للقيام بهذا العمل خصيصًا، وهذه الدالة ستُنفذ عند انتهاء حالة الاختبار، كالتالي:
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.462sHelper
من خلال t.Helper() يمكن تمييز الدالة الحالية كدالة مساعدة، والدالة المساعدة لن تكون حالة اختبار منفصلة للتنفيذ، وعند تسجيل السجل يكون رقم السطر هو رقم سطر مستدعي الدالة المساعدة، مما يجعل تحليل السجل أكثر دقة، ويتجنب المعلومات الزائدة الأخرى. مثلًا يمكن تعديل مثال t.Cleanup أعلاه إلى دالة مساعدة، كالتالي:
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.464sTIP
جميع العمليات المذكورة أعلاه يمكن إجراؤها فقط في الاختبار الرئيسي، أي حالات الاختبار المنفذة مباشرة، إذا تم استخدامها في الاختبار الفرعي سيحدث panic.
الاختبار الفرعي
في بعض الحالات، نحتاج لاختبار حالة اختبار أخرى داخل حالة اختبار، وهذا النوع من الاختبارات المتداخلة يسمى عمومًا الاختبار الفرعي. من خلال طريقة t.Run()، توقيع هذه الطريقة كالتالي:
// طريقة Run ستفتح كوروتين جديد لتشغيل الاختبار الفرعي، وتنتظر حتى تنتهي الدالة f من التنفيذ قبل أن تعود
// القيمة المرجعة هي ما إذا كان الاختبار قد نجح
func (t *T) Run(name string, f func(t *T)) boolفيما يلي مثال:
func TestTool(t *testing.T) {
t.Run("tool.Sum(10,101)", TestSum)
t.Run("tool.Equal(10,101)", TestEqual)
}النتيجة بعد التنفيذ:
$ 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() لتمييز حالة الاختبار كقابلة للتشغيل بالتوازي، وبذلك سيكون ترتيب الإخراج غير محدد.
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()، كالتالي:
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. فيما يلي مثال، هذا مثال لإنشاء بيانات اختبار بالإعلان عن متغيرات متعددة يدويًا، إذا كان هناك مجموعات متعددة من البيانات فلن يبدو بديهيًا، لذا نعدّله إلى أسلوب الجدول:
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)
}
}الكود المعدّل كالتالي:
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 لربط السلاسل النصية بطريقتين مختلفتين:
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، الكود كالتالي:
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 المستخدمة، وتنفيذ كل حالة اختبار مرتين، الإخراج كالتالي:
$ 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 هي أداة مفتوحة المصدر لتحليل اختبار الأداء، عينات اختبار الأداء أعلاه مجموعتان فقط، وبمجرد زيادة العينات، التحليل اليدوي سيستغرق وقتًا وجهدًا كبيرين. هذه الأداة وُلدت لحل مشاكل تحليل الأداء.
أولاً، تحتاج لتنزيل هذه الأداة:
$ go install golang.org/x/perf/benchstatنفذ اختبار الأداء مرتين، هذه المرة عدّل عدد العينات إلى 5، وأخرج النتائج إلى ملفي old.txt و new.txt للمقارنة. نتيجة التنفيذ الأولى:
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a old.txtنتيجة التنفيذ الثانية:
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a new.txtثم استخدم benchstat للمقارنة:
$ benchstat old.txt new.txtمن النتائج يمكن ملاحظة أن benchstat قسمتها إلى ثلاث مجموعات: الوقت المستغرق، استخدام الذاكرة، وعدد مرات تخصيص الذاكرة. منها geomean هو المتوسط، p هو مستوى المعنوية للعينة، والفاصل الحرج عادة هو 0.05، أعلى من 0.05 لا يكون موثوقًا.
الاختبار الضبابي
الاختبار الضبابي هو ميزة جديدة أطلقتها Go 1.18، وهو نوع من التعزيز لاختبار الوحدة واختبار الأداء، والفرق أن البيانات للاختبارين السابقين تحتاج من المطور كتابتها يدويًا، بينما الاختبار الضبابي يمكنه توليد بيانات اختبار عشوائية من خلال مكتبة العينات. لمعرفة المزيد عن الاختبار الضبابي في Go، انتقل إلى Go Fuzzing. ميزة الاختبار الضبابي هي أنه مقارنة بالبيانات الثابتة، البيانات العشوائية يمكنها اختبار شروط الحدود للبرنامج بشكل أفضل.
فيما يلي مثال من الدرس الرسمي. هذه المرة سنختبر دالة لعكس السلسلة النصية. أولاً، أنشئ ملف /tool/strings.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، واكتب الكود التالي:
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) لإجراء الاختبار، توقيع الدالة كالتالي:
func (f *F) Fuzz(ff any)
func (f *F) Add(args ...any)fn تشبه منطق دالة اختبار الوحدة، المعامل الأول للدالة يجب أن يكون t *testing.T، ثم تليها المعاملات المراد توليدها. بما أن السلسلة النصية المُمررة غير متوقعة، هنا نستخدم طريقة العكس مرتين للتحقق. نفذ الأمر التالي:
$ go test -run Fuzz -vعندما لا يحتوي المعامل على -fuzz، لن يتم توليد بيانات اختبار عشوائية، وسيتم فقط تمرير البيانات من مكتبة العينات لدالة الاختبار.
أضف معامل -fuzz ونفذ مرة أخرى:
$ go test -fuzz . -fuzztime 30s -run Fuzz -vيمكن ملاحظة أن الاختبار لم يمر هذه المرة، والسبب هو أن السلسلة النصية بعد العكس أصبحت بصيغة غير utf8، لذا من خلال الاختبار الضبابي تم اكتشاف هذه المشكلة. بما أن بعض الأحرف تشغل أكثر من بايت واحد، إذا تم عكسها بوحدة البايت فستصبح بالتأكيد مشوهة.
عدّل الكود المصدري المراد اختباره إلى ما يلي، حوّل السلسلة النصية إلى []rune، وبذلك يمكن تجنب المشكلة المذكورة أعلاه:
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,[]byteint,int8,int16,int32/rune,int64uint,uint8/byte,uint16,uint32,uint64float32,float64bool
