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

简介: 本文作者详细讲解了关于单元测试的相关知识,做好单元测试能有效地保障代码质量,本文将手把手教你学会应用单元测试并附有案例、测试插件。

一、什么是单元测试


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


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


二、为什么要写单元测试


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


2.1 减少BUG,释放资源

image.png

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


  • 85%的缺陷都在代码设计阶段产生;
  • 发现bug的阶段越靠后,耗费成本就越高,呈指数级别的增长。

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


2.2 为代码重构保驾护航


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


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


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


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


2.3 既是编写单测也是CodeReview


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


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


2.4 便于调试与验证


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


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


2.5 驱动设计与重构


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


三、怎样编写单元测试


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_{给定场景}

举个例子:


@Test
public void should_returnFalse_when_deleteContent_given_invokeFailed() {
    ...
}

反例:


@Test
public void testDeleteContent() {
    ...
}


3.2.2.2 单测方法实现分层

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

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

举个例子:

@Test
public 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方法来进行模拟对象的注入,这里介绍了较为简便的方式。


3.4.2 Mock返回值

3.4.2.1 Mock无返回值方法


doNothing().when(contentService.deleteContent(anyLong()));

3.4.2.2 Mock方法返回值


// given
Result<Boolean> deleteResult = new Result<>(Boolean.FALSE);
when(contentService.deleteContent(anyLong())).thenReturn(deleteResult);

3.4.2.3 执行方法的真实调用


when(contentService.deleteContent(anyLong())).thenCallRealMethod();

3.4.2.4 Mock方法调用异常


when(contentService.deleteContent(anyLong())).thenThrow(NullPointerException.class);

3.4.3 自动化验证

3.4.3.1 验证依赖方法的调用


// 验证调用方法的入参,指定为"testTagId"
verify(tagOrmService).queryByValue("testTagId");

// 验证queryByValue方法被调用了2次
verify(tagOrmService, times(2)).queryByValue(anyString());

3.4.3.2 验证返回值

对验证方法的返回值或异常进行验证


// then
Assert.assertNotNull(result);
Assert.assertEquals(result.getResponseCode(), 200);

// 其他常用的断言函数
Assert.assertTrue(...); 
Assert.assertFalse(...);
Assert.assertSame(...);  
Assert.assertEquals(...);  
Assert.assertArrayEquals(...);


3.4.4 其他单测技巧处理

3.4.4.1 使用Mockito模拟静态方法


MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("tag");


3.4.4.2 处理Mockito注册静态方法范围

在执行mvn test时,如果有多个测试方法mock了Mockito.mockStatic(TagHandler.class),会报错,因为静态方法是类级别的,会出现注册多次的情况。可以参考下面两种解法:


1、使用@BeforeClass@AfterClass

@BeforeClass注解方法:只被执行一次;运行junit测试类时第一个被执行的方法

@AfterClass注解方法:只被执行一次;运行junit测试类时最后一个被执行的方法

示例:


@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {

    @Mock
    DocManageService docManageService;

    @InjectMocks
    ContentService contentService;

    private static MockedStatic<TagHandler> tagHandlerMockedStatic = null;

    @BeforeClass
    public static void beforeTest() {
        tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class);
        tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");
    }

    // 省略测试方法

    @AfterClass
    public static void afterTest() {
        tagHandlerMockedStatic.close();
    }

}

2、try-with-resources构造中定义模拟


@RunWith(MockitoJUnitRunner.class)
public class ContentServiceTest {

    @Mock
    DocManageService docManageService;

    @InjectMocks
    ContentService contentService;

    @Test
    public void should_returnEmptyList_when_queryContentTags_given_invokeParams() throws Exception {
        try (MockedStatic<TagHandler> tagHandlerMockedStatic = Mockito.mockStatic(TagHandler.class)) {
            tagHandlerMockedStatic.when(() -> TagHandler.getSingleCommonTag(anyString())).thenReturn("testTag");

            // 省略单测方法具体实现
            ...
        }
    }

}

3.4.4.3 如何mock一条链式调用


