总览

  1. 什么是单元测试
  2. 为什么要做单元测试
  3. 关于单元测试的一些误解
  4. 有哪些主流的单元测试框架
  5. 各个框架示例赏析
  6. 各个框架对比
  7. 如何进行单元测试
  8. 问题思考

一,什么是单元测试

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

单元测试和集成测试的区别

单元测试和集成测试使用的测试框架和工具大部分是相同的。

首先需要达成一致的是,无论是单元测试还是集成测试,它们都是自动化测试。为了更好地区分,我们可以这样理解:和生产代码以及单元测试代码在同一个代码仓库中,由开发同学自己编写的,对外部环境(数据库、文件系统、外部系统、消息队列等)有真实调用的测试就是集成测试。

下表中也从各种角度来对比了单元测试、集成测试和系统级别测试(包括端到端测试、链路测试、自动化回归测试、UI测试等)的区别。

单元测试集成测试系统级别测试
编写人员开发开发开发 / 测试
编写场地生产代码仓库内生产代码仓库内生产代码仓库内 / 生产代码仓库外
编写时间代码发布前代码发布前代码发布前 / 代码发布后
编写成本
编写难度
反馈速度极快,秒级较慢,分钟级慢,天级别
覆盖面积代码行覆盖60-80% 分支覆盖40-60%功能级别覆盖核心保障链路
环境依赖代码级别,不依赖环境依赖日常或本地环境依赖预发或生产环境
外部依赖模拟全部模拟部分模拟不模拟,完全使用真实环境

二,为什么要做单元测试?

好处:

  1. 提高系统稳定性,利于迭代。

  2. 有利于深度了解技术与业务。

  3. 单测成本低,速度快。

  4. 单测是最佳的、自动化的、可执行的文档。

  5. 单测驱动设计,提升代码简洁度和规范性,确保安全重构,代码修改后,单测仍然能通过,能够增强开发者的信心。

  6. 快速反馈,更快的发现问题,定位缺陷比集成测试更快更准确,降低修复成本。

    开发成本低:

    1,

    最直观的想法:

    2,

    这样的想法确实是最直观的。但这只是想到了第一层,如果我们把 开发流程所有步骤 都加进来,会发现是这样的:

    在开发过程后面,几乎每个流程都可能抛出 Bug。越是到后面流程才抛出的 Bug,程序员就越是要投入比开发阶段更大的时间和业务,而且所承受的风险也是最高的。

下面这张图,也在说明两个问题:一是 85% 的缺陷都在代码设计阶段产生;二是发现 Bug 的阶段越靠后,耗费成本就越高,呈指数级别的增长。这种 “指数成本” 的案例也经常发生,当我们改正一个 Bug 的时候,可能随之而来又会多出 3 个 Bug,俗称:改崩了。

所以,在早期的单元测试就能发现bug,不仅可以省时省力,在开发流程上提高效率,也能降低反复修改出现的风险和时间成本。

三,关于单元测试的一些误解

知乎 https://zhuanlan.zhihu.com/p/547068206 2,5,6,9,11

误解1: 单元测试减慢了开发过程

事实是:像任何一种新工具一样,习惯进行单元测试也需要一点时间,不过,总的来说,进行单元测试可以节省时间,同时浪费的时间也会缩短。实际上,进行回归测试可以持续不断地推进开发过程,并且不会有任何担心。假若在日常构建时进行单元测试,那么这样的测试是不会占用开发时间的。

误解2:一旦项目结束,那么投入到单元测试上的工作就废掉了

完全不是这样的。如果你曾经重用过代码,那么你将会意识到你所做的一切都是资产。

事实是:在你在一个项目中采用了以前为另一个项目写的代码,或者对这段代码进行编辑的时候,你可以采用相同的单元测试,也可以对这些单元测试进行编辑。在同一个项目中使用相似的测试代码段也是没有问题的。

误解3:单元测试就是浪费时间

你要弄明白什么才是浪费时间?

一而再再而三地修改同样的漏洞

在整个开发过程中编写或者重写验证代码

修补了一个漏洞,不料在其他地方莫名其妙地出现另一个漏洞

在编写代码期间被意外打断,完全不知道该怎么办

拒绝进行单元测试是可以理解的,不过许多开发人员只有在使用单元测试完成一个项目以后,他们才会称赞单元测试多么的好。

事实是:你只需编写单元测试一次,但可多次运行。这与你对其他代码的修改没有任何关系。一开始进行的投入会得到长期的回报。

误解4:单元测试对程序调试没有任何帮助,或者说不能防止漏洞的出现

