اختبار الوحدة ، دراسة مفصلة للاختبارات ذات المعلمات. الجزء الأول

يوم جيد ، زملائي.



قررت أن أشارك رؤيتي لاختبارات الوحدات ذات المعلمات ، وكيف نقوم بها ، وكيف لا تفعل ذلك على الأرجح (ولكنك تريد القيام بذلك).



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



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



دعونا ننشئ مشروعًا مخضرمًا ، نضيف junit5 ، junit-jupiter-params و mokito إليه



حتى لا يكون مملًا تمامًا ، سنبدأ الكتابة على الفور من الاختبار ، كما يحب المدافعون عن TDD ، نحتاج إلى خدمة سنختبرها بشكل إعلاني ، أي شيء سيفعله ، فليكن خدمة HabrService.



لنقم بإنشاء اختبار HabrServiceTest. أضف رابطًا إلى HabrService في حقل فئة الاختبار:



public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}


أنشئ خدمة عبر بيئة تطوير متكاملة (بالضغط برفق على الاختصار) ، أضف التعليق التوضيحيInjectMocks إلى الحقل.



لنبدأ مباشرة بالاختبار: سيكون لدى HabrService في تطبيقنا الصغير طريقة مقبض واحد () تأخذ وسيطة HabrItem واحدة ، والآن يبدو اختبارنا كما يلي:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @Test
    void handleTest(){
        HabrItem item = new HabrItem();
        habrService.handle(item);
    }
}


دعنا نضيف التابع handle () إلى HabrService ، والذي سيعيد معرف المنشور الجديد على Habré بعد أن يتم الإشراف عليه وحفظه في قاعدة البيانات ، ويأخذ نوع HabrItem ، وسننشئ أيضًا HabrItem ، والآن يجمع الاختبار ، لكن يتعطل.



النقطة المهمة هي أننا أضفنا فحصًا لقيمة الإرجاع المتوقعة.



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        Long actual = habrService.handle(item);

        assertEquals(1L, actual);
    }
}


