作者 | 任旭东
![640?wx_fmt=jpeg](https://img-blog.csdnimg.cn/img_convert/bc3a3578f9ecafe2a7edd751fc9fd99c.png)
杏仁后端攻城狮,关注服务端技术和敏捷开发。
单元测试的实践之路
祸乱生与疏忽,单元测试先于交付。
穿越暂时黑暗的时光隧道,才能迎来系统的曙光。
——《码出高效》
认识
知道测试金字塔模型的人都知道,单元测试位于最底层,也是最重要的一层。
单元测试的测试粒度最小,但对发现问题的帮助却最大。
![640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/06768674166b9cce2270069daad473a5.png)
单元测试的基本原则——AIR
Automatic 自动化
Independent 独立性
Repeatable 可重复
单元测试之于项目,就像大气层之于地球一样,它影响不到线上的业务逻辑,但却可以保证线上代码的质量。
单元测试的好处
提升代码质量
促进代码优化
提升研发效率
增加重构信心
单元测试有点像早睡早起,大家都听说过它的各种好处,但做到的却很少。
实践单元测试的痛点
测试数据准备繁琐
依赖关系复杂
没有养成习惯
代码不可测
认识不足
探索
参数化单元测试(JUnit 5 Parameterized Tests)
对于开发而言,准备测试数据是写单元测试最大的痛点。Parameterized Tests 的出现为我们提供了新的选择。
常规的 UT 通常都包含下面的三个步骤:
测试数据准备
调用目标方法
验证执行结果
![640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/ee2da0e87be05468860f6fb9011fda66.png)
很明显,不同的测试数据对应着不同的验证逻辑。但是第二步调用目标方法都是一样的。正所谓铁打的营盘流水的兵。如果能将测试数据和对应的验证逻辑独立维护,再依次与调用目标方法的逻辑拼装到一起。这样单元测试的代码会更加简洁,维护起来也更加容易。准备测试数据的时候也可以跟专注。
通过 @ParameterizedTest
和 @MethodSource
将测试数据和验证逻辑以参数的形式传入
@DisplayName("基于 id 的游标分页降序测试")
@ParameterizedTest
@MethodSource("contentCursorableAndVerifierProvider")
public void testCursorOrderById(List<TestData> content, Cursorable cursorable,
Consumer<Cursor<TestData>> verifier) {
Cursor<TestData> cursor = Cursor.of(content, cursorable, TestData::getId, Long.class);
verifier.accept(cursor);
}
static Stream<Arguments> contentCursorableAndVerifierProvider() {
return Stream.of(
/* 1.空页
* || */
arguments(Lists.newArrayList(), buildOrderByIdCursorable(),
contentIsEmpty.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
/* 2.第一页,不足一页
* |13, 12, 11| */
arguments(TestData.createListById(13, 12, 11), buildOrderByIdCursorable(),
contentNotEmpty.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
/* 3.第一页,刚好一页
* |15, 14, 13, 12, 11| */
arguments(TestData.createListById(15, 14, 13, 12, 11),
buildOrderByIdCursorable(),
fullContent.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
/* 4.第一页,有下一页
* |15, 14, 13, 12, 11|10, 9... */
arguments(TestData.createListById(15, 14, 13, 12, 11, 10), buildOrderByIdCursorable(),
fullContent.andThen(hasNext).andThen(nextCursorIs(11)).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
/* 5.点击下一页后,上一页和下一页都有数据
* 15, 14, 13, 12, 11|10, 9, 8, 7, 6|5... */
arguments(TestData.createListById(10, 9, 8, 7, 6, 5), buildOrderByIdCursorableWithAfter(11L),
fullContent.andThen(hasNext).andThen(nextCursorIs(6)).andThen(hasPrevious).andThen(previousCursorIs(10))),
/* 6.点击下一页后,最后一页数据未满
* 13, 12, 11, 10, 9, 8, 7, 6, 5, 4|3, 2, 1| */
arguments(TestData.createListById(3, 2, 1), buildOrderByIdCursorableWithAfter(4L),
contentNotEmpty.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasPrevious).andThen(previousCursorIs(3))),
/* 7.点击下一页后,最后一页数据刚满
* 15, 14, 13, 12, 11, 10, 9, 8, 7, 6|5, 4, 3, 2, 1| */
arguments(TestData.createListById(5, 4, 3, 2, 1), buildOrderByIdCursorableWithAfter(6L),
fullContent.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasPrevious).andThen(previousCursorIs(5))),
/* 8.点击上一页后,上一页和下一页都有数据
* 15, 14, 13, 12, 11|10, 9, 8, 7, 6|5... */
arguments(TestData.createListById(11, 10, 9, 8, 7, 6), buildOrderByIdCursorableWithBefore(5L),
fullContent.andThen(hasNext).andThen(nextCursorIs(6)).andThen(hasPrevious).andThen(previousCursorIs(10))),
/* 9.点击上一页后,第一页数据未满(刚有数据被删的时候)
* |15, 14, 12, 11|10, 9... */
arguments(TestData.createListById(15, 14, 12, 11), buildOrderByIdCursorableWithBefore(10L),
contentNotEmpty.andThen(hasNext).andThen(nextCursorIs(11)).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
/* 10.点击上一页后,第一页数据刚满
* |15, 14, 13, 12, 11|10, 9... */
arguments(TestData.createListById(15, 14, 13, 12, 11), buildOrderByIdCursorableWithBefore(10L),
fullContent.andThen(hasNext).andThen(nextCursorIs(11)).andThen(hasNotPrevious).andThen(previousCursorIsNull))
);
}
Reference:
官方doc#Parameterized Tests
JUnit 5 Tutorial: Writing Parameterized Tests
从 @CsvFileSource 到 @YamlFileSource
第一个例子是相对比较简单的场景,实际项目中的场景往往会复杂很多。如果是扁平的对象还可以通过 JUnit 5 中的 @CsvFileSource
+ .csv
文件来准备测试数据。但如果是结构化的对象就会让人头痛了。而实际项目中大部分都是结构化的对象。还好 JUnit 5 的扩展性很好,我们可以自定义一个 @YamlFileSource
来实现结构化的测试数据的准备。
//YamlFileSource.java
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = EXPERIMENTAL, since = "5.0")
@ArgumentsSource(YamlFileArgumentsProvider.class)
public @interface YamlFileSource {
String[] resources();
Class type();
}
//YamlFileArgumentsProvider.java
public class YamlFileArgumentsProvider<T> implements ArgumentsProvider, AnnotationConsumer<YamlFileSource> {
private String[] resources;
private Class<T> type;
private final YAMLFactory yamlFactory = new YAMLFactory();
private final ObjectMapper mapper = new ObjectMapper(yamlFactory).registerModule(new Jdk8Module());
@Override
@SuppressWarnings("unchecked")
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
if (Object.class.equals(type)) {
context.getTestMethod().filter(method -> method.getParameterCount() > 0)
.map(method -> method.getParameterTypes()[0])
.ifPresent(clazz -> type = (Class<T>) clazz);
}
return Arrays.stream(resources).map(this::readValue).map(Arguments::of);
}
@Override
@SuppressWarnings("unchecked")
public void accept(YamlFileSource annotation) {
resources = annotation.resources();
type = annotation.type();
}
private T readValue(String filePath) {
Preconditions.notBlank(filePath, "文件路径不能为空");
try {
InputStream is = getSystemResourceAsStream((filePath));
return mapper.readValue(yamlFactory.createParser(is), type);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
这样我们只需要定义一个简单的POJO,把测试中需要的数据都放进去。然后就可以通过 .yaml
文件来准备测试数据了。将 Jdk8Module
注册到 ObjectMapper
后,像 Optional
这样 Jdk8
新增的包装类型也不在话下。比如:
TestDataFromYaml.java
public class TestDataFromYaml {
private String caseName;
private int id;
private boolean enableAbs;
private Optional<String> optionalString;
private List<Integer> list;
private Map<String, String> map;
// expected data
private Integer result;
@Override
public String toString() {
return String.format("%d: %s", id, caseName);
}
public String getCaseName() {
return caseName;
}
public void setCaseName(String caseName) {
this.caseName = caseName;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public boolean isEnableAbs() {
return enableAbs;
}
public void setEnableAbs(boolean enableAbs) {
this.enableAbs = enableAbs;
}
public List<Integer> getList() {
return list;
}
public void setList(List<Integer> list) {
this.list = list;
}
public Map<String, String> getMap() {
return map;
}
public void setMap(Map<String, String> map) {
this.map = map;
}
public Integer getResult() {
return result;
}
public void setResult(Integer result) {
this.result = result;
}
public Optional<String> getOptionalString() {
return optionalString;
}
public void setOptionalString(Optional<String> optionalString) {
this.optionalString = optionalString;
}
}
test_case_1.yaml
caseName: "case one"
id: 2
optionalString: "str"
enableAbs: false
list:
- 2
- -3
map:
k3: "v3"
k2: "v2"
result: -1
然后再配合 @ParameterizedTest
就可以轻松实现多个case的单元测试
public class YamlFileSourceTest {
@YamlFileSource(resources = {"test_case_1.yaml", "test_case_2.yaml"})
@ParameterizedTest
public void sumNumberListTest(TestDataFromYaml testDataFromYaml) {
boolean enableAbs = testDataFromYaml.isEnableAbs();
Integer result = testDataFromYaml.getList().stream()
.mapToInt((i -> enableAbs ? Math.abs(i) : i)).sum();
assertEquals(result, testDataFromYaml.getResult());
assertNotNull(testDataFromYaml.getOptionalString());
}
}
@Mock & @Spy & @InjectMocks
Mockito:Tasty mocking framework for unit tests in Java
如果说 JUnit 5
帮我们解决了数据准备的痛点,那 Mockito
就能帮我们解决依赖关系复杂的痛点。
Mockito 不仅可以帮我们模拟外部依赖的行为,还帮我们验证真实依赖的行为。配合JUnit 5
的 @InjectMocks
即可方便的将模拟对象注入到目标对象中。
`@Mock vs @Spy`
相同点
都不是真正的对象。
都可以 Mock 对象的行为骗过调用者。
都可以验证 mock 对象的行为。
不同点
@Spy 修饰的对象没有 mock 行为时,会调用真实的方法。
实例:收到退款通知后库存恢复的步骤
只 mock 部分依赖
InventoryRecoveryStep
依赖了 ArmoryFacade
和 ArmoryConverter
。可是 ArmoryConverter
没必要 mock 而且 mock 的代价也不小。所以只 mock ArmoryFacade
,并且给 InventoryRecoveryStep
对象的声明加上 @InjectMocks
。
@InjectMocks
private InventoryRecoveryStep inventoryRecoveryStep;
@Mock
private ArmoryFacade armoryFacade;
但是运行后会发现 inventoryRecoveryStep
中的 armoryConverter
是 null。所以我希望在实例化 InventoryRecoveryStep
的时,注入 ArmoryFacade
mock 对象的同时 ,能把真实的 ArmoryConverter
实例也注入进去。但是 @InjectMocks
只会注入 mock 对象到 inventoryRecoveryStep
中。所以再声明一个 @Spy
修饰的 armoryConverter
就行了。
@Spy
private ArmoryConverter armoryConverter;
果然,运行后发现 armoryConverter
确实被注入到 inventoryRecoveryStep
中了,并且默认会调用 ArmoryConverter
真实的方法。但是新的问题来了,ArmoryConverter
还依赖了 ProductConverter
,并且 ProductConverter
还依赖了ProductEnumConverter
。
嵌套对象的初始化
没了 Spring 的帮助, @Autowired
当然会失效。那该如何完成这种嵌套对象的初始化呢?
inventoryRecoveryStep => armoryConverter => productConverter => productEnumConverter
Round 1 手动硬塞
debug了 @InjectMocks
修饰对象的初始化过程发现它是通过 mockito-core 里的工具类 FieldSetter
来完成 mock 对象注入的。所以可以在测试用例执行之前将它们硬塞进去。
@Spy
private ProductConverter productConverter;
@Spy
private ProductEnumConverter productEnumConverter;
@BeforeEach
public void InjectDependency() {
try {
FieldSetter.setField(armoryConverter,
ArmoryConverter.class.getDeclaredField("productConverter"),productConverter);
FieldSetter.setField(productConverter,
ProductConverter.class.getDeclaredField("productEnumConverter"), productEnumConverter);
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
这个方法虽然成功了,但方式有些生硬。并且加在 @BeforeAll
里,@Spy
修饰的对象还没初始化,行不通。 只能放在@BeforeEach
里面,可这些逻辑也没必要在每个测试用例之前执行一遍。
Round 2 @Spy & @InjectMocks 齐上阵
突然想到是不是可以给加了 @Spy
注解的对象再加一个 @InjectMocks
呢?
@InjectMocks
private InventoryRecoveryStep inventoryRecoveryStep;
@Spy
@InjectMocks
private ArmoryConverter armoryConverter;
@Spy
@InjectMocks
private ProductConverter productConverter;
@Spy
private ProductEnumConverter productEnumConverter;
运行后发现,@Spy
注解的效果被 @InjectMocks
抵消了。Debug 发现在 mock 实例初始化后,除了 productEnumConverter
其他的还都是 null
,所以在注入阶段也没 mock 对象可注入了?。
mock 对象初始化时间 < 真实对象初始化时间 < 将mock对象注入真实对象的时间
Round 3 @InjectMocks + spy()
那有什办法能在注入阶段之前就把部分需要注入的对象初始化呢?
突然想到 Mockito 还提供了静态方法 spy() 来初始化 spy 对象。所以可以在声明的时候就用 spy() 来初始化。并且加上 @InjectMocks
修饰。这样就能保证 mock 对象的初始化在注入阶段之前了。
@Mock
private ArmoryFacade armoryFacade;
@InjectMocks
private InventoryRecoveryStep inventoryRecoveryStep;
@InjectMocks
private ArmoryConverter armoryConverter = spy(ArmoryConverter.class);
@InjectMocks
private ProductConverter productConverter = spy(ProductConverter.class);
@Spy
private ProductEnumConverter productEnumConverter;
Debug 可以发现确实在注入阶段之前,mock 对象都已经初始化了。
![1 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/78d3c96ecbd2551549c393e0bae7e86e.png)
然后在注入阶段将各自依赖的注入进去,就达到了预期的效果。
![2 640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/114252b27503ac2b241b1fb32eda2e7e.png)
Reference:
https://www.baeldung.com/mockito-annotations
https://static.javadoc.io/org.mockito/mockito-core/2.9.0/org/mockito/Mockito.html
坚持&展望
目前的探索算是解决了前两个痛点。然后就需要我们坚持实践,不断发现问题不断探索改进。希望越来越多的人都能真切感受到单元测试带来的的好处,不再只是听听而已。
也许将来我们还可以将 Mock
和验证逻辑也放到 .yaml
文件中,实现真正的数据驱动的单元测试,甚至自动生成单元测试代码。
全文完
以下文章您可能也会感兴趣:
我们正在招聘 Java 工程师,欢迎有兴趣的同学投递简历到 rd-hr@xingren.com 。
![640?wx_fmt=png](https://img-blog.csdnimg.cn/img_convert/c44ac58b7298b93e37b1d81765ca9024.png)
杏仁技术站
长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。