اختبار تطبيق Go: ابدأ بالطريقة الصحيحة

نشرت: 2022-03-11

عند تعلم أي شيء جديد ، من المهم أن تكون لديك حالة ذهنية جديدة.

إذا كنت جديدًا إلى حد ما على Go وكنت قادمًا من لغات مثل JavaScript أو Ruby ، ​​فمن المحتمل أنك معتاد على استخدام أطر العمل الحالية التي تساعدك على السخرية والتأكيد والقيام بمعالجات اختبار أخرى.

الآن تخلص من فكرة الاعتماد على التبعيات أو الأطر الخارجية! كان الاختبار هو العائق الأول الذي صادفته عندما تعلمت لغة البرمجة الرائعة هذه قبل عامين ، في وقت كانت فيه الموارد المتاحة أقل بكثير.

أعلم الآن أن اختبار النجاح في Go يعني السفر الخفيف على التبعيات (كما هو الحال مع كل الأشياء Go) ، والاعتماد بشكل ضئيل على المكتبات الخارجية ، وكتابة تعليمات برمجية جيدة قابلة لإعادة الاستخدام. يعد هذا العرض التقديمي لتجارب Blake Mizerany في المغامرة مع مكتبات اختبار الطرف الثالث بداية رائعة لتعديل طريقة تفكيرك. سترى بعض الحجج الجيدة حول استخدام المكتبات الخارجية والأطر مقابل القيام بذلك "طريقة الذهاب".

تريد أن تتعلم Go؟ تحقق من دروسنا التمهيدية حول Golang.

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

افعل الأشياء بطريقة "Go Way" ، اقضِ على التبعيات على الأطر الخارجية.
سقسقة

اختبار الجدول في Go

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

avg.go

 func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }

الدالة أعلاه ، func Avg(nos ...int) ، ترجع إما صفرًا أو متوسط ​​عدد صحيح لسلسلة من الأرقام المعطاة لها. الآن دعنا نكتب اختبارًا لها.

في Go ، يُعتبر من أفضل الممارسات تسمية ملف اختبار يحمل نفس اسم الملف الذي يحتوي على الكود الذي يتم اختباره ، مع اللاحقة _test المضافة. على سبيل المثال ، الكود أعلاه موجود في ملف يسمى avg.go ، لذلك سيتم تسمية ملف الاختبار الخاص بنا avg_test.go .

لاحظ أن هذه الأمثلة ليست سوى مقتطفات من الملفات الفعلية ، حيث تم حذف تعريف الحزمة والواردات من أجل التبسيط.

فيما يلي اختبار لوظيفة Avg :

avg__test.go

 func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }

هناك العديد من الأشياء التي يجب ملاحظتها حول تعريف الوظيفة:

  • أولاً ، بادئة "اختبار" على اسم وظيفة الاختبار. يعد هذا ضروريًا حتى تلتقطه الأداة كاختبار صالح.
  • الجزء الأخير من اسم الوظيفة هو بشكل عام اسم الوظيفة أو الطريقة التي يتم اختبارها ، وفي هذه الحالة يكون Avg .
  • نحتاج أيضًا إلى اجتياز هيكل الاختبار المسمى اختبار testing.T ، والذي يسمح بالتحكم في تدفق الاختبار. لمزيد من التفاصيل حول واجهة برمجة التطبيقات هذه ، يرجى زيارة صفحة التوثيق.

الآن دعنا نتحدث عن الشكل الذي كتب به المثال. يتم تشغيل مجموعة اختبار (سلسلة من الاختبارات) من خلال الوظيفة Avg() ، ويحتوي كل اختبار على مدخلات محددة والمخرجات المتوقعة. في حالتنا ، يرسل كل اختبار شريحة من الأعداد الصحيحة ( Nos ) ويتوقع قيمة إرجاع محددة ( Result ).

يحصل اختبار الجدول على اسمه من هيكله ، ويمكن تمثيله بسهولة بواسطة جدول به عمودين: متغير الإدخال ومتغير الإخراج المتوقع.

واجهة جولانج الساخرة

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

الواجهة عبارة عن مجموعة مسماة من الأساليب ، ولكنها أيضًا نوع متغير.

لنأخذ سيناريو وهميًا حيث نحتاج إلى قراءة أول N بايت من io.Reader وإعادتها كسلسلة. سيبدو شيء هكذا:

readn.go

 // readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }

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

  • r.Read يسمى القراءة بمخزن مؤقت بحجم n.
  • r.Read يعيد خطأ إذا تم إلقاء أحد.

