我决定分享我对参数化单元测试的看法,我们如何做以及您可能不愿意做(但想做)。
我想为应该正确测试的内容写一个漂亮的短语,测试很重要,但是在我之前已经说过很多东西,并且已经写了很多材料,我只是试图总结和强调我认为人们很少使用(理解)的东西,基本上搬进去。
本文的主要目的是展示如何(并且应该)停止使用用于创建对象的代码来使单元测试混乱,以及如何在模拟(任何())不够的情况下以声明方式创建测试数据,并且存在许多此类情况。
让我们创建一个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概念,我想将测试数据添加到讲故事的活动中,以使其更加清晰和有趣。