问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

深入理解单元测试:技巧与最佳实践

创作时间:
作者:
@小白创作中心

深入理解单元测试:技巧与最佳实践

引用
1
来源
1.
https://crossoverjie.top/2024/08/15/ob/unit-test/

单元测试是软件开发中不可或缺的一环,它能帮助开发者确保代码的正确性和稳定性。本文将通过Apache HertzBeat项目的具体案例,深入讲解单元测试的技巧与最佳实践,包括简单函数测试、复杂场景模拟、测试覆盖率分析以及集成测试的实现方法。

什么情况下需要单元测试

对于一些功能单一、核心逻辑、同时变化不频繁的公开函数才有必要做单元测试。对于业务复杂、链路繁琐但也是核心流程的功能通常建议做 e2e 测试,这样可以保证最终测试结果的一致性。

具体案例

单测的主要目的是模拟执行你写过的每一行代码,目的就是要覆盖到主要分支,做到自己的每一行代码都心中有数。下面以Apache HertzBeat的一些单测为例,讲解如何编写一个单元测试。

先以一个最简单的org.apache.hertzbeat.collector.collect.udp.UdpCollectImpl#preCheck函数测试为例。这里的preCheck函数就是简单的检测做参数校验。测试时只要我们手动将metrics设置为null就可以进入这个 if 条件。

@ExtendWith(MockitoExtension.class)
class UdpCollectImplTest {
    @InjectMocks
    private UdpCollectImpl udpCollect;

    @Test
    void testPreCheck() {
        List<String> aliasField = new ArrayList<>();
        aliasField.add("responseTime");
        Metrics metrics = new Metrics();
        metrics.setAliasFields(aliasField);
        assertThrows(IllegalArgumentException.class, () -> udpCollect.preCheck(metrics));
    }
}

来看具体的单测代码,我们一行行的来看:

  • @ExtendWith(MockitoExtension.class)是JUnit5提供的一个注解,里面传入的MockitoExtension.class是我们单测 mock 常用的框架。简单来说就是告诉JUnit5,当前的测试类会使用 mockito 作为扩展运行,从而可以mock我们运行时的一些对象。

  • @InjectMocks也是mockito这个库提供的注解,通常用于声明需要测试的类。需要注意的是这个注解必须是一个具体的类,不可以是一个抽象类或者是接口。其实当我们了解了他的原理就能知道具体的原因:

当我们debug运行时会发现udpCollect对象是有值的,而如果我们去掉这个注解@InjectMocks再运行就会抛空指针异常。因为并没有初始化udpCollect而使用@InjectMocks注解后,mockito框架会自动给udpCollect注入一个代理对象;而如果是一个接口或者是抽象类,mockito框架是无法知道创建具体哪个对象。当然在这个简单场景下,我们直接udpCollect = new UdpCollectImpl()进行测试也是可以的。

配合 jacoco 输出单测覆盖率

在 IDEA 中我们可以以Coverage的方式运行,IDEA就会将我们的单测覆盖情况显示在源代码中,绿色的部分就代表在实际在运行时执行到的地方。我们也可以在maven项目中集成jacoco,只需要添加一个根目录的pom.xml中添加一个plugin就可以了。

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>${jacoco-maven-plugin.version}</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
    </executions>
</plugin>

之后运行mvn test就会在target目录下生成测试报告了。我们还可以在GitHub的CI中集成Codecov,他会直接读取jacoco的测试数据,并且在PR的评论区加上测试报告。

需要从Codecov里将你项目的token添加到repo的环境变量中即可。具体可以参考这个PR:https://github.com/apache/hertzbeat/pull/1985

复杂一点的单测

刚才展示的是一个非常简单的场景,下面来看看稍微复杂的。我们以这个单测为例:

@ExtendWith(MockitoExtension.class)
public class RedisClusterCollectImplTest {
    @InjectMocks
    private RedisCommonCollectImpl redisClusterCollect;
    @Mock
    private StatefulRedisClusterConnection<String, String> connection;
    @Mock
    private RedisAdvancedClusterCommands<String, String> cmd;
    @Mock
    private RedisClusterClient client;
}

这个单测在刚才的基础上多了一个@Mock的注解。这是因为我们需要测试的RedisCommonCollectImpl类中需要依赖StatefulRedisClusterConnection/RedisAdvancedClusterCommands/RedisClusterClient这几个类所提供的服务。单测的时候需要使用mockito创建一个他们的对象,并且注入到需要被测试的RedisCommonCollectImpl类中。不然我们就需要准备单测所需要的资源,比如可以使用的Redis、MySQL等。

模拟行为

只是注入进去还不够,我们还需要模拟它的行为:

  • 比如调用某个函数可以模拟返回数据
  • 模拟函数调用抛出异常
  • 模拟函数调用耗时

这里以最常见的模拟函数返回为例:

String clusterNodes = connection.sync().clusterInfo();

在源码里看到会使用connection的clusterInfo()函数返回集群信息。

String clusterKnownNodes = "2";
String clusterInfoTemp = """
    cluster_slots_fail:0
    cluster_known_nodes:%s
""";
String clusterInfo = String.format(clusterInfoTemp, clusterKnownNodes);
Mockito.when(cmd.clusterInfo()).thenReturn(clusterInfo);

