单元测试,详细考虑参数化测试。第一部分

大家好



我决定分享我对参数化单元测试的看法,我们如何做以及您可能不愿意做(但想做)。



我想为应该正确测试的内容写一个漂亮的短语,测试很重要,但是在我之前已经说过很多东西,并且已经写了很多材料,我只是试图总结和强调我认为人们很少使用(理解)的东西,基本上搬进去。



本文的主要目的是展示如何(并且应该)停止使用用于创建对象的代码来使单元测试混乱,以及如何在模拟(任何())不够的情况下以声明方式创建测试数据,并且存在许多此类情况。



让我们创建一个maven项目,向其中添加junit5,junit-jupiter-params和mokito,以便它



不会完全无聊,我们将立即开始编写测试,就像TDD辩护者所希望的那样,我们需要一个将进行声明式测试的服务,任何可以做的事情,让它成为HabrService。



让我们创建一个测试HabrServiceTest。在测试类字段中添加指向HabrService的链接:



public class HabrServiceTest {

    private HabrService habrService;

    @Test
    void handleTest(){

    }
}


通过ide创建服务(轻按快捷键),然后将@InjectMocks批注添加到该字段中。



让我们直接从测试开始:我们的小型应用程序中的HabrService将具有一个单独的handle()方法,该方法将带有一个HabrItem参数,现在我们的测试如下所示:



public class HabrServiceTest {

    @InjectMocks
    private HabrService habrService;

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


让我们向HabrService添加一个handle()方法,该方法在审核并保存到数据库后在Habré上返回新帖子的ID,并采用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);
    }
}


另外,我想确保在调用handle()方法期间,调用了ReviewService和PersistanceService,它们严格地一个接一个地被调用,它们恰好工作了1次,并且不再调用其他方法。换句话说,像这样:



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和persistenceService添加到类类的字段中,创建它们,并分别向其添加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;
    }
}


并使用when()。then()构造,我们锁定了辅助组件的行为,结果,我们的测试变成了这样,现在它是绿色的:



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);
    }
}


演示参数化测试功能的模型已准备就绪。



向我们的HabrItem服务的请求模型中添加一个具有hub类型hubType的字段,创建一个枚举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请求是一个新的editCount字段,其中Habr用户编辑文章的次数将在发布前被写入,因此您至少需要一点点,并且假设HabrService深入的某处存在某种执行未知操作的逻辑有些东西,取决于作者尝试了多少,如果我不想为所有可能的editCount选项编写5或55个测试,但我想进行声明式测试,那么在某个地方的某个地方立即指出我要检查的所有值...没有什么比这更简单了,使用参数化测试的api,我们可以在方法声明中得到如下内容:



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


有一个问题,我们要声明式地一次在测试方法参数中收集两个值,您可以使用另一种出色的参数化测试方法@CsvSource,非常适合测试简单参数,具有简单的输出值(对于测试实用程序类非常方便),但是如果对象变得更复杂?假设它将有大约10个字段,而不仅仅是基元和Java类型。



@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批注具有generateSource字符串,这是什么?这是将为我们收集所需模型的方法的名称,其声明如下所示:



   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负责处理junit中@ParametrizedTest随附的所有注释,我们将编写自己的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);
    }


在模拟json中,我们可以快速生成大量所需的对象,并且从现在开始,没有代码会分散测试本质,因为测试数据的形成当然可以经常使用模拟,但并非总是如此。



总结一下,我想说以下内容:我们经常像过去一样工作多年,而没有想到可以使用我们多年使用的库的标准api精美而简单地完成某些事情,但并不了解它们的全部功能。



PS:本文并非尝试了解TDD概念,我想将测试数据添加到讲故事的活动中,以使其更加清晰和有趣。



All Articles