怎么写好单元测试——个人实践

2023年 8月 18日 114.2k 0

前言

作为一个开发,我相信大部分人应该都写过单元测试,单元测试的好处我就不再多说了,提高代码质量、增加代码的可维护性、提升效率,减少测试成本等等。但是怎么样的单元测试才是一个比较好的,或者说有效的单元测试呢,下面我分享几点我写单元测试的一些经验。

首先我们写单元测试的一个比较核心的需求是,验证代码逻辑的正确性。不管是新增逻辑,亦或是原有的代码变更,都希望可以通过单元测试可以帮助我们提前发现问题。假设我们开发了一个登录模块,那我们可能的单元测试可能是这样的:

@SpringBootTest
public class LoginTest {
    @Resource
    private AuthService authService;
    
    @Test
    public void login() {
        String username = "testUser";
        String password = "********";
        UserToken token = authService.login(username, password);
        System.out.println(token);
    }
}

命名

每个测试用例,我们应该给它定义一个有意义的命名,这样便于我们后期维护,在进行方法命名时,我们可以通过输入、动作、输出 的规则来定义,这样可以通过方法名,明确的知道这个单元测试需要什么输入,进行了什么操作,应该输出怎么样的结果,比如:

@Test
    public void givenUserNameAndPassword_whenLogin_returnToken() {
        String username = "testUser";
        String password = "********";
        UserToken userToken = authService.login(username, password);
        System.out.println(userToken);
    }

这部分参考BDD(Behavior-driven development,行为驱动开发)的定义,由关键字Given,When,Then,And,But(steps)来定义某一个场景的具体步骤。

结果校验

每个单元测试,理论上都应该有一个期望值,那么我们就需要校验实际结果和期望值,通过Assert(断言)来校验我们的测试结果,而不是只是打印结果。

@Test
    public void givenUserNameAndPassword_whenLogin_returnToken() {
        String username = "testUser";
        String password = "********";
        UserToken userToken = authService.login(username, password);
        System.out.println(userToken);
        String expectUserId = "001";
        String realUserId = userToken.getUserId();
        Assertions.assertNotNull(userToken.getToken(), "登录token不应该为空");
        Assertions.assertEquals(expectUserId, realUserId, "登录用户ID不正确");
    }

在对比结果时,最好可以使用有意义的变量名来对比,增加代码的可读性。

题外话:我在不同的公司时几乎都见到过单元测试中使用打印的方式来验证结果,即使错误,单元测试也可以正常运行,全靠人眼识别。

用例粒度

我们每个单元测试,除了集成测试之外,功能的粒度尽可能的小,即每个功能点一个用例,不要在一个测试用例中,堆了一大堆功能,这样做的好处是什么? 当我们某个功能点出现问题的时候,可以快速的定位到对应的异常功能,而不需要在一个超级大的用例中去排查到底是哪个功能出了问题。

一个单元测试用例最好只对应一个特定的场景

主流程集成测试

除了上面我们所说的功能点用例外,我们需要针对我们的产品中的核心流程、主要流程进行场景化的测试,即我们需要模拟用户的实际使用情况,来编排测试用例。因为即使我们每个功能点都没有问题,但并不意味着把它们整合在一起就没有问题。

@Test
    public void givenUserInfo_whenRegisterAndLogin_returnToken() {
        //注册
        User user = User.mock();
        User created = userService.register(user);
        Assertions.assertNotNull(created, "注册用户失败");
        String username = created.getUsername();
        String password = created.getPassword();
        //登录
        UserToken userToken = authService.login(username, password);
        String expectUserId = created.getId();
        String realUserId = userToken.getUserId();
        Assertions.assertNotNull(userToken.getToken(), "登录token不应该为空");
        Assertions.assertEquals(expectUserId, realUserId, "登录用户ID不正确");
    }

为什么只考虑核心流程和主要流程? 因为这种场景化的单测太难写了,我个人来说能写个核心流程与主流程已经算是比较勤勉了。

可重复运行

单元测试,如果它们不可以重复运行,那么测试结果可能不准确,并且如果每次运行都要手动去修改数据,或者准备环境,我相信没几个人可以常态化利用起来,十分低效,因此我们在写单元测试时,需要保证它们可重复运行。

@Sql(
        scripts = "create-data.sql",
        config = @SqlConfig(transactionMode = ISOLATED)
)
@SpringBootTest
public class LoginTest {
    @Resource
    private AuthService authService;
    @Resource
    private UserService userService;
    @Container
    public MySQLContainer mysqlContainer = new MySQLContainer(DockerImageName.parse("mysql:8.0-debian")).withExposedPorts(3306);
    /**
     * 覆盖数据库配置
     */
    @DynamicPropertySource
    public void dynamicProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", () -> mysqlContainer.getJdbcUrl());
        registry.add("spring.datasource.driverClassName", () -> mysqlContainer.getDriverClassName());
        registry.add("spring.datasource.username", () -> mysqlContainer.getUsername());
        registry.add("spring.datasource.password", () -> mysqlContainer.getPassword());
    }

    @Test
    public void givenUserInfo_whenRegisterAndLogin_returnToken() {
        //注册
        User user = User.mock();
        User created = userService.register(user);
        Assertions.assertNotNull(created, "注册用户失败");
        String username = created.getUsername();
        String password = created.getPassword();
        //登录
        UserToken userToken = authService.login(username, password);
        String expectUserId = created.getId();
        String realUserId = userToken.getUserId();
        Assertions.assertNotNull(userToken.getToken(), "登录token不应该为空");
        Assertions.assertEquals(expectUserId, realUserId, "登录用户ID不正确");
    }
}

我们通过隔离的数据环境,以及mock依赖服务等手段,保证每次操作都是独立的,即同样的输入在代码没有问题的情况上,理论上都会获取到同样的结果。

数据隔离相关在之前的《数据隔离篇》有分享。mock数据我们可以依赖一些三方库,比如Mockito, EasyMock等等。

覆盖率

单元测试的覆盖率当然是越高越好,如果能做到100%,那简直太完美了,但是这个想法也是太理想了。 这个可以根据每个人的项目实际情况来决定,但是最少要达到70%效果才会比较明显。覆盖可以通过一些三方框架来检测,比如JaCoCo

最后,跑起来

我们做了这么多的工作,最终也只有跑起来才能够看到效果,所以在maven打包的时候,不要在maven.test.skip=true了,让测试跑起来。如果可以的话,把单元测试加到我们的CI/CD流程中,只有常态化的用起来,才可以真正的产生效果。

结论

上面这些只是我在进行代码质量实践时的一些经验,并不适用于所有人或者场景,但是不管我们可以做到多少,怎么做,只要开始写单元测试了,那么我们的代码质量就一定会没写时更高。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论