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

نشرت: 2022-02-15

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

في حين أن الأمثلة الواردة في هذا المنشور مكتوبة باستخدام JUnit 5 و AssertJ ، فإن الدروس قابلة للتطبيق على أي إطار اختبار وحدة آخر.

JUnit هو إطار اختبار Java الأكثر شيوعًا. AssertJ هي مكتبة Java تساعد المطورين على كتابة المزيد من الاختبارات التعبيرية.

هيكل الاختبار الأساسي

المثال الأول للاختبار الذي سننظر إليه هو آلة حاسبة بسيطة لإضافة رقمين.

 آلة حاسبة للفصل الدراسي

     @ الاختبار // 1
     مجموع باطل () {
         آلة حاسبة حاسبة = آلة حاسبة جديدة () ؛ // 2
         نتيجة int = calculator.sum (1، 2) ؛ // 3
         تأكيد ذلك (النتيجة) .isEqualTo (3) ؛ // 4
     }
}

أنا أفضل استخدام اصطلاح التسمية ClassShould should كتابة الاختبارات لتجنب التكرار أو test في كل اسم طريقة. يمكنك قراءة المزيد عنه هنا.

ماذا يفعل الاختبار أعلاه؟

دعنا نكسر سطر الاختبار بسطر:

  1. يتيح التعليق التوضيحي @Test لإطار JUnit معرفة الطرق التي من المفترض تشغيلها كاختبارات. من الطبيعي تمامًا وجود طرق private في فئة الاختبار ليست اختبارات.
  2. هذه هي مرحلة الترتيب للاختبار ، حيث نقوم بإعداد بيئة الاختبار. كل ما نحتاجه لهذا الاختبار هو الحصول على نسخة من Calculator .
  3. هذه هي مرحلة الفعل حيث نقوم بتشغيل السلوك الذي نريد اختباره.
  4. هذه هي مرحلة التأكيد التي نفحص فيها ما حدث وإذا تم حل كل شيء كما هو متوقع. assertThat(result) هي جزء من مكتبة AssertJ ولها العديد من الأحمال الزائدة.

يقوم كل حمل زائد بإرجاع كائن Assert متخصص. يحتوي الكائن المرتجع على عمليات منطقية للكائن الذي مررناه إلى التابع assertThat . في حالتنا ، هذا الكائن هو AbstractIntegerAssert مع طرق لاختبار الأعداد الصحيحة. isEqualTo(3) مما إذا كانت result == 3 . إذا كان الأمر كذلك ، فسوف ينجح الاختبار ويفشل بخلاف ذلك.

لن نركز على أي تطبيقات في منشور المدونة هذا.

طريقة أخرى للتفكير في الترتيب ، الفعل ، التأكيد تُعطى ، متى ، ثم .

بعد أن نكتب طريقة تنفيذ sum ، يمكننا أن نسأل أنفسنا بعض الأسئلة:

  • كيف يمكنني تحسين هذا الاختبار؟
  • هل هناك المزيد من حالات الاختبار التي ينبغي عليّ تغطيتها؟
  • ماذا يحدث إذا أضفت رقمًا موجبًا ورقمًا سالبًا؟ رقمين سالبين؟ واحد إيجابي والآخر سلبي؟
  • ماذا لو تجاوزت قيمة العدد الصحيح؟

دعنا نضيف هذه الحالات ونحسن اسم الاختبار الحالي قليلاً.

لن نسمح بحدوث تجاوزات في تنفيذنا. إذا تجاوز sum ، ArithmeticException بدلاً من ذلك.

 آلة حاسبة للفصل الدراسي

     الآلة الحاسبة الخاصة = الحاسبة الجديدة ()؛

     @اختبار
     مجموع باطلالأرقام الإيجابية () {
         int sum = calculator.sum (1، 2) ؛
         تأكيد ذلك (المجموع) .isEqualTo (3) ؛
     }

     @اختبار
     مجموع باطلةالأرقام السالبة () {
         مجموع int = calculator.sum (-1 ، -1) ؛
         تأكيد ذلك (المجموع) .isEqualTo (-2) ؛
     }

     @اختبار
     sumPositiveAndNegativeNumbers () {باطل
         int sum = calculator.sum (1، -2) ؛
         تأكيد ذلك (المجموع) .isEqualTo (-1) ؛
     }

     @اختبار
     فشل باطلWithArithmeticExceptionWhenOverflown () {
         assertThatThrownBy (() -> calculator.sum (عدد صحيح MAX_VALUE، 1))
             .isInstanceOf (ArithmeticException.class) ؛
     } 

}

