5个编写技巧,有效提高单元测试实践

1. 什么是单元测试

“在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。”

摘录来自维基百科

单元测试(Unit Testing)顾名思义就是测试一个单元,这里的单元通常指一个函数或类,区别于集成测试中的模块和系统。集成测试的测试过程通常存在跨系统模块的调用,是一种端到端的测试;而单元测试关注对象的颗粒度较小,用来保障一个类或者函数是否按照预期正确的执行。

2. 为什么要写单元测试

作为保障代码质量的有效手段之一,公司也在积极的推进单元测试。结合单测的实践,总结了以下几点单元测试的好处,认真实践过的同学,应该会有共鸣。

2.1 减少BUG,释放资源

上面这张图,旨在说明两个问题:

  • 85%的缺陷都在代码设计阶段产生;
  • 发现bug的阶段越靠后,耗费成本就越高,呈指数级别的增长。
  • 单元测试是所有测试环节中最底层的一类测试,是第一个环节,也是最重要的一个环节。大多数缺陷是Coding阶段引入,修复的成本随着软件生命周期进展不断上升。日常研发中,在交付测试前我们对功能单元进行主流程、各种边界及异常单元测试的编写,能有效帮助我们发现代码中的缺陷。相对于后期来自测试同学或者线上异常反馈,再来进行排查定位、修复发布的成本来说,单元测试的性价比是极高的。单元测试可以有效地保障代码质量,给我们带来质量口碑的同时,也为他人和自己减少因修复低级BUG而投入的时间,能够将精力分配到其他更有意义的事情上。

    2.2 为代码重构保驾护航

    面对项目中历史遗留的腐化代码,我们都有推倒重来的冲动,但它毕竟经过了长时间的稳定性考验,我们又担心重构之后出现问题。这是我们经常会遇到的境况,当要重构不是非常熟悉的祖传代码,又没有充足的测试资源保障的时候,重构引入缺陷的风险还是很大的。

    那如何保证重构不出错呢?Martin Fowler在《重构:改善既有代码的设计》提到:

    重构是很有价值的工具,但只有重构还不行。要正确地进行重构,前提是得有一套稳固的测试集合,以帮我发现难以避免的疏漏。即便有工具可以帮我自动完成一些重构,很多重构手法依然需要通过测试集合来保障。

    除了需要对业务流程有足够的了解并且熟练掌握各种设计思想、模式之外,单元测试是保证重构不出错的有效手段。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有正确的逻辑未被破坏,原有的外部可见行为没有发生改变。单元测试给了我们重构的信心与底气。

    2.3 既是编写单测也是CodeReview

    单元测试和CR是保障代码质量行之有效的两个手段。在研发交付过程中,通常我们提交CR的时机较为滞后,评审同学指出待优化或修复的时间点也较晚,修复的风险和成本上都有所增加。

    我们编写编码单元测试过程,其实也是自我CodeReview的过程。在这个过程中,我们对功能单元主流程、边界及异常进行测试,也在自我审视代码的规范、逻辑及设计。既提高了后续提交CR的质量与评审效率,也将问题提前暴露。

    2.4 便于调试与验证

    当项目存在多个协同方时,我们只需按照约定mock出依赖项的数据,无需等所有依赖的应用接口开发部署完成后再进行调试,提高了我们协同的效率与质量。我们将功能需求进行拆解,在开发完每一个小功能点时,即可进行单元测试的编写与验证,这种习惯能让我们对编码得到快速的验证反馈;同时,在开发完整个功能时,我们需要跑一遍项目所有的单测用例,可以清晰的感知,本次整个功能需求的改动是否对已有业务case造成影响。

    如果我们能够保障每个类、函数都能通过单元测试按照预期业务逻辑执行,那整合后的功能模块或系统,出问题的概率都能大大降低。从这个意义上讲,单元测试也对集成测试、系统测试做了有力的支撑。

    2.5 驱动设计与重构

    设计和编码的时候,我们很难将所有的问题都想清楚。那我们知道,评判代码质量重要的的标准之一就是代码的可测性。如果对一段代码进行单测,发现难于编写,需要编写的case非常多,或者当前的测试框架无法mock依赖对象,需要依赖其他具备高级特性的测试框架时,我们需要回过头来审视代码,是否编码设计得不合理,导致代码的可测性不高。这是个正反馈的过程,让我们有针对性的进行重新设计与重构。

    3. 怎样编写单元测试

    3.1 单元测试框架的构建

    3.1.1 单元测试框架JUnit

    JUnit是目前Java语言应用最为广泛的单元测试框架,用于编写和运行可重复的自动化测试,它包含以下特性:

  • 用于测试期望结果的断言(Assertion)
  • 用于共享共同测试数据的测试工具
  • 用于方便的组织和运行测试的测试套件
  • 图形和文本的测试运行器
  • 多数Java的开发环境都已经集成了JUnit作为单元测试的工具,开源框架对JUnit 都有相应的支持

    3.1.2 单元测试Mock框架

    项目中依赖关系往往往非常复杂,单元测试Mock框架做的事就是模拟被测试类的依赖项,提供预期的行为和状态,使得我们的单测可以聚焦在被测试类本身,而不必受到依赖项的复杂度的影响。

    这里我们讨论常用的Mockito与PowerMock,两者都是作为单元测试模拟框架,模拟应用中复杂的依赖对象。Mockito基于动态代理的方式实现,PowerMock在Mockito基础上增加了类加载器以及字节码篡改技术,使其可以实现完成对private/static/final方法的Mock。

    公司使用JaCoCo来做单元覆盖率的检测,当我们使用支持字节码篡改的mock工具的时候,可能会造成:

  • 测试失败,mock工具与jacoco同时修改字节码时引入的冲突
  • 某些类的覆盖率为0
  • 所以我们推荐使用Mockito来作为我们的单元测试Mock框架,原因有二:

    1. 在版本3.4.0以后,Mockito支持静态方法的mock。并且作为SpringBootTest默认集成的Mock工具,所以建议大家使用高版本的Mockito,并通过它来完成静态方法的Mock
    2. 不提倡使用PowerMock,并不是一味追求单测覆盖率,而是当我们需要使用到具备高级特性mock工具时,我们需要审视代码的合理性,并尝试进行优化重构,使其具备较好的可测性

    3.1.3 依赖引入

    3.1.3.1 添加JUnit的maven依赖

  • Springboot项目
  • <dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-test</artifactId>    <scope>test</scope></dependency>
  • SpringMVC项目
  • <dependency>    <groupId>junit</groupId>    <artifactId>junit</artifactId>    <version>4.12</version>    <scope>test</scope></dependency>

    3.1.3.2 单测Mock框架的引入

    <dependency>    <groupId>org.mockito</groupId>    <artifactId>mockito-core</artifactId>    <version>4.7.0</version>    <scope>test</scope></dependency><dependency>    <groupId>org.mockito</groupId>    <artifactId>mockito-inline</artifactId>    <version>4.7.0</version>    <scope>test</scope></dependency>

    3.2 单测方法的命名

    3.2.1 单元测试类的规范

  • 单元测试类需要放在工程的test目录下,比如xxx/src/test/java
  • 单测类的命名按照规范,应以被测类名开头,并追加Test作为结尾,比如ContentService -> ContentServiceTest
  • 3.2.2 单元测试方法规范

    3.2.2.1 测试方法的命名

    好的单元测试方法名,能让我们快速知道测试的场景、意图及验证的预期。

    建议采用should_{预期结果}_when_{被测方法}_given_{给定场景}

    举个

    @Testpublic void should_returnFalse_when_deleteContent_given_invokeFailed() {    ...}

    反例

    @Testpublic void testDeleteContent() {    ...}

    3.2.2.2 单测方法实现分层

    单测方法的实现如果分层清晰,能让代码便于理解,一目了然,同时也能提高后续的CR的效率

    这里我们建议采用given-when-then的三段落结构

    举个

    @Testpublic void should_returnFalse_when_deleteContent_given_invokeFailed() {    // given    Result<Boolean> deleteDocResult = new Result<>();    deleteDocResult.setEntity(Boolean.FALSE);    when(docManageService.deleteContentDoc(anyLong())).thenReturn(deleteDocResult);    when(docManageService.queryContentDoc(anyLong())).thenReturn(new DocEntity());    // when    Long contentId = 123L;    Boolean result = contentService.deleteContent(contentId);    // then    verify(docManageService, times(1)).queryContentDoc(contentId);    verify(docManageService, times(1)).deleteContentDoc(contentId);    Assert.assertFalse(result);}

    3.3 单测方法的示例

    3.3.1 代码案例

    public class SnsFeedsShareServiceImpl {    private SnsFeedsShareHandler snsFeedsShareHandler;    @Autowired    public void setSnsFeedsShareHandler(SnsFeedsShareHandler snsFeedsShareHandler) {        this.snsFeedsShareHandler = snsFeedsShareHandler;    }    public Result<Boolean> shareFeeds(Long feedsId, String platform, List<String> snsAccountList) {        if (!validateParams(feedsId, platform, snsAccountList)) {            return ResponseBuilder.paramError();        }        try {            Result<Boolean> snsResult = snsFeedsShareHandler.batchShareFeeds(feedsId, platform, snsAccountList);            if (Objects.isNull(snsResult) || !snsResult.isSuccess() || Objects.isNull(snsResult.getModel())) {                return ResponseBuilder.buildError(ResponseEnum.SNS_SHARE_SERVICE_ERROR);            }            return ResponseBuilder.successResult(snsResult.getModel());        } catch (Exception e) {            LOGGER.error("shareFeeds error, feedsId:{}, platform:{}, snsAccountList:{}",                    feedsId, platform, JSON.toJSONString(snsAccountList), e);            return ResponseBuilder.systemError();        }    }    // 省略代码...}

    3.3.2 单元测试代码案例

    @RunWith(MockitoJUnitRunner.class)public class SnsFeedsShareServiceImplTest {    @Mock    SnsFeedsShareHandler snsFeedsShareHandler;    @InjectMocks    SnsFeedsShareServiceImpl snsFeedsShareServiceImpl;    @Test    public void should_returnServiceError_when_shareFeeds_given_invokeFailed() {        // given        Result<Boolean> invokeResult = new Result<>();        invokeResult.setSuccess(Boolean.FALSE);        invokeResult.setModel(Boolean.FALSE);        when(snsFeedsShareHandler.batchShareFeeds(anyLong(), anyString(), anyList())).thenReturn(invokeResult);        // when        Long feedsId = 123L;        String platform = "TEST_SNS_PLATFORM";        List<String> snsAccountList = Collections.singletonList("TEST_SNS_ACCOUNT");        Result<List<String>> result = snsFeedsShareServiceImpl.shareFeeds(feedsId, platform, snsAccountList);        // then        verify(snsFeedsShareHandler, times(1)).batchShareFeeds(feedsId, platform, snsAccountList);        Assert.assertNotNull(result);        Assert.assertEquals(result.getResponseCode(), ResponseEnum.SNS_SHARE_SERVICE_ERROR.getResponseCode());    }}

    3.4 单测的编码技巧

    3.4.1 Mock依赖对象

    @RunWith(MockitoJUnitRunner.class)public class ContentServiceTest {    @Mock    DocManageService docManageService;    @InjectMocks    ContentService contentService;    ...}
  • MockitoJUnitRunner使Mockito的注解生效或者使用初始化方法MockitoAnnotations.initMocks(this)
  • 利用@Mock模拟各种依赖对象
  • 使用@InjectMocks将mock出的依赖对象注入到目标测试对象中。以上述代码为例,单测中将docManageService注入到contentService
  • 当然我们也可以使用直接初始化或者@Spy的方式来模拟对象,然后使用Setter方法来进行模拟对象的注入,这里介绍了较为简便的方式。

    点击查看原文,获取更多福利!

    https://developer.aliyun.com/article/1081898?utm_content=g_1000365270

    声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

    上一篇 2022年10月28日
    下一篇 2022年10月28日

    相关推荐