此时我们就可以使用Mockito.when().thenReturn()来模拟这个函数的返回数据。而其中的cmd自然也是需要模拟返回的:

Mockito.mockStatic(RedisClusterClient.class).when(() -> RedisClusterClient.create(Mockito.any(ClientResources.class), Mockito.any(RedisURI.class))).thenReturn(client);
Mockito.when(client.connect()).thenReturn(connection);
Mockito.when(connection.sync()).thenReturn(cmd);
Mockito.when(cmd.info(metrics.getName())).thenReturn(info);
Mockito.when(cmd.clusterInfo()).thenReturn(clusterInfo);

cmd是通过Mockito.when(connection.sync()).thenReturn(cmd);返回的,而connection又是从client.connect()返回的。最终就像是套娃一样,client在源码中是通过一个静态函数创建的。

模拟静态函数

我依稀记得在我刚接触mockito的16~17年那段时间还不支持模拟调用静态函数,不过如今已经支持了:

@Mock
private RedisClusterClient client;
Mockito.mockStatic(RedisClusterClient.class).when(() -> RedisClusterClient.create(Mockito.any(ClientResources.class), Mockito.any(RedisURI.class))).thenReturn(client);

这样就可以模拟静态函数的返回值了,但前提是返回的client需要使用@Mock注解。

模拟构造函数

有时候我们也需要模拟构造函数,从而可以模拟后续这个对象的行为。

MockedConstruction<FTPClient> mocked = Mockito.mockConstruction(FTPClient.class, (ftpClient, context) -> {
    Mockito.doNothing().when(ftpClient).connect(ftpProtocol.getHost(), Integer.parseInt(ftpProtocol.getPort()));
    Mockito.doAnswer(invocationOnMock -> true).when(ftpClient).login(ftpProtocol.getUsername(), ftpProtocol.getPassword());
    Mockito.when(ftpClient.changeWorkingDirectory(ftpProtocol.getDirection())).thenReturn(isActive);
    Mockito.doNothing().when(ftpClient).disconnect();
});

可以使用Mockito.mockConstruction来进行模拟,该对象的一些行为就直接写在这个模拟函数内。需要注意的是返回的mocked对象需要记得关闭。

不需要 Mock

当然也不是所有的场景都需要mock。比如刚才第一个场景,没有依赖任何外部服务时就不需要mock。类似于这个PR里的测试,只是依赖一个基础的内存缓存组件,就没必要 mock,但如果依赖的是Redis缓存组件还是需要 mock 的。https://github.com/apache/hertzbeat/pull/2021

修改源码

如果有些测试场景下需要获取内部变量方便后续的测试,但是该测试类也没有提供获取变量的函数,我们就只有修改源码来配合测试了。比如这个PR:

当然如果只是给测试环境下使用的函数或变量,我们可以加上@VisibleForTesting注解标明一下,这个注解没有其他作用,可以让后续的维护者更清楚的知道这是做什么用的。

集成测试

单元测试只能测试一些功能单一的函数,要保证整个软件的质量仅依赖单测是不够的,我们还需要集成测试。通常是需要对外提供服务的开源项目都需要集成测试:

  • Pulsar
  • Kafka
  • Dubbo 等

以我接触到的服务型应用主要分为两类:一个是Java应用一个是Golang应用。

Golang

Golang因为工具链没有Java那么强大,所以大部分的集成测试的功能都是通过编写Makefile和shell脚本实现的。还是以我熟悉的Pulsar的go-client为例,它在GitHub的集成测试是通过GitHub action触发的,定义如下:

最终调用的是Makefile中的test命令,并且把需要测试的Golang版本传入进去。

Dockerfile:

这个镜像简单来说就是将Pulsar的镜像作为基础运行镜像(这里面包含了Pulsar的服务端),然后将这个pulsar-client-go的代码复制进去编译。接着运行:

cd /pulsar/pulsar-client-go && ./scripts/run-ci.sh

也就是测试脚本。测试脚本的逻辑也很简单:

  • 启动pulsar服务端
  • 运行测试代码

因为所有的测试代码里连接服务端的地址都是localhost,所以可以直接连接。

通过这里的action日志可以跟踪所有的运行情况。

Java

Java因为工具链强大,所以集成测试几乎不需要用Makefile和脚本配合执行。还是以Pulsar为例,它的集成测试是需要模拟在本地启动一个服务端(因为Pulsar的服务端源码和测试代码都是Java写的,更方便做测试),然后再运行测试代码。这个的好处是任何一个单测都可以在本地直接运行,而Go的代码还需要先在本地启动一个服务端,测试起来比较麻烦。

来看看它是如何实现的,我以其中一个BrokerClientIntegrationTest为例:

会在单测启动的时候先启动服务端。最终会调用PulsarTestContextbuild函数启动broker(服务端),而执行单测也只需要使用mvn test就可以自动触发这些单元测试。只是每一个单测都需要启停服务端,所以要把Pulsar的所有单测跑完通常需要1~2个小时。

以上就是日常编写单测可能会碰到的场景,希望对大家有所帮助。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号