绝对不是这样的。单元测试可以让程序调试更加简单,因为这样你就可以把精力集中在有问题的代码上,修补问题,接着再重新合并修改后代码。在增加功能的时候,它还可以防止引入漏洞,尤其在使用面向对象方法编程的时候,它还可以阻止问题令人非常沮丧地反复出现。单元测试不能确保100%的排除漏洞,不过它却是减少漏洞的好方法。

事实是:单元测试虽然不能解决你调试过程中遇到的所有问题,但是在你发现漏洞的时候,单元测试中相互隔离的代码可以让漏洞的修补更加容易。根据开发人员中单元测试的铁杆粉丝所说,进行单元测试的最大好处就是让程序的调试非常容易了,简单了。

误解5:使用单元测试进行程序调试覆盖不全面

这仅仅是因为你不能对整个代码进行调试,但这并不意味着调试覆盖不全面。使用单元测试进行程序调试至少比其他类型的调试效果好。事实上,单元测试有一个非常突出的优点是:(如果不是大大地删除,那么就是)大大地减少汇报上面我所提到的漏洞的数量。在开发和调试程序的时候,重现漏洞是一个令人非常沮丧的事情。通过单元测试,你可以在增加、修改和删除功能的时候减少引入新漏洞的频率。调试从来都是“全覆盖的”,尤其是在程序运行的设备或者系统差异非常大的时候。

事实是:特别是在处理漏洞的时候,单元测试可以确保能找到从来都没有汇报过的漏洞。而且在你进行程序调试的时候,你不需要查看全部代码,只需要修改出现漏洞的地方。

四,有哪些主流的单元测试框架?

  1. Junit: Junit是最常用的Java单元测试框架之一,它提供了一组简单易用的API,可以方便地编写和运行单元测试。Junit主要优点包括易学易用、广泛使用、生态丰富等,但它缺乏一些高级功能,如模拟对象等。
  2. Mockito: Mockito是一个用于模拟对象的Java单元测试框架,它可以帮助开发人员创建和管理模拟对象,从而进行真正的单元测试。Mockito的主要优点包括功能强大、易于学习和使用、支持扩展等,但它可能会带来一些性能问题。
  3. Spock: Spock是一个基于Groovy语言的Java单元测试框架,它提供了一组简洁、可读性强的DSL(领域特定语言),可以让开发人员轻松地编写和运行单元测试。Spock的主要优点包括易于阅读和维护、提供丰富的断言库、支持数据驱动测试等,但它需要额外的Groovy编译器支持。但其兼容性比较差,依赖版本稍微不对,就会报一些莫名其妙的错误,而且提示也不明显,不太好排查问题。
  4. TestNG: TestNG是一个Java测试框架,类似于Junit,它提供了一组功能强大的测试功能,包括支持多线程测试、数据驱动测试、分组测试等。TestNG的主要优点包括功能丰富、易于扩展、可以与各种持续集成工具集成等,但它缺乏一些高级功能,如模拟对象等。
  5. PowerMock: PowerMock是一个用于Java单元测试的扩展框架,它可以帮助开发人员编写更灵活的单元测试。PowerMock的主要优点包括支持对静态方法、私有方法等进行测试、易于学习和使用、可以与其他测试框架配合使用等,但它可能会引入更多的复杂性和维护成本。

五,各个框架使用示例

JUnit:

import org.junit.Test;
import static org.junit.Assert.*;

public class MyTest {
    @Test
    public void testSomething() {
        // 执行测试代码
        assertEquals(2 + 2, 4);
    }
}

Mockito:

import static org.mockito.Mockito.*;

public class MyTest {
    @Test
    public void testSomething() {
        // 创建模拟对象
        MyObject mockObject = mock(MyObject.class);
        // 设置模拟对象的行为
        when(mockObject.someMethod()).thenReturn("Hello World");
        // 执行测试代码
        String result = mockObject.someMethod();   
        // 断言结果是否符合预期
        assertEquals(result, "Hello World");
    }
}

Spock:

import spock.lang.Specification
import spock.lang.Subject

class CalculatorSpec extends Specification {

    @Subject
    Calculator calculator = new Calculator()

    def "test add method"() {
        given:
        int a = 2
        int b = 3

        when:
        int result = calculator.add(a, b)

        then:
        result == 5
    }

    def "test subtract method"() {
        given:
        int a = 5
        int b = 2

        when:
        int result = calculator.subtract(a, b)

        then:
        result == 3
    }
}

class Calculator {
    int add(int a, int b) {
        return a + b
    }