من أجل معرفة حجم المخزن المؤقت الذي تم تمريره إلى r.Read ، اقرأ ، بالإضافة إلى التحكم في الخطأ الذي يرجع إليه ، نحتاج إلى محاكاة r لتمريره إلى readN . إذا نظرنا إلى وثائق Go على النوع Reader ، فسنرى كيف يبدو io.Reader :

 type Reader interface { Read(p []byte) (n int, err error) }

هذا يبدو سهلا نوعا ما. كل ما يتعين علينا القيام به من أجل إرضاء io.Reader هو أن يكون لدينا طريقة Read وهمية. لذلك يمكن أن يكون ReaderMock بنا على النحو التالي:

 type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }

دعنا نحلل الكود أعلاه قليلاً. من الواضح أن أي مثيل لـ ReaderMock يلبي واجهة io.Reader لأنه يطبق طريقة Read الضرورية. يحتوي نموذجنا أيضًا على الحقل ReadMock ، مما يسمح لنا بتعيين السلوك الدقيق للطريقة التي تم الاستهزاء بها ، مما يجعل من السهل جدًا علينا إنشاء مثيل ديناميكي لكل ما نحتاجه.

تتمثل إحدى الحيل الرائعة الخالية من الذاكرة لضمان رضا الواجهة في وقت التشغيل في إدراج ما يلي في التعليمات البرمجية الخاصة بنا:

 var _ io.Reader = (*MockReader)(nil)

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

للمضي قدمًا ، دعنا نكتب r.Read الأول ، حيث يتم استدعاء r مع مخزن مؤقت بحجم n . للقيام بذلك ، نستخدم ReaderMock بنا على النحو التالي:

 func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }

كما ترى أعلاه ، قمنا بتعريف سلوك وظيفة Read io.Reader "المزيف" مع متغير نطاق ، والذي يمكن استخدامه لاحقًا لتأكيد صحة اختبارنا. سهل بما فيه الكفاية.

لنلقِ نظرة على السيناريو الثاني الذي نحتاج إلى اختباره ، والذي يتطلب منا أن نسخر من Read لإرجاع خطأ:

 func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }

في الاختبار أعلاه ، ستؤدي أي مكالمة إلى mr.Read (القارئ الذي تم الاستهزاء به) إلى إرجاع الخطأ المحدد ، وبالتالي من الآمن افتراض أن الأداء الصحيح لـ readN سيفعل الشيء نفسه.

وظيفة السخرية مع Go

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

ضع في اعتبارك عبارة if البسيطة أدناه والتي تسجل المخرجات اعتمادًا على قيمة n :

 func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }

في المثال أعلاه ، نفترض السيناريو المضحك حيث نختبر هذا السجل على وجه التحديد. يتم استدعاء log.Println بالقيم الصحيحة. لكي نسخر من هذه الوظيفة ، علينا أن نلفها داخل وظيفتنا أولاً:

 var show = func(v ...interface{}) { log.Println(v...) }

يتيح لنا إعلان الوظيفة بهذه الطريقة - كمتغير - الكتابة فوقها في اختباراتنا وتعيين أي سلوك نريدها. ضمنيًا ، يتم استبدال الأسطر التي تشير إلى log.Println بـ show ، لذلك يصبح برنامجنا:

 func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }

الآن يمكننا اختبار:

 func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }

لا ينبغي أن تكون الوجبات الجاهزة لدينا عبارة عن "سجل وهمي. log.Println " ، ولكن في تلك السيناريوهات العرضية جدًا عندما نحتاج إلى الاستهزاء بوظيفة على مستوى الحزمة لأسباب مشروعة ، فإن الطريقة الوحيدة للقيام بذلك (على حد علمي) هي من خلال إعلانها كمتغير على مستوى الحزمة حتى نتمكن من التحكم في قيمتها.

ومع ذلك ، إذا احتجنا في أي وقت إلى الاستهزاء بأشياء مثل log.Println ، فيمكن كتابة حل أكثر أناقة إذا أردنا استخدام مسجل مخصص.

اذهب إلى اختبارات عرض النموذج

سيناريو آخر شائع إلى حد ما هو اختبار ما إذا كان إخراج النموذج المقدم وفقًا للتوقعات. دعنا نفكر في طلب GET إلى http://localhost:3999/welcome?name=Frank ، والذي يقوم بإرجاع النص التالي:

 <html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>

في حال لم يكن الأمر واضحًا بدرجة كافية حتى الآن ، فليس من قبيل المصادفة أن يتطابق name معامل الاستعلام مع محتوى span المصنف على أنه "اسم". في هذه الحالة ، سيكون الاختبار الواضح هو التحقق من أن هذا يحدث بشكل صحيح في كل مرة عبر مخرجات متعددة. لقد وجدت مكتبة GoQuery مفيدة للغاية هنا.

