单元测试的实践之路

作者 | 任旭东    


640?wx_fmt=jpeg

杏仁后端攻城狮,关注服务端技术和敏捷开发。

单元测试的实践之路

祸乱生与疏忽,单元测试先于交付。

穿越暂时黑暗的时光隧道,才能迎来系统的曙光。

——《码出高效》

认识

知道测试金字塔模型的人都知道,单元测试位于最底层,也是最重要的一层。

单元测试的测试粒度最小,但对发现问题的帮助却最大。

640?wx_fmt=png
单元测试的基本原则——AIR
  • Automatic 自动化

  • Independent 独立性

  • Repeatable 可重复

单元测试之于项目,就像大气层之于地球一样,它影响不到线上的业务逻辑,但却可以保证线上代码的质量。

单元测试的好处
  • 提升代码质量

  • 促进代码优化

  • 提升研发效率

  • 增加重构信心

单元测试有点像早睡早起,大家都听说过它的各种好处,但做到的却很少。

实践单元测试的痛点
  • 测试数据准备繁琐

  • 依赖关系复杂

  • 没有养成习惯

  • 代码不可测

  • 认识不足

探索

参数化单元测试(JUnit 5 Parameterized Tests)

对于开发而言,准备测试数据是写单元测试最大的痛点。Parameterized Tests 的出现为我们提供了新的选择。

常规的 UT 通常都包含下面的三个步骤:

  1. 测试数据准备

  2. 调用目标方法

  3. 验证执行结果

640?wx_fmt=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(131211), buildOrderByIdCursorable(),
                  contentNotEmpty.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
        /* 3.第一页,刚好一页
        * |15, 14, 13, 12, 11| */

        arguments(TestData.createListById(1514131211),
                  buildOrderByIdCursorable(),
                  fullContent.andThen(hasNotNext).andThen(nextCursorIsNull).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
        /* 4.第一页,有下一页
        * |15, 14, 13, 12, 11|10, 9... */

        arguments(TestData.createListById(151413121110), 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(1098765), 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(321), 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(54321), 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(11109876), buildOrderByIdCursorableWithBefore(5L),
                  fullContent.andThen(hasNext).andThen(nextCursorIs(6)).andThen(hasPrevious).andThen(previousCursorIs(10))),
        /* 9.点击上一页后,第一页数据未满(刚有数据被删的时候)
        * |15, 14, 12, 11|10, 9... */

        arguments(TestData.createListById(15141211), buildOrderByIdCursorableWithBefore(10L),
                  contentNotEmpty.andThen(hasNext).andThen(nextCursorIs(11)).andThen(hasNotPrevious).andThen(previousCursorIsNull)),
        /* 10.点击上一页后,第一页数据刚满
        * |15, 14, 13, 12, 11|10, 9... */

        arguments(TestData.createListById(1514131211), 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<Timplements ArgumentsProviderAnnotationConsumer<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 对象都已经初始化了。

640?wx_fmt=png
1

然后在注入阶段将各自依赖的注入进去,就达到了预期的效果。

640?wx_fmt=png
2

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


杏仁技术站

长按左侧二维码关注我们,这里有一群热血青年期待着与您相会。



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值