    int subtract(int a, int b) {
        return a - b
    }
}

TestNG:

import org.testng.annotations.Test;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.AfterMethod;
import static org.testng.Assert.assertEquals;

public class MyTestNGTest {
    
    @BeforeMethod
    public void setUp() {
        // 在测试方法执行前执行的代码
    }
    
    @AfterMethod
    public void tearDown() {
        // 在测试方法执行后执行的代码
    }
    
    @Test
    public void testAddition() {
        int result = Calculator.add(2, 3);
        assertEquals(result, 5);
    }
    
    @Test
    public void testSubtraction() {
        int result = Calculator.subtract(5, 3);
        assertEquals(result, 2);
    }
}

class Calculator {
    public static int add(int a, int b) {
        return a + b;
    }
    
    public static int subtract(int a, int b) {
        return a - b;
    }
}

PowerMock:

import org.junit.Test;
import org.junit.runner.RunWith;
import org.powermock.core.classloader.annotations.PrepareForTest;
import org.powermock.modules.junit4.PowerMockRunner;
import static org.powermock.api.mockito.PowerMockito.*;

@RunWith(PowerMockRunner.class)
@PrepareForTest(Example.class)
public class ExampleTest {

    @Test
    public void testPrivateMethod() throws Exception {
        Example spy = spy(new Example());
        doReturn("mocked value").when(spy, "privateMethod");

        String result = spy.publicMethod();
        assertEquals("mocked value called from publicMethod", result);
    }
}

class Example {
    public String publicMethod() throws Exception {
        return privateMethod() + " called from publicMethod";
    }

    private String privateMethod() {
        return "privateMethod";
    }
}

以上仅是举了一个简单的例子,实际使用时还需要根据具体的情况进行相应的配置和编写测试代码。

六,各框架对比

框架和特点Mock功能支持私有方法,静态方法代码可读性学习成本语法概念可拓展性和定制性整合性和兼容性文档和社区支持性能和稳定性
Junit××可读性高简单易理解较差spring-test默认集成良好良好
Mockito×可读性高简单的 API、优秀的文档较差spring-test默认集成良好良好
TestNg×可读性一般中等语法简单,但依赖配置繁琐提供了丰富的拓展点和插件机制良好一般,文档相对较少良好
PowerMock代码冗长复杂,可读性不高中等语法相对较复杂提供了丰富的拓展点和插件机制良好良好良好
Spock需要懂Groovy语法基于Groovy语言,语法复杂提供了丰富的拓展点和插件机制兼容性较差良好良好

总结:建议使用Junit+Mockito

  • Junit和TestNG多用于集成测试,PowerMock和Spock学习成本高,代码冗长,可读性较复杂,不利于阅读。
  • 它具有简单的 API、优秀的文档以及大量示例,比较容易上手,且代码复杂性不高。
  • 可读性强,代码简单易理解,有良好的社区支持,出现问题也容易找到解决方法。
  • spring-boot-starter-test默认集成了Junit和Mockito框架。
  • 对于私有和静态方法,可以使用Junit,PowerMock 和 Mockito 的组合来模拟。

七,如何进行单测?

1,单测的使用场景

  1. 代码复用率。代码复用率越高,越有必要推行单测,越有必要提升单测的要求。因此这些代码被很多业务引用,因此其一旦有问题便会影响很多业务方,在这样的代码推行单测是收益较高的。
  2. 业务变化率。业务变化越快,越不适合用单测。如果业务变化非常快,一个单测的内容上线了没几天就又要修改,那么你不仅仅需要修改业务代码,还需要修改单测代码,那就是双倍的工作量了。
  3. 人员变化率。人员变化率指的是某个模块负责人的变化情况。如果某个模块的负责人经常变来变去,那么也是不太适合推行单测的。因为新负责的人需要花大量的时间去熟悉单测的内容,这会导致需求开发的时间变得非常长。
  4. 业务重要性。越是核心的业务,越有必要推行单测,并且越有必要以高标准要求。因为核心业务的稳定性、健壮性对于公司来说肯定非常重要,而单测确实是能够在最小单元去提升系统稳定性和系统健壮性。

上面提到的 4 个衡量维度,我们不能单一地去看待,而是要根据实际情况去综合判断,得出一个最适合的标准!

2,好的单元测试必须遵守的原则。

AIR原则

说明:单元测试在线上运行时,感觉像空气(AIR)一样感觉不到,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。

  • A:Automatic(自动化)单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。
  • I:Independent(独立性)保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。反例:method2需要依赖method1的执行,将执行结果做为method2的参数输入。
  • R:Repeatable(可重复)单元测试是可以重复执行的,不能受到外界环境的影响。说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。