ستقوم JUnit بإنشاء مثيل جديد من CalculatorShould قبل تشغيل كل طريقة @Test . هذا يعني أن كل CalculatorShould يجب أن يكون لها calculator مختلفة لذلك لا يتعين علينا نسخها في كل اختبار.

shouldFailWithArithmeticExceptionWhenOverflown يستخدم الاختبار نوعًا مختلفًا من assert . يتحقق من فشل جزء من التعليمات البرمجية. ستعمل طريقة assertThatThrownBy على تشغيل lambda التي قدمناها والتأكد من فشلها. كما نعلم بالفعل ، تُعيد جميع عمليات Assert assertThat متخصصًا يسمح لنا بالتحقق من نوع الاستثناء الذي حدث.

هذا مثال على كيفية اختبار فشل كودنا عندما نتوقعه. إذا قمنا في أي وقت بإعادة تشكيل الآلة Calculator ولم ترمي ArithmeticException إلى تجاوز السعة ، فسيفشل اختبارنا.

ObjectMother نمط التصميم

المثال التالي هو فئة المدقق للتأكد من أن نسخة الشخص صالحة.

 فئة PersonValidatorShould {

    مدقق PersonValidator الخاص = new PersonValidator () ؛

    @اختبار
    فشل باطل عندماNameIsNull () {
        شخص شخص = شخص جديد (لاغ ، 20 ، عنوان جديد (...) ، ...) ؛

        assertThatThrownBy (() -> validator.validate (شخص))
            .isInstanceOf (InvalidPersonException.class) ،
    }

    @اختبار
    فشل باطلWhenAgeIsNegative () {
        شخص شخص = شخص جديد ("جون" ، -5 ، عنوان جديد (...) ، ...) ؛

        assertThatThrownBy (() -> validator.validate (شخص))
            .isInstanceOf (InvalidPersonException.class) ،

    }
}

غالبًا ما يتم استخدام نمط تصميم ObjectMother في الاختبارات التي تنشئ كائنات معقدة لإخفاء تفاصيل إنشاء مثيل من الاختبار. قد تؤدي الاختبارات المتعددة إلى إنشاء نفس الكائن ولكن تختبر أشياء مختلفة عليه.

الاختبار رقم 1 مشابه جدًا للاختبار رقم 2. يمكننا إعادة PersonValidatorShould عن طريق استخراج التحقق من الصحة كطريقة خاصة ثم تمرير مثيلات Person غير القانونية إليها مع توقع فشلها جميعًا بنفس الطريقة.

 فئة PersonValidatorShould {

     مدقق PersonValidator الخاص = new PersonValidator () ؛

     @اختبار
     فشل باطل عندماNameIsNull () {
         shouldFailValidation (PersonObjectMother.createPersonWithoutName ()) ،
     }

     @اختبار
     فشل باطلWhenAgeIsNegative () {
         shouldFailValidation (PersonObjectMother.createPersonWithNegativeAge ()) ؛
     }

     الفراغ الخاص shouldFailValidation (الشخص غير صالحPerson) {
         assertThatThrownBy (() -> validator.validate (validPerson))
             .isInstanceOf (InvalidPersonException.class) ،
   
     }
 }

اختبار العشوائية

كيف يفترض بنا اختبار العشوائية في كودنا؟

لنفترض أن لدينا PersonGenerator قام generateRandom حالات عشوائية من Person .

نبدأ بكتابة ما يلي:

 فئة PersonGeneratorShould {

     مولد PersonGenerator الخاص = new PersonGenerator () ؛

     @اختبار
     إنشاء باطل ValidPerson () {
         شخص شخص = generator.generateRandom () ؛
         أكد ذلك (شخص).
    }
}

وبعد ذلك يجب أن نسأل أنفسنا:

  • ما الذي أحاول إثباته هنا؟ ماذا يجب أن تفعل هذه الوظيفة؟
  • هل يجب أن أتحقق فقط من أن الشخص الذي تم إنشاؤه هو مثيل غير فارغ؟
  • هل أحتاج إلى إثبات أنها عشوائية؟
  • هل يجب أن يتبع المثيل الذي تم إنشاؤه بعض قواعد العمل؟

يمكننا تبسيط اختبارنا باستخدام حقن التبعية.

 الواجهة العامة RandomGenerator {
     String createRandomString () ،
     int إنشاءRandomInteger () ،
}