public T select(QueryCondition queryCondition) throws Exception {
    LindormQueryParam params = queryCondition.generateQueryParams();
    if (Objects.isNull(params)) {
        LOGGER.error("Invalid query condition:{}", queryCondition.toString());
        return null;
    }

    Select select = tableService.select()
            .from(params.getTableName())
            .where(params.getCondition())
            .limit(1);
    QueryResults results = select.execute();
    return convert(results.next());
}

Mockito提供了形如

tableService.select().from(params.getTableName()).where(params.getCondition()).limit(1)

链式调用解决办法,mock对象的时候增加参数RETURNS_DEEP_STUBS


@Test
public void should_returnNull_when_select_given_invalidQueryCondition() throws Exception {
    // when
    TableService tableService = mock(TableService.class, RETURNS_DEEP_STUBS);
    when(tableService.select().from(anyString()).where(any()).limit(anyInt())).thenReturn(null);
    Object result = lindormClient.select(new QueryCondition());
            
    // then
    Assert.isNull(result);
}


3.5 单测生成插件

IDEA有两款比较好用的单测自动生成插件TestMe[1]Diffblue[2],这里主要介绍TestMe,如果大家有比较好的插件也可以推荐。

1、安装:在IDEA设置中的Plguins插件里搜索TestMe,下载安装即可。

2、使用:在code按钮找到入口,或者直接使用快捷键option+shift+Q

image.png

3、生成的代码如下

image.png

自动生成插件方便初始化部分代码,可以提升单测编写的效率,但是也存在局限性:单测名称规范、具体实现等还是需要我们完善、补充后才能正常使用。


四、如何落地单元测试


4.1 清晰单测的价值认知


不难发现,公司内的项目还是外网开源项目,少有工程具备完善、高质量的单元测试。上文讲了为什么要写单测,这里就不再赘述了。短期来看,单测无疑会带来开发工作量和开发时长的增加,但是我们要从整个迭代周期来看单测的优势。从最终的效果来看,坚持单元测试会有效的减少迭代中的缺陷数以及缩短需求的交付周期。


4.2 将单测纳入流程规范

4.2.1 将单元测试纳入CR标准

以往我们CR只关注核心的业务代码,大多数情况下,我们在评审中可以指出代码较为明显的缺陷或者不合理的设计,但是各种条件case、边界及异常情况很难通过肉眼review出来。如果提交的CR中包含完善、高质量的单元测试,提交、评审双方的的信心都会增强。


4.2.2 发布管控

当我们提交代码后,CI可以设置运行该分支的单元测试。在发布流程中,添加单测相关的管控,比如单元测试通过率以及单元测试增量覆盖率等

image.png

4.3 单测工作量评估


对于单元测试工作量的评估,没有一个固定的标准,主要视业务逻辑复杂度而定。一般来说,如果之前没有编写过单元测试,在熟悉阶段可以根据需求的工作量对应增加20%~30%;后期熟练掌握后,增加需求工作量的10%就足够了当业务需求涉及的case较多,单测需要覆盖这些必要流程时,我们评估工作量时,可以给自己加些时间来保障高质量的单测。


五、后记


单元测试是一件知易行难的事情,公司也在积极宣导和建设单测文化。工作方式的改变其实难度并不大,难的是能够建立一致的共识,并从心底认可单元测试的价值,只有这样才能有效落地。


参考链接:

[1]https://plugins.jetbrains.com/plugin/9471-testme

[2]https://plugins.jetbrains.com/plugin/14946-diffblue-cover--create-complete-junit-tests-with-ai


作者 |  张存曦(曳风)

来源 | 阿里云开发者公众号

相关文章
|
1月前
|
测试技术
软件测试的艺术:探索式测试的实践与思考
在软件开发的广阔海洋中,测试是确保航船稳健行驶的关键。本文将带你领略探索式测试的魅力,一种结合创造性思维和严格方法论的测试方式。我们将一起揭开探索式测试的神秘面纱,了解其核心概念、实施步骤和带来的效益。通过实际代码示例,你将学会如何将探索式测试融入日常的软件质量保证流程中,提升测试效率与质量。
|
23天前
|
数据采集 监控 机器人
浅谈网页端IM技术及相关测试方法实践(包括WebSocket性能测试)
最开始转转的客服系统体系如IM、工单以及机器人等都是使用第三方的产品。但第三方产品对于转转的业务,以及客服的效率等都产生了诸多限制,所以我们决定自研替换第三方系统。下面主要分享一下网页端IM技术及相关测试方法,我们先从了解IM系统和WebSocket开始。
41 4
|
25天前
|
人工智能 JavaScript 前端开发
自动化测试框架的演进与实践###
本文深入探讨了自动化测试框架从诞生至今的发展历程,重点分析了当前主流框架的优势与局限性,并结合实际案例,阐述了如何根据项目需求选择合适的自动化测试策略。文章还展望了未来自动化测试领域的技术趋势,为读者提供了宝贵的实践经验和前瞻性思考。 ###
|
23天前
|
测试技术 Python
探索软件测试的深度与广度:从理论到实践
在数字化时代,软件已成为我们生活中不可或缺的一部分。随着技术的不断进步和用户需求的多样化,确保软件质量变得尤为重要。本文将深入浅出地介绍软件测试的核心概念、类型及其在软件开发生命周期中的重要性。我们将通过实际案例,展示如何实施有效的测试策略,并探讨自动化测试的未来趋势,旨在为读者提供一套完整的软件测试知识体系,帮助提升软件质量和开发效率。
|
24天前
|
测试技术 Python
探索软件测试的奥秘:从理论到实践
在软件开发的宇宙中,软件测试犹如一颗璀璨的星辰,指引着质量的方向。本文将带你穿梭于软件测试的理论与实践之间,揭示其内在的逻辑和魅力。从测试的重要性出发,我们将探讨不同类型的测试方法,并通过实际案例分析,深入理解测试用例的设计和应用。最后,我们将通过一个代码示例,展示如何将理论知识转化为实际操作,确保软件质量的同时,也提升你的测试技能。让我们一起踏上这段探索之旅,发现软件测试的无限可能。
|
27天前
|
jenkins 测试技术 持续交付
自动化测试框架的搭建与实践
在软件开发领域,自动化测试是提升开发效率、确保软件质量的关键手段。本文将引导读者理解自动化测试的重要性,并介绍如何搭建一个基本的自动化测试框架。通过具体示例和步骤,我们将探索如何有效实施自动化测试策略,以实现软件开发流程的优化。
54 7
|
26天前
|
测试技术
探索软件测试的奥秘:从理论到实践
本文深入探讨了软件测试的基本概念、重要性、主要类型以及实施策略。通过分析不同测试阶段和相应的测试方法,文章旨在为读者提供一套完整的软件测试知识体系,帮助他们更好地理解和应用测试技术,确保软件产品的质量和可靠性。
51 4
|
1月前
|
机器学习/深度学习 人工智能 自然语言处理
智能化软件测试:AI驱动的自动化测试策略与实践####
本文深入探讨了人工智能(AI)在软件测试领域的创新应用,通过分析AI技术如何优化测试流程、提升测试效率及质量,阐述了智能化软件测试的核心价值。文章首先概述了传统软件测试面临的挑战,随后详细介绍了AI驱动的自动化测试工具与框架,包括自然语言处理(NLP)、机器学习(ML)算法在缺陷预测、测试用例生成及自动化回归测试中的应用实例。最后,文章展望了智能化软件测试的未来发展趋势,强调了持续学习与适应能力对于保持测试策略有效性的重要性。 ####
|
1月前
|
敏捷开发 Devops 测试技术
探索自动化测试之美:从理论到实践
在软件开发的海洋中,自动化测试犹如一座灯塔,指引着项目向着质量和效率的彼岸。本文将扬帆起航,从自动化测试的意义出发,穿越工具选择的海域,停靠在实战演练的岛屿,最终抵达持续集成的港湾。我们将通过一个具体的代码示例,体验自动化测试的魅力,并分享如何将这些实践应用到日常的软件质量保证过程中。
|
29天前
|
存储 算法 C语言
用C语言开发游戏的实践过程,包括选择游戏类型、设计游戏框架、实现图形界面、游戏逻辑、调整游戏难度、添加音效音乐、性能优化、测试调试等内容
本文探讨了用C语言开发游戏的实践过程,包括选择游戏类型、设计游戏框架、实现图形界面、游戏逻辑、调整游戏难度、添加音效音乐、性能优化、测试调试等内容,旨在为开发者提供全面的指导和灵感。
46 2