أيضًا ، أريد أن أتأكد من أنه أثناء استدعاء طريقة المقبض () ، تم استدعاء ReviewService و PersistanceService ، وتم استدعاؤهما بدقة واحدة تلو الأخرى ، وعملوا مرة واحدة بالضبط ، ولم يعد يتم استدعاء أي طرق أخرى. بمعنى آخر ، مثل هذا:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp(){
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem item = new HabrItem();
        
        Long actual = habrService.handle(item);
        
        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(item);
        inOrder.verify(persistenceService).makePersist(item);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


أضف reviewService و persistentService إلى حقلي الفصل ، وأنشئهما ، وأضف أساليب makeRewiew () و makePersist () إليهما على التوالي. الآن يتم تجميع كل شيء ، لكن الاختبار أحمر بالطبع.



في سياق هذه المقالة ، لا تعد تطبيقات ReviewService و PersistanceService مهمة جدًا ، كما أن تطبيق HabrService مهم ، فلنجعله أكثر تشويقًا مما هو عليه الآن:



public class HabrService {

    private final ReviewService reviewService;

    private final PersistenceService persistenceService;

    public HabrService(final ReviewService reviewService, final PersistenceService persistenceService) {
        this.reviewService = reviewService;
        this.persistenceService = persistenceService;
    }

    public Long handle(final HabrItem item) {
        HabrItem reviewedItem = reviewService.makeRewiew(item);
        Long persistedItemId = persistenceService.makePersist(reviewedItem);

        return persistedItemId;
    }
}


وباستخدام عندما (). ثم () نقوم بتثبيت سلوك المكونات المساعدة ، ونتيجة لذلك ، أصبح اختبارنا على هذا النحو والآن أصبح أخضر:



public class HabrServiceTest {

    @Mock
    private ReviewService reviewService;

    @Mock
    private PersistenceService persistenceService;

    @InjectMocks
    private HabrService habrService;

    @BeforeEach
    void setUp() {
        initMocks(this);
    }

    @Test
    void handleTest() {
        HabrItem source = new HabrItem();
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }
}


نموذج بالحجم الطبيعي لإثبات قوة الاختبارات المحددة جاهز.



أضف حقلاً بنوع المحور ، hubType إلى نموذج الطلب الخاص بنا لخدمة HabrItem ، قم بإنشاء Enum HubType وقم بتضمين عدة أنواع فيه:



public enum HubType {
    JAVA, C, PYTHON
}


وبالنسبة لنموذج HabrItem ، قم بإضافة دالة getter و setter إلى حقل HubType الذي تم إنشاؤه.



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



        
    @Test
    void handleTest() {
        HabrItem reviewedItem = mock(HabrItem.class);
        HabrItem source = new HabrItem();
        source.setHubType(HubType.JAVA);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


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



@ParameterizedTest
    @EnumSource(HubType.class)
    void handleTest(final HubType type) 


بشكل جيد ، بشكل إعلاني ، وسيتم استخدام جميع قيم تعدادنا بالتأكيد في بعض الاختبارات التالية ، يحتوي التعليق التوضيحي على معلمات ، ويمكننا إضافة استراتيجيات للتضمين والاستبعاد.



لكن ربما لم أقنعك بأن الاختبارات ذات المعلمات جيدة. اضف إليه

طلب HabrItem الأصلي هو حقل تحرير جديد ، حيث سيتم كتابة عدد آلاف المرات التي يعدل فيها مستخدمو Habr مقالتهم قبل النشر ، بحيث تعجبك قليلاً على الأقل ، وافترض أنه في مكان ما في أعماق خدمة HabrService يوجد نوع من المنطق يفعل المجهول شيئًا ما ، اعتمادًا على مقدار ما حاول المؤلف ، ماذا لو كنت لا أرغب في كتابة 5 أو 55 اختبارًا لجميع خيارات editCount الممكنة ، لكنني أريد الاختبار بشكل إعلاني ، وفي مكان ما في مكان واحد ، أشر على الفور إلى جميع القيم التي أود التحقق منها ... لا يوجد شيء أبسط ، وباستخدام API للاختبارات ذات المعلمات ، نحصل على شيء مثل هذا في إعلان الطريقة:



    @ParameterizedTest
    @ValueSource(ints = {0, 5, 14, 23})
    void handleTest(final int type) 


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



يأتي التعليق التوضيحيMethodSource للإنقاذ ، وأصبحت طريقة الاختبار لدينا أقصر بشكل ملحوظ ولم يعد هناك محددون فيها ، ويتم تغذية مصدر الطلب الوارد إلى طريقة الاختبار كمعامل:



    
    @ParameterizedTest
    @MethodSource("generateSource")
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


يحتوي التعليق التوضيحيMethodSource على سلسلة إنشاء مصدر ، فما هو؟ هذا هو اسم الطريقة التي ستجمع النموذج المطلوب لنا ، سيبدو إعلانها كما يلي:



   private static Stream<Arguments> generateSource() {
        HabrItem habrItem = new HabrItem();
        habrItem.setHubType(HubType.JAVA);
        habrItem.setEditCount(999L);
        
        return nextStream(() -> habrItem);
    }


للراحة ، قمت بنقل تشكيل دفق من وسيطات nextStream إلى فئة اختبار فائدة منفصلة:



public class CommonTestUtil {
    private static final Random RANDOM = new Random();

    public static <T> Stream<Arguments> nextStream(final Supplier<T> supplier) {
        return Stream.generate(() -> Arguments.of(supplier.get())).limit(nextIntBetween(1, 10));
    }

    public static int nextIntBetween(final int min, final int max) {
        return RANDOM.nextInt(max - min + 1) + min;
    }
}


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



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



في رأيي ، كل شيء رائع بالفعل ، والاختبار الآن يصف فقط سلوك بذرة ، والنتائج المتوقعة.



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



ممتاز. نستخدم التعليق التوضيحيJsonSource ، والذي سيأخذ معلمة مسار ، مع مسار ملف نسبي وفئة مستهدفة. تبا! لا يوجد مثل هذا التعليق التوضيحي في الاختبارات ذات المعلمات ، لكني أود ذلك.



دعونا نكتبها بأنفسنا.



ArgumentsProvider هي المسؤولة عن معالجة جميع التعليقات التوضيحية المضمنة فيParametrizedTest في junit ، وسوف نكتب JsonArgumentProvider الخاص بنا:



public class JsonArgumentProvider implements ArgumentsProvider, AnnotationConsumer<JsonSource> {

    private String path;

    private MockDataProvider dataProvider;

    private Class<?> clazz;

    @Override
    public void accept(final JsonSource jsonSource) {
        this.path = jsonSource.path();
        this.dataProvider = new MockDataProvider(new ObjectMapper());
        this.clazz = jsonSource.clazz();
    }

    @Override
    public Stream<Arguments> provideArguments(final ExtensionContext context) {
        return nextSingleStream(() -> dataProvider.parseDataObject(path, clazz));
    }
}


MockDataProvider هي فئة لتحليل ملفات json الوهمية ، وتنفيذها بسيط للغاية:




public class MockDataProvider {

    private static final String PATH_PREFIX = "json/";

    private final ObjectMapper objectMapper;

     public <T> T parseDataObject(final String name, final Class<T> clazz) {
        return objectMapper.readValue(new ClassPathResource(PATH_PREFIX + name).getInputStream(), clazz);
    }

}


الموفر الوهمي جاهز ، وموفر الوسيطة للتعليق التوضيحي أيضًا ، ويبقى إضافة التعليق التوضيحي نفسه:




/**
 * Source-   ,
 *     json-
 */
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@ArgumentsSource(JsonArgumentProvider.class)
public @interface JsonSource {

    /**
     *   json-,   classpath:/json/
     *
     * @return     
     */
    String path() default "";

    /**
     *  ,        
     *
     * @return  
     */
    Class<?> clazz();
}


الصيحة. شرحنا جاهز للاستخدام ، طريقة الاختبار الآن:



  
    @ParameterizedTest
    @JsonSource(path = MOCK_FILE_PATH, clazz = HabrItem.class)
    void handleTest(final HabrItem source) {
        HabrItem reviewedItem = mock(HabrItem.class);

        when(reviewService.makeRewiew(source)).thenReturn(reviewedItem);
        when(persistenceService.makePersist(reviewedItem)).thenReturn(1L);

        Long actual = habrService.handle(source);

        InOrder inOrder = Mockito.inOrder(reviewService, persistenceService);
        inOrder.verify(reviewService).makeRewiew(source);
        inOrder.verify(persistenceService).makePersist(reviewedItem);
        inOrder.verifyNoMoreInteractions();

        assertEquals(1L, actual);
    }


في mock json ، يمكننا إنتاج الكثير وبسرعة كبيرة مجموعة من العناصر التي نحتاجها ، وليس هناك من أي مكان من الآن فصاعدًا رمز يصرف الانتباه عن جوهر الاختبار ، لتكوين بيانات الاختبار ، بالطبع ، يمكنك غالبًا القيام به باستخدام mocks ، ولكن ليس دائمًا.



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



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



All Articles