لدى PersonGenerator الآن مُنشئ آخر يقبل أيضًا مثيلًا لتلك الواجهة أيضًا. بشكل افتراضي ، يستخدم تطبيق JavaRandomGenerator الذي يولد قيمًا عشوائية باستخدام java.Random .

ومع ذلك ، في الاختبار ، يمكننا كتابة تطبيق آخر أكثر قابلية للتنبؤ به.

 @اختبار
 إنشاء باطل ValidPerson () {
     RandomGenerator randomGenerator = new PredictableGenerator ("John Doe" ، 20) ؛
     مولد PersonGenerator = مولد شخصي جديد (مولد عشوائي) ؛
     شخص شخص = generator.generateRandom () ؛
     تأكيد ذلك (شخص) .isEqualTo (شخص جديد ("John Doe" ، 20)) ؛
}

يثبت هذا الاختبار أن PersonGenerator يولد حالات عشوائية كما هو محدد بواسطة RandomGenerator دون الدخول في أي تفاصيل عن RandomGenerator .

لا يضيف اختبار JavaRandomGenerator أي قيمة لأنه عبارة عن غلاف بسيط حول java.Random . من خلال اختباره ، ستختبر بشكل أساسي java.Random من مكتبة Java القياسية. لن تؤدي كتابة الاختبارات الواضحة إلا إلى صيانة إضافية مع القليل من الفوائد ، إن وجدت.

لتجنب كتابة تطبيقات لأغراض الاختبار ، مثل PredictableGenerator ، يجب عليك استخدام مكتبة محاكاة مثل Mockito.

عندما كتبنا PredictableGenerator ، قمنا بالفعل بإيقاف فئة RandomGenerator يدويًا. يمكنك أيضًا إيقافه باستخدام Mockito:

 @اختبار
 إنشاء باطل ValidPerson () {
     RandomGenerator randomGenerator = mock (RandomGenerator.class) ؛
     عندما (randomGenerator.generateRandomString ()). ثم العودة ("John Doe") ؛
     عندما (randomGenerator.generateRandomInteger ()). thenReturn (20) ؛

     مولد PersonGenerator = مولد شخصي جديد (مولد عشوائي) ؛
     شخص شخص = generator.generateRandom () ؛
     تأكيد ذلك (شخص) .isEqualTo (شخص جديد ("John Doe" ، 20)) ؛
 }

هذه الطريقة في كتابة الاختبارات تكون أكثر تعبيرًا وتؤدي إلى عدد أقل من التطبيقات لاختبارات محددة.

Mockito هي مكتبة Java لكتابة نماذج وأذواق. إنه مفيد جدًا عند اختبار الكود الذي يعتمد على مكتبات خارجية لا يمكنك إنشاء مثيل لها بسهولة. يسمح لك بكتابة السلوك لهذه الفئات دون تنفيذها بشكل مباشر.

يسمح Mockito أيضًا ببناء جملة آخر لإنشاء وحقن نماذج لتقليل الصيغة المعيارية عندما يكون لدينا أكثر من اختبار مشابه لما اعتدنا عليه:

 ExtendWith (MockitoExtension.class) // 1
 فئة PersonGeneratorShould {

     Mock // 2
     مولد عشوائي عشوائي.

     @ 3
     مولد شخصي خاص

     @اختبار
     إنشاء باطل ValidPerson () {
         عندما (randomGenerator.generateRandomString ()). ثم العودة ("John Doe") ؛
         عندما (randomGenerator.generateRandomInteger ()). thenReturn (20) ؛

         شخص شخص = generator.generateRandom () ؛
         تأكيد ذلك (شخص) .isEqualTo (شخص جديد ("John Doe" ، 20)) ؛
     }
}

1. يمكن للوحدة 5 استخدام "الامتدادات" لتوسيع قدراتها. يسمح هذا التعليق التوضيحي بالتعرف على الصور المحاكاة من خلال التعليقات التوضيحية وإدخالها بشكل صحيح.

2. ينشئ التعليق التوضيحي @Mock للسخرية من الحقل. هذا هو نفس كتابة mock(RandomGenerator.class) في هيكل طريقة الاختبار لدينا.

3. سينشئ التعليق التوضيحي @InjectMocks جديدًا من PersonGenerator ويحقن الصور في مثيل generator .

لمزيد من التفاصيل حول ملحقات JUnit 5 انظر هنا.

لمزيد من التفاصيل حول حقن Mockito انظر هنا.

هناك مشكلة واحدة لاستخدام @InjectMocks . قد يزيل الحاجة إلى إعلان مثيل للكائن يدويًا ، لكننا نفقد أمان وقت الترجمة للمنشئ. إذا أضاف شخص ما في أي وقت تبعية أخرى للمنشئ ، فلن نحصل على خطأ وقت الترجمة هنا. قد يؤدي هذا إلى فشل الاختبارات التي يصعب اكتشافها. أفضل استخدام @BeforeEach لإعداد المثيل يدويًا:

 ExtendWith (MockitoExtension.class)
فئة PersonGeneratorShould {

     Mock
     مولد عشوائي عشوائي.

     مولد شخصي خاص

     تضمين التغريدة
     الإعداد باطل() {
         المولد = PersonGenerator جديد (randomGenerator) ؛
     }

     @اختبار
     إنشاء باطل ValidPerson () {
         عندما (randomGenerator.generateRandomString ()). ثم العودة ("John Doe") ؛
         عندما (randomGenerator.generateRandomInteger ()). thenReturn (20) ؛

         شخص شخص = generator.generateRandom () ؛
         تأكيد ذلك (شخص) .isEqualTo (شخص جديد ("John Doe" ، 20)) ؛
     }
}

اختبار العمليات الحساسة للوقت

غالبًا ما يعتمد جزء من الكود على الطوابع الزمنية ونميل إلى استخدام طرق مثل System.currentTimeMillis() للحصول على الطابع الزمني الحالي.

بينما يبدو هذا جيدًا ، من الصعب اختبار وإثبات ما إذا كانت التعليمات البرمجية الخاصة بنا تعمل بشكل صحيح عندما يتخذ الفصل قرارات لنا داخليًا. مثال على مثل هذا القرار هو تحديد ما هو اليوم الحالي.

 مفهرس الصفوف يجب أن يكون {
     مفهرس خاص = مفهرس جديد () ؛
     @اختبار
     إنشاء باطل إندكسنامي فورتومورو () {
         String indexName = indexer.tomorrow ("my-index") ؛
         // سيعمل هذا الاختبار اليوم ، لكن ماذا عن الغد؟
        تأكيد ذلك (اسم الفهرس)
           .isEqualTo ("my-index.2022-02-02") ؛
     }
}

يجب أن نستخدم Dependency Injection مرة أخرى حتى نتمكن من "التحكم" في اليوم الذي يتم فيه إنشاء اسم الفهرس.

تحتوي Java على فئة Clock للتعامل مع حالات الاستخدام مثل هذه. يمكننا تمرير مثيل Clock إلى Indexer لدينا للتحكم في الوقت. يمكن للمُنشئ الافتراضي استخدام Clock.systemUTC() للتوافق مع الإصدارات السابقة. يمكننا الآن استبدال استدعاءات System.currentTimeMillis() بـ clock.millis() .

من خلال حقن Clock ، يمكننا فرض وقت يمكن التنبؤ به في فصولنا وكتابة اختبارات أفضل.

اختبار طرق إنتاج الملفات

  • كيف يجب أن نختبر الفئات التي تكتب مخرجاتها إلى الملفات؟
  • أين يجب أن نخزن هذه الملفات لكي تعمل على أي نظام تشغيل؟
  • كيف نتأكد من أن الملف غير موجود بالفعل؟

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

 فئة DogToCsvWriterShould {

     كاتب DogToCsvWriter الخاص = new DogToCsvWriter ("/ tmp / dogs.csv") ؛
     
     @اختبار
     باطل convertToCsv () {
         كاتب.appendAsCsv (كلب جديد (Breed.CORGI ، Color.BROWN ، "Monty")) ؛
         كاتب.appendAsCsv (كلب جديد (Breed.MALTESE ، Color.WHITE ، "Zoe")) ؛

         String csv = Files.readString ("/ tmp / dogs.csv") ؛

         assertThat (csv) .isEqualTo ("Monty، corgi، brown \ nZoe، maltese، white")؛
     }
}

يجب فصل عملية التسلسل عن عملية الكتابة ، لكن دعنا نركز على إصلاح الاختبار.

المشكلة الأولى في الاختبار أعلاه هي أنه لن يعمل على Windows لأن مستخدمي Windows لن يتمكنوا من حل المسار /tmp/dogs.csv . هناك مشكلة أخرى وهي أنه لن يعمل إذا كان الملف موجودًا بالفعل لأنه لا يتم حذفه عند تنفيذ الاختبار أعلاه. قد يعمل بشكل جيد في خط أنابيب CI / CD ، ولكن ليس محليًا إذا تم تشغيله عدة مرات.

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

 فئة DogToCsvWriterShould {

     @اختبار
     باطل convertToCsv (TempDir Path tempDir) {
         Path dogsCsv = tempDir.resolve ("dogs.csv") ؛
         الكاتب DogToCsvWriter = جديد DogToCsvWriter (كلابكسسف) ؛
         كاتب.appendAsCsv (كلب جديد (Breed.CORGI ، Color.BROWN ، "Monty")) ؛
         كاتب.appendAsCsv (كلب جديد (Breed.MALTESE ، Color.WHITE ، "Zoe")) ؛

         String csv = Files.readString (dogsCsv) ،

         assertThat (csv) .isEqualTo ("Monty، corgi، brown \ nZoe، maltese، white")؛
     }
}

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

الأمر مقابل اختبار الاستعلام

ما هو الفرق بين الأمر والاستعلام؟

  • الأمر : نوجه كائنًا لتنفيذ إجراء ينتج عنه تأثير دون إرجاع قيمة (طرق باطلة)
  • الاستعلام : نطلب من كائن ما تنفيذ إجراء وإرجاع نتيجة أو استثناء

حتى الآن ، اختبرنا بشكل أساسي الاستعلامات حيث قمنا باستدعاء طريقة أعادت قيمة أو ألقيت استثناء في مرحلة الفعل. كيف يمكننا اختبار الطرق void ومعرفة ما إذا كانت تتفاعل بشكل صحيح مع الفئات الأخرى؟ توفر الأطر مجموعة مختلفة من الأساليب لكتابة هذه الأنواع من الاختبارات.

كانت التأكيدات التي كتبناها حتى الآن عن الاستفسارات تبدأ assertThat . عند كتابة اختبارات الأوامر ، نستخدم مجموعة مختلفة من الأساليب لأننا لم نعد نفحص النتائج المباشرة للطرق كما فعلنا مع الاستعلامات. نريد "التحقق" من التفاعلات التي أجرتها طريقتنا مع أجزاء أخرى من نظامنا.

 ExtendWith (MockitoExtension.class)
 فئة FeedMentionServiceShould {

     Mock
     مستودع FeedRepository الخاص ؛

     Mock
     باعث FeedMentionEventEmitter الخاص ؛

     خدمة FeedMentionService الخاصة ؛

     تضمين التغريدة
     الإعداد باطل() {
         service = new FeedMentionService (مستودع ، باعث) ؛
     }

     @اختبار
     إدراج باطل MentionToFeed () {
         معرف التغذية الطويل = 1 لتر ؛
         أذكر = ... ؛

         عندما (repository.upsertMention (feedId ، الذكر))
             .thenReturn (UpsertResult.success (feedId، الذكر)) ؛

         حدث FeedInsertionEvent = FeedInsertionEvent جديد (feedId ، الذكر) ؛
         noteService.insertMentionToFeed (حدث) ؛

         التحقق من (باعث). الإشارة إلى الخلاصة (feedId ، الذكر) ؛
         التحقق من عدم وجود المزيد من التفاعلات (الباعث) ؛
     }
}

في هذا الاختبار ، سخرنا أولاً من مستودعنا للرد بنجاح نتيجة UpsertResult.success عندما طُلب منك تأكيد الإشارة في خلاصتنا. لسنا معنيين باختبار المستودع هنا. يجب اختبار طرق المستودع في FeedRepositoryShould . من خلال السخرية من هذا السلوك ، لم نستدعي طريقة المستودع. قلنا لها ببساطة كيفية الرد في المرة القادمة التي يتم استدعاؤها.

ثم أخبرنا خدمة الإشارة إلى إدراج هذه الإشارة في mentionService . نحن نعلم أنه يجب أن تصدر النتيجة فقط إذا نجحت في إدراج الإشارة في الخلاصة. باستخدام طريقة verify ، يمكننا التأكد من استدعاء الطريقة المذكورة مع mentionInsertedToFeed ولم يتم استدعاؤها مرة أخرى باستخدام verifyNoMoreInteractions .

افكار اخيرة

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

هل أنت مهتم بالانضمام إلى فريق Mediatoolkit؟
تحقق من منصبنا المفتوح لمطور الواجهة الأمامية الأقدم !