يستخدم GoQuery واجهة برمجة تطبيقات تشبه jQuery للاستعلام عن بنية HTML ، وهو أمر لا غنى عنه لاختبار صلاحية إخراج الترميز لبرامجك.

الآن يمكننا كتابة اختبارنا بهذه الطريقة:

welcome__test.go

 func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }

أولاً ، نتحقق من أن رمز الاستجابة كان 200 / موافق قبل المتابعة.

أعتقد أنه ليس بعيد المنال أن نفترض أن باقي مقتطف الشفرة أعلاه واضح بذاته: نسترجع عنوان URL باستخدام حزمة http وننشئ مستندًا جديدًا متوافقًا مع goquery من الاستجابة ، والذي نستخدمه بعد ذلك للاستعلام DOM الذي تم إرجاعه. نتحقق من أن span.name داخل h1.header-name يغلف النص "فرانك".

اختبار JSON APIs

يتم استخدام Go بشكل متكرر لكتابة واجهات برمجة التطبيقات من نوع ما ، لذلك أخيرًا وليس آخرًا ، دعنا ننظر في بعض الطرق عالية المستوى لاختبار واجهات برمجة تطبيقات JSON.

ضع في اعتبارك ما إذا كانت نقطة النهاية قد عادت سابقًا إلى JSON بدلاً من HTML ، لذلك من http://localhost:3999/welcome.json?name=Frank ، نتوقع أن يبدو جسم الاستجابة كما يلي:

 {"Salutation": "Hello Frank!"}

تأكيد استجابات JSON ، كما قد يكون المرء قد خمّن بالفعل ، لا يختلف كثيرًا عن تأكيد استجابات النموذج ، باستثناء أننا لا نحتاج إلى أي مكتبات أو تبعيات خارجية. مكتبات Go القياسية كافية. إليك اختبارنا الذي يؤكد إرجاع JSON الصحيح للمعلمات المحددة:

welcome__test.go

 func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }

إذا تم إرجاع أي شيء بخلاف البنية التي نفك تشفيرها ، json.NewDecoder بدلاً من ذلك خطأ وسيفشل الاختبار. بالنظر إلى أن الاستجابة تتفكك ضد البنية بنجاح ، نتحقق من أن محتويات الحقل كما هو متوقع - في حالتنا "Hello Frank!".

الإعداد والتفكيك

يعد الاختبار باستخدام Go أمرًا سهلاً ، ولكن هناك مشكلة واحدة في كل من اختبار JSON أعلاه واختبار عرض القالب قبل ذلك. كلاهما يفترض أن الخادم يعمل ، وهذا يخلق تبعية غير موثوقة. أيضًا ، ليس من الجيد أن تتعارض مع خادم "مباشر".

ليس من الجيد أبدًا إجراء اختبار مقابل البيانات "الحية" على خادم إنتاج "مباشر" ؛ قم بتدوير النسخ المحلية أو التطويرية حتى لا يحدث أي ضرر للأشياء التي تسوء بشكل فظيع.

لحسن الحظ ، تقدم Go حزمة HTptest لإنشاء خوادم اختبار. تعمل الاختبارات على تشغيل خادم منفصل خاص بها ، بشكل مستقل عن خادمنا الرئيسي ، وبالتالي لن يتداخل الاختبار مع الإنتاج.

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

 func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }

لاحظ مرجع app.Handler() . هذه وظيفة من أفضل الممارسات التي تعرض http.Handler الخاص بالتطبيق ، والذي يمكنه إنشاء مثيل لخادم الإنتاج أو خادم الاختبار.

خاتمة

تُعد "Testing in Go" فرصة رائعة لاكتساب المنظور الخارجي لبرنامجك والاستفادة من حذاء زوارك ، أو في معظم الحالات ، مستخدمي واجهة برمجة التطبيقات الخاصة بك. إنه يوفر فرصة رائعة للتأكد من أنك تقدم كودًا جيدًا وتجربة عالية الجودة.

عندما لا تكون متأكدًا من الوظائف الأكثر تعقيدًا في التعليمات البرمجية الخاصة بك ، يكون الاختبار مفيدًا كطمأنة ، ويضمن أيضًا أن الأجزاء ستستمر في العمل معًا بشكل جيد عند تعديل أجزاء من الأنظمة الأكبر.

آمل أن يكون هذا المقال مفيدًا لك ، ونرحب بك للتعليق إذا كنت تعرف أي حيل اختبار أخرى.