②FIRST原则

1、F-Fast(快速的)

单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,大型项目的单元测试通常应该在几分钟内运行完毕。

2、I-Independent(独立的)

单元测试应该是可以独立运行的,单元测试用例互相之间无依赖,且对外部资源也无任何依赖。

3、R-Repeatable(可重复的)

单元测试应该可以稳定重复的运行,并且每次运行的结果都是稳定可靠的。

4、S-SelfValidating(自我验证的)

单元测试应该是用例自动进行验证的,不能依赖人工验证。

5、T-Timely(及时的)

单元测试必须及时进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量。

3,编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量。

  • B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数据顺序等。
  • C:Correct,正确的输入,并得到预期的结果。
  • D:Design,与设计文档相结合,来编写单元测试。
  • E:Error,强制错误信息输入(如:非法数据、异常流程、非业务允许输入等),并得到预期的结果。

4,哪些场景需要写单测?

核心业务、核心应用、核心模块的增量代码确保单元测试通过。

单元测试软件开发的最基础的手段,而在软件开发中,最重要的思想之一,就是分层思想。每个高层的模块都是又多个底层的模块组合而成,如果用一些不稳定的底层模块组装而成,高层模块也将变得不可靠。就像数学王国,是有几个基础的公理,一层一层的通过证明的方式严格构建而成。 另外,编写业务中,传统的 controller、service、dao 的三层模式,我们应该更重视其中的分层思想,而不是教条似的所有业务都这三板斧。以分层思路为指导思想,复杂的业务层次多一点,简单的业务层次少一点。

Controller -> service -> Manager 外部接口

— - - - - - - – - - - – - -> Dao 数据库

Controller:负责接受请求并返回响应,以及参数的简单校验 。对于无校验逻辑可以不做单测。复杂校验逻需要进行单测。主要用于集成测试

Service:搭积木的作用,负责业务逻辑编排,处理业务逻辑,处理来自Controller层的请求,并访问dao层和manager,需要写单测。

Manager:①负责协调多个 Service 层组件并处理服务层之间的交互,需要单测。②对外部接口进行封装,无额外处理逻辑就不需要单测。③与Dao层交互,控制事务,需要单测。

Dao:执行数据库相关操作。复杂的逻辑需要写单测,纯粹的取数据或者更新,基本不做单测。

DAO层的单测如何进行才不会污染测试数据库

  1. 使用嵌入式数据库:可以使用嵌入式数据库,如H2、HSQLDB等来进行单元测试。这些嵌入式数据库可以在内存中运行,因此不会污染生产数据库中的数据。

  2. 使用事务回滚:可以使用事务回滚来确保测试不会污染数据库中的数据。在测试方法开始前,开启一个事务,在测试结束后,回滚事务,这样所有修改的数据都将被还原到测试前的状态,从而避免了数据污染。(推荐使用)

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class UserServiceTest {
    
        @Autowired
        private UserService userService;
    
        @Test
        @Transactional
        public void testAddUser() {
            User user = new User();
            user.setName("John Doe");
            userService.addUser(user);
    
            // perform assertion
            User savedUser = userService.getUserByName(user.getName());
            Assert.assertNotNull(savedUser);
        }
    }
    
  3. 使用数据库迁移工具:可以使用数据库迁移工具,如Flyway、Liquibase等来进行单元测试。这些工具可以在每次测试前,自动创建一个新的数据库实例,并使用数据库迁移脚本来初始化数据,从而避免了数据污染的问题。

  4. 打个 docker 镜像里面再启动个数据库

总之,编写单元测试的目的是为了保证每个模块的正确性和可靠性,只有每个模块都经过了单元测试的验证,才能组合成一个稳定的、可靠的整体。

八,讨论问题:

1,遗留项目,代码混乱不好写单测,且不能推翻重写,如何优雅加入单测?

2,写好单测之后,功能变更,先改代码,还是写单测?

测试与编码,可以类比为人的两条腿。那么这个问题就变成了,走路是先迈左腿还是右腿?我想大家都会觉得这个问题回答没有意义,但细思一下走路,你会发现,左右腿的协同,是我们行走的关键,步子大了、一只腿瘸了、或者一只脚跳着走,都是不好的形态。 类比单元测试,也是一样,测试驱动编码,编码优化测试。编码臃肿了,就像步子太大了;不好的单测,就是一只腿瘸了;你不写单测,那就是单腿游戏了。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