深入探讨单元测试:理论基础与实践技巧

863 阅读21分钟

摘要

当今软件开发行业中,单元测试已经成为一种广泛应用的软件开发实践。本文深入探讨了单元测试的理论基础,包括单元测试的定义、意义、关注重点和基本准则。在实践篇中,我们不仅介绍了如何选择适合自己的测试框架、设计高效的测试用例,还分享了实际工程案例中不同类型方法的测试奇技淫巧,帮助读者更好地掌握单元测试实现的方法和技巧。通过本文的阅读,读者不仅可以了解单元测试的基本概念和原则,还能够获得实践方面的经验和技巧,提高软件开发团队的测试效率和软件的质量和可靠性。本文没有分享常见的mock框架例如PowerMock、Mockito等来完成测试,而是借助第三方依赖通过不需要mock的相关代码来达到测试的目的。虽然这种方法可以解决一些问题,但在复杂场景下可能会存在一定的局限性。鼓励读者在实践中积极探索各种测试场景和问题,并根据具体情况选择合适的测试方法和工具。本文的思维导图如下:

一、理论篇

1、何为单元测试

计算机编程中,单元测试(英语:Unit Testing)又称为模块测试 [来源请求],是针对程序模块软件设计的最小单位)来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。

                                                                                                                                                             ---维基百科

这句话可能有点晦涩难懂,我个人认单元测试是一种软件测试的方法,用于检验程序中的最小可测试组件(即“单元”)的正确性。这些单元通常是体量小、执行单一功能的程序、函数或过程。通过单元测试,我们可以确定每个单元是否按照预期执行,并且可以及早发现和定位代码中的缺陷。由于单元测试只针对最小的可测试组件,因此发现的问题更容易修复。此外,单元测试还可以帮助开发人员在重构代码时确保代码的稳定性和正确性。总的来说,单元测试是一种非常重要的软件开发工具,可以帮助开发人员提高代码质量、提高开发效率并减少错误发生的几率。

2、单元测试的意义

在《单元测试的艺术》这本书提到一个案例:找了开发能力相近的两个团队,同时开发相近的需求。进行单测的团队在编码阶段时长增长了一倍,从7天到14天,但是,这个团队在集成测试阶段的表现非常顺畅,bug量小,定位bug迅速等。最终的效果,整体交付时间和缺陷数,均是单测团队最少。

发现在编码过程中引入的错误

在编码过程中,我们可能会因为担心修改(重构)老代码的影响范围而不敢动手。特别是在多个模块之间存在依赖关系的情况下,我们更容易感到不安。但是,如果我们使用单元测试,情况就会变得不同。在修改(重构)代码之后,我们只需要运行相应的单元测试,就能够知道这些改动是否对整个系统产生了影响。这样,我们就可以更加自信地进行代码的修改和重构了。

验证代码是与设计相符合的

为了确保我们的代码与设计相符,单元测试是最有效的测试手段之一。通常情况下,我们会在完成代码编写后进行自我测试,以验证其可行性。然而,自我测试只能涵盖部分场景,难以模拟真实的异常情况,比如数据库访问异常或IOException等场景。同时,我们也很难穷尽程序的各种边界条件。这些问题可以通过单元测试得以解决。通过编写单元测试和相关依赖,我们可以轻松地构建各种测试场景,从而验证代码在各种异常和边界条件下的表现,确保其可以正常运行并处理所有情况。因此,单元测试是确保代码质量的重要手段,也是代码与设计相符的有效保障。

发现设计和需求中存在的缺陷

单元测试不仅可以验证代码是否符合需求,还可以帮助我们发现设计和需求中存在的缺陷。在编写测试用例的过程中,我们需要考虑业务的各种场景,从而可以跳出代码来思考业务,反过来思考我们的代码是否满足业务的需求。因为在编写业务代码并进行心流模式后,我们很可能只会关注代码本身的质量和层次结构,而忽略了业务的实际需求。通过跳出代码设计来思考业务,我们可以更容易地发现设计和需求中存在的缺陷,从而对代码进行进一步的改进和优化。因此,单元测试不仅是验证代码是否合乎需求的有效手段,还是发现设计和需求中存在缺陷的重要工具。

跟踪需求与设计的实现

为了确保代码符合需求和设计,我们需要跟踪需求和设计的实现。传统的文档和注释虽然可以起到记录作用,但是往往会存在与代码不同步的问题,导致可能会误导后续接手代码的开发者。因此,单元测试成为了最佳的开发辅助文档。单元测试可以覆盖接口的所有使用方法,同时也是最好的示例代码,可以帮助开发者更好地理解代码的实现。此外,单元测试还可以避免因为文档和注释与代码不同步而产生的问题,从而确保代码的正确性和可靠性。因此,跟踪需求和设计的实现需要依赖于单元测试,它不仅可以作为代码的文档,还可以保证代码的质量和可维护性。

3、单元测试关注的重点

独立路径

独立路径是指从程序的入口到出口的多次执行中,每次至少有一个语句是新的,未被重复的,也即每次至少要经历一条从未走过的弧。引用网图如下。这里引用一个概念“分支(BranchCoverage)”和“分支覆盖率(BranchCoverage)”。

每个控制结构中(例如if和case语句)的每个分支(也称为决策到决策路径)是否均被执行?例如,给定一个if语句,其true和false分支是否均被执行?(此为边覆盖率的子集)

常常出现的错误有:

  • 运算的优先次序不正确或误解了运算的优先次序
  • 运算的方式错,即运算的对象彼此在类型上不相容
  • 浮点数运算精度问题而造成的两值比较不等
  • 不正确地多循环一次或少循环一次
  • 不适当地修改了循环变量
  • 错误或不可能的终止条件

局部数据结构

局部数据结构是为了保证在模块内临时存储的数据在程序执行过程中保持完整、正确的基础。在单元工作的过程中,必须测试单元内部的数据是否完整,以确保内部数据的内容、形式及相互关系不会出现错误。

常常出现的错误有:

  • 不合适或不相容的类型说明
  • 变量初始化或省缺值有错
  • 不正确的变量名(如拼写错误或截断错误)

错误处理

为了设计比较完善的模块,需要预见可能出现的错误条件,并设置适当的错误处理机制。这样一旦程序出错,就可以对出错程序进行重新安排,保证其逻辑上的正确性。

常常出现的错误有:

  • 出错的描述不足以对错误定位,不足以确定出错的原因
  • 显示的错误与实际的错误不符
  • 对错误条件的处理不正确
  • 异常处理不当

边界条件

边界上出现错误确实很常见,因此在测试边界处的单元时需要非常谨慎和仔细。除了关注一些可能与边界有关的数据类型,如数值、字符等,还需要考虑边界的首个值、最后一个值、最大值、最小值等。

常常出现的错误有:

  • 小于、小于等于、等于、大于、大于等于、不等于确定的比较值出错
  • 循环条件出错,index出错
  • 出现上溢、下溢

单元接口

单元接口是指输入和输出之间相互对应的集合。为了进行动态测试,需要给单元输入一组数据,然后检查输出是否与预期结果一致。如果数据无法正常输入和输出,单元测试就无法进行。因此,需要对单元接口进行如下测试:

  • 调用其他模块时所给的输入参数与模块的形式参数在个数、属性、顺序上是否匹配
  • 调用预定义函数时所用参数的个数、属性和次序是否正确
  • 是否处理了输入/输出错误

* 代码书写规范

代码书写规范是保证代码易读、易维护的重要保障。

主要关注以下几个方面:

  • 代码格式:代码应该有统一的缩进格式,如使用四个空格或一个制表符。统一的缩进格式可以使代码结构更清晰,易于阅读。
  • 对齐格式:代码中的对齐应该有统一的规范,如使用对齐符号来对齐变量名、注释、方法名等,以使代码更具可读性。
  • 注释:代码中应该有必要的注释,以便其他人能够理解代码的作用。注释应该清晰、简洁、准确,注释的内容应该与代码保持同步更新。
  • 命名规范:变量、方法、类等的命名应该遵循团队或部门的规范。命名应该简洁、明确、有意义,以便于其他人理解和维护代码。
  • 编码规范:编写代码时应该遵循一定的编码规范,如禁止使用魔法数字、避免使用全局变量、使用常量等。这些规范都是为了提高代码的可读性和可维护性。

4、单元测试的基本原则

单元测试中应该优先遵守FIRST原则

F——Fast:快速

当调试代码时,需要频繁运行单元测试以验证结果是否正确。如果单元测试足够快速,可以节省不必要的时间,提高工作效率。

I——Isolated:隔离

优秀的单元测试关注逻辑的一个方面,确保每个测试之间不会产生依赖,不会因为测试顺序不同而导致运行结果不同。

// 按照逻辑分离测试
void test_nullpoint_exception() {
    /* 空指针异常测试 */
}

void test_other_exception() {
    /* 其他异常测试 */
}

每个测试之间不应该产生依赖,不会因为测试顺序不同而导致运行的结果不同。

//! 错误示范 ❌
void test_nullpoint_exception() {
  String str = createStr();  /* 创建字符串 */
  /* 空指针测试内容 */
}

void test_other_exception() {
  String str = getStr();     /* 获取字符串 */
  /* 错误参数测试内容 */
  destroy_str();            /* 删除字符串 */
}

// 正确示范 ✔
void test_nullpoint_exception() {
  String str = createStr();  /* 创建字符串 */
  /* 空指针测试内容 */
  destroy_str()
}

void test_other_exception() {
  String str = createStr();     /* 获取字符串 */
  /* 错误参数测试内容 */
  destroy_str();            /* 删除字符串 */
}

R——Repeatable:可重复

单元测试需要保持运行稳定,每次运行都需要得到相同的结果,间歇性地失败会导致我们不断去查看测试,失去可靠性。

//! 错误示范 ❌
void test() {
  assertTrue(70 < (rand() % 100));
}

S——Self-verifying:自我验证

单元测试应该采用Asset函数等进行自我验证,这样当单元测试执行完毕后,测试结果就会自动得知,无需人工干预。

T——Timely:及时

在代码稳定运行之前进行单元测试可能是最有效的方式,可以确保在实现函数功能之前就检测到问题。

其他原则

  • 必须保证单元测试用例本身的正确性
  • 对全新的代码或修改后的代码要进行单元测试
  • 对被测单元需要达到一定的代码覆盖率要求

二、实践篇

1、测试框架的选择

在我看来,虽然没有一种测试框架是完全适合所有单元测试的使用的,但是在一个团队中,最好能够共同使用同一个测试框架。这样可以提高团队协作效率,减少沟通成本,并且能够更容易地维护和管理测试代码。然而,在某些情况下,不同的业务需求可能需要使用不同的测试框架来解决,这时候需要进行妥协和协商。总之,团队成员需要不断学习和了解不同的测试框架,并根据具体情况来选择最合适的框架来进行单元测试。

测试框架则是测试的重要工具之一,可以帮助开发人员快速构建测试用例,自动化运行测试,提高测试效率和准确度。在Java领域,JUnit和TestNG是两个最常用的测试框架,而PowerMock、JMockit、Mockito和Spock则是常用的Mock框架,下面我们来对比一下它们的功能和特点。

测试框架单元测试集成测试功能测试性能测试压力测试跨平台支持分布式测试Mock支持参数化测试数据库测试并发测试Spring集成
JUnit5✔️✔️✔️
TestNG✔️✔️✔️✔️✔️✔️✔️✔️✔️✔️
PowerMock✔️✔️✔️
JMockit✔️✔️✔️✔️
Mockito✔️✔️✔️
Spock✔️✔️✔️✔️✔️✔️✔️✔️

首先是单元测试框架JUnit和TestNG。JUnit是Java领域最常用的单元测试框架之一,它有着简单易用的API,可以帮助开发人员快速编写测试用例。而TestNG则是基于JUnit的测试框架,它支持更多的测试类型,如集成测试、功能测试、性能测试、并发测试等,同时还支持Spring集成、自动化报告等特性。下面是JUnit和TestNG的功能对比表:

测试框架推荐场景特点
JUnit单元测试简单易用,快速编写测试用例
TestNG综合测试支持更多的测试类型,支持Spring集成、自动化报告等特性

接着是Mock框架PowerMock、JMockit、Mockito和Spock。Mock框架可以帮助开发人员模拟外部依赖以及测试不同的场景,提高测试的可靠性和覆盖率。下面是PowerMock、JMockit、Mockito和Spock的功能对比表:

测试框架推荐场景特点
PowerMock需要模拟静态方法、私有方法等时支持模拟静态方法、私有方法等
JMockit需要Mock所有依赖,包括静态方法、私有方法、构造函数等时支持Mock所有类型的依赖
Mockito需要一个简单易用的Mock框架时简单易用,支持Mock对象、方法、参数等
Spock喜欢Groovy语言编写测试用例,以及需要支持BDD时支持JUnit和TestNG的所有特性,支持BDD和Groovy语言的特性

总的来说,选择测试框架需要根据具体的需求和场景来综合考虑。如果只需要进行单元测试,那么JUnit是不错的选择;如果需要进行综合测试,并且需要支持更多的测试类型,那么TestNG是更好的选择。而如果需要Mock依赖,那么根据实际情况选择PowerMock、JMockit、Mockito和Spock都是不错的选择。

2、用例的设计

测试的目的

借用《人月神话》一书的观念:

软件开发主要的复杂度来自于设计概念,次要的复杂度是实现概念。换句话说,最困难的事情是知道“该怎么做才正确”,而不是“做出来”。

这也是为什么软件开发的效率和可靠性难以提升,软件越来越难以理解的原因。尽管程序的基本结构非常简单,只有循环、判断、计算、函数调用等结构,但在具体编码时,我们堆砌的都是不同的内容,这些内容使得程序的复杂度非线性增加。随着复杂度的上升,列举所有可能性变得困难,函数调用变得复杂,新功能的添加变得困难,软件也越来越像一个黑盒。

单元测试能够有效地检测代码的正确性,降低程序出现缺陷的风险,提高程序的可靠性。同时,单元测试也能够帮助开发人员更好地理解程序的功能和实现细节,减少代码的复杂度,提高代码的可读性和可维护性。因此,单元测试是软件开发过程中非常重要的一环。

虽然单元测试不能保证系统没有问题,但实际上,没有任何测试能够证明系统没有问题。这是软件工程的基本理论,即测试能证明错误的存在,而不能证明错误不存在。但是单元测试是写起来、运行起来、调试起来最快、效能最高的方式。因此,单元测试是一个优秀且具有生命力的程序必不可少的一部分。在软件开发过程中,我们应该始终重视单元测试并且不断地改进它。

有效性的保证

在日常编码工作中,我们常常会形成自己的编码习惯和方法论,其中包括测试用例的设计和编写。在此推荐一种编写单元测试的常见套路或方法论,即GIVEN-WHEN-THEN原则,同时该套路也是提高测试用例有效性的一种方法。通过遵循GIVEN-WHEN-THEN原则,我们可以更加清晰地定义测试环境、期望结果以及测试操作,从而提高测试用例的可靠性和准确性,进而提高测试的有效性。此外,根据具体需求可以添加WHERE部分,使测试数据更加丰富和真实,进一步增强测试用例的有效性。

  • Given:初始状态或前置条件
  • When:行为发生
  • Then:断言结果
  • Where: 数据来源

当我们编写测试用例时,我们通常会使用GIVEN-WHEN-THEN原则,即通过精心准备一组输入数据(GIVEN)和测试数据(WHERE),在调用被测试代码的行为(WHEN)之后,断言返回的结果是否与预期结果相符(THEN)。这种基于用例的测试方式在开发(包括TDD)过程中非常有用,因为它清晰地定义了输入输出,而且大部分情况下体量都很小、容易理解。

以一个简单的例子来说,我们有一个UserService类,负责处理domain对象,并且还有一个数据访问层。我们需要设计的用例是对UserService的findUser方法进行测试。在测试之前,我们需要确定输入数据和期望的输出结果。我们可以使用一些假数据(WHERE)来模拟输入数据。然后,我们调用findUser方法(WHEN),并断言返回的结果是否与预期相符(THEN)。

例如,假设我们的输入数据是用户ID,我们期望的输出结果是对应的用户对象。我们可以编写如下的测试用例:

GIVEN 用户ID为1
WHERE 数据库中存在ID为1的用户对象
WHEN 调用findUser方法
THEN 返回用户对象

下面我们用代码具体实现:

@Data
public class User {
    private int id;
    private String name;
    private int age;
}
@Getter
@AllArgsConstructor
public class Service {
    private UserDao userDao;
    //      Others ..
    private AgeDao ageDao;
    private OtherDao otherDao;

    public Service(UserDao userDao) {
        this.userDao = userDao;
    }
    public User findUser(int id){
        return userDao.get(id);
    }
}
public interface UserDao {
    User get(int id);
}

GIVEN:

在用例设计时,需要考虑实际情况并结合业务场景。例如,如果我们需要测试UserService中的findUser(int id)方法,该方法使用了UserDao这个字段,那么我们可以将UserService中除UserDao以外的变量mock或fake,因为在这个用例中我们只关心UserDao的行为。假设我们需要查询id为1的用户信息,我们可以进行如下的设计:

创建UserService对象,将除UserDao以外的参数mock或fake,例如:

//        given:
    service = new Service(userDao, Mock(AgeDao), Mock(XXDao))
    int id = 1

WHEN:

调用UserService的findUser方法,例如::

// when:
    User user = service.findUser(id)

THEN:

对findUser方法返回的结果进行断言或其他操作,例如:

//        Then:
        Assert.assertEquals("li ming", user.getName());
        Assert.assertEquals(1, user.getId());
        Assert.assertEquals(18, user.getAge());

最后,为这条用例起一个有意义的名字,一个简单的用例就完成了。最后需要注意的是,在实际测试时,还需要考虑边界情况、异常情况、并发等方面,以确保代码的健壮性和稳定性。

    @Test
    public void test_given_otherDao_When_query_a_user_by_id_Then_success() {
//        Given:
        Service service = new Service(userDao, mock(AgeDao.class), mock(OtherDao.class));
        int id = 1;

//        when:
        User user = service.findUser(id);

//        Then:
        Assert.assertEquals("li ming", user.getName());
        Assert.assertEquals(1, user.getId());
        Assert.assertEquals(18, user.getAge());
    }

* WHERE

WHERE是一个新引入的概念,用于指示测试数据的来源。通常情况下,测试数据可以来自不同的数据源,如数据库、CSV等。我们可以使用各种框架或模拟数据源来准备测试数据。常用的方法是PowerMockite.when(.).thenReturn(...)或者使用H2内存数据库来模拟和在线数据库的交互。这些都是我们模拟测试数据来源的方法。

在使用Spock测试框架时,我们可以清晰地看到GIVEN-WHEN-THEN-WHERE的测试表格,其中WHERE部分提供了测试数据的来源和输入值,以确保我们的测试用例更加全面和准确。

def "it gets a user by id"() {
        given:
        service = new Service(userDao, Mock(AgeDao), Mock(XXDao))
        userDao.get(id) >> new User(id: id, name: userName, age: userAge)

        when:
        def user = service.findUser(id)

        then:
        user.id == userId;
        user.name == userName
        user.age == userAge

        where:
        id || userId | userName  | userAge
        1  || 1      | "li ming" | 18
        2  || 2      | "li hua"  | 19
        3  || 3      | "li li"   | 20
    }

3、实际工程探究

参数化测试

Java参数化测试是一种测试方法,即在测试用例中使用不同的参数进行多次测试,以验证代码在不同参数下的行为是否符合预期。它通过使用JUnit框架提供的Parameterized注释和参数化测试运行器来实现。以下是Java参数化测试的适用范围和推荐场景,以及优劣。

适用范围推荐场景优劣
测试相同逻辑但输入输出不同的方法数据库操作,文件操作,API测试优点:节省时间和精力,减少代码冗余,提高代码可读性缺点:难以维护,测试覆盖率受限
对于需要测试大量组合的参数参数组合测试,边界测试优点:覆盖更多情况,发现更多问题缺点:测试用例数量增加,测试时间变长
对于需要测试不同环境下的代码系统兼容性测试,集成测试优点:可以测试代码在不同环境下的表现缺点:测试用例数量增加,测试时间变长

Java参数化测试需要在以下情况下使用:

  • 当需要测试多个输入参数时,可以使用Java参数化测试来简化测试代码并提高测试效率。
  • 当需要测试代码在不同环境下的表现时,可以使用Java参数化测试来模拟不同的环境。
  • 当需要测试大量参数组合时,可以使用Java参数化测试来增加测试覆盖率。

Java参数化测试不推荐在以下情况下使用:

  • 当参数组合数量很小且测试时间短时,使用Java参数化测试并不会带来明显的效益。
  • 当测试用例数量很少时,使用Java参数化测试可能会增加代码的复杂度并降低代码可读性。

现在让我们考虑一个比较复杂的例子,其中被测试代码圈复杂度高达19且有5个代码分支,我们将使用参数化测试来测试所有代码分支。为了说明这一点,我们将使用一个简单的应用程序,它将分数映射到等级。分数在0到100之间,等级根据分数分为A、B、C、D和F。这个应用程序有许多代码分支,所以我们将使用参数化测试来测试所有的代码分支。

以下是被测试代码:

public class GradeCalculator {

    public static String calculateGrade(int score) {
        if (score <0 || score >100) {
            return "Invalid score";
        } else if (score >=90) {
            return "A";
        } else if (score >=80) {
            return "B";
        } else if (score >=70) {
            return "C";
        } else if (score >=60) {
            return "D";
        } else {
            return "F";
        }
    }
}

在这个例子中,我们有一个静态方法calculateGrade,它将一个整数作为输入,并将其映射到一个等级字符串。根据输入值,它可能会进入5个不同的代码分支。现在,我们将使用参数化测试并使用GIVEN-WHEN-THEN的原则来测试这个方法的所有分支。

@RunWith(Parameterized.class)
public class GradeCalculatorTest {
    // where 
    @Parameterized.Parameters(name = "{index}: score({0})={1}")
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][]{
                // given
                {-1"Invalid score"},
                {0"F"},
                {59"F"},
                {60"D"},
                {69"D"},
                {70"C"},
                {79"C"},
                {80"B"},
                {89"B"},
                {90"A"},
                {100"A"},
                {101"Invalid score"}
        });
    }

    private final int input;
    private final String expectedOutput;

    public GradeCalculatorTest(int input, String expectedOutput) {
        this.input = input;
        this.expectedOutput = expectedOutput;
    }


    @Test
    public void testCalculateGrade() {
        //when 
        String grade = GradeCalculator.calculateGrade(input);

        // then 
        assertEquals(expectedOutput, grade);
    }
}

在这个例子中,我们使用 @Parameters 注解来指定一个静态方法,该方法返回包含测试数据的集合。每个测试数据包含两个参数:输入值和预期输出值。构造函数使用这些参数来设置测试实例的状态,并在测试方法中使用它们执行测试。

运行这些测试后,我们可以看到JUnit4在每个输入值上运行测试,并根据预期输出值验证结果。这使我们可以轻松地测试所有代码分支,并确保我们的代码在所有情况下都能正确运行。

总的来说,通过使用参数化测试,我们可以轻松地测试多个输入和预期输出值,从而测试被测试代码的多个代码分支,从而确保代码的正确性。这是一个非常强大的测试工具,对于复杂的代码非常有用。

H2+DbUnit+SpringDbUnit测试

SpringDbUnit是一个基于Spring框架的DbUnit扩展,而DbUnit则是一个数据库单元测试工具,它可以自动生成测试数据,清空测试数据和验证结果,从而提高测试的效率和准确性。而H2是一个内存数据库,可以在测试过程中使用,这三个工具结合起来,可以进行数据库单元测试,提高测试的效率和准确性。

使用SpringDbUnit+h2+DbUnit测试的好处主要包括:

  1. 提高测试效率:使用内存数据库可以减少测试时间和测试成本。
  2. 准确的测试数据:通过DbUnit可以准备合理的测试数据,避免了手动创建测试数据带来的错误和不准确。
  3. 更好的测试覆盖率:数据库测试可以覆盖更多的代码路径和异常情况,从而提高测试的覆盖率。

相比与传统的Mock测试主要优劣点如下

优点:

  1. 可以模拟外部依赖:通过模拟外部依赖,可以在不依赖外部系统的情况下进行测试。
  2. 提高测试效率:不需要启动整个系统,可以只针对某个模块进行测试。
  3. 更好的可控性:可以精确控制每个测试用例的输入和输出。

缺点:

  1. 可能存在模拟不准确的情况:模拟对象可能无法完全模拟真实情况,从而产生错误测试结果。
  2. 可能存在遗漏测试用例的情况:由于模拟对象只是模拟真实系统的一部分,可能会遗漏一些测试用例。
  3. 可能存在维护成本高的情况:由于需要手动编写模拟对象,可能会增加维护成本。

下面是一个简单的表格,展示传统mock测试和SpringDbUnit+h2+DbUnit测试的对比:

测试方法优点缺点
SpringDbUnit+h2+DbUnit测试提高测试效率;准确的测试数据;更好的测试覆盖率。可能存在数据库表结构变更导致测试失败的情况;需要配置h2数据库;需要准备测试数据。
传统mock测试可以模拟外部依赖;提高测试效率;更好的可控性。可能存在模拟不准确的情况;可能存在遗漏测试用例的情况;可能存在维护成本高的情况。

使用例子,需要在pom中增加以下依赖,对应的版本建议去官网寻找适合的版本:

<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>com.github.ppodgorsek</groupId>
  <artifactId>spring-test-dbunit-core</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.dbunit</groupId>
  <artifactId>dbunit</artifactId>
  <scope>test</scope>
</dependency>

需要在测试模块的properties增加spring.main.allow-bean-definition-overriding=true属性。

@TestConfiguration
public class H2DataSourceConfig {

    @Bean
    public DataSource dataSource() {
        return DataSourceBuilder.create()
                .driverClassName("org.h2.Driver")
                .url("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL")
                .username("SA")
                .password("")
                .type(JdbcDataSource.class)
                .build();
    }

    // 数据库初始化脚本 schema-h2.sql 放在test目录下 是数据库的DDL
    @Bean
    public DatabasePopulator databasePopulator(
            @Value("classpath:schema-h2.sql") Resource schemaScript) {
        return new ResourceDatabasePopulator(schemaScript);
    }

    // 
    @Bean
    public DataSourceInitializer dataSourceInitializer(
            final DataSource dataSource, final DatabasePopulator databasePopulator) {
        final DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDataSource(dataSource);
        initializer.setDatabasePopulator(databasePopulator);
        return initializer;
    }
}
@TestExecutionListeners(
        value = {DbUnitTestExecutionListener.class},
        mergeMode = MERGE_WITH_DEFAULTS)
@DbUnitConfiguration(dataSetLoader = CsvUrlDataSetLoader.class)
//@DatabaseSetup 可以放在类上或者方法上,放在方法上时前面的用例对于数据库的操作不会影响到后面的用例
// DbUnitTestExecutionListener会监听测试类或者方法上是否有@DatabaseSetup注解,有的话会将
// 对于对应path里的数据加载到数据库
@DatabaseSetup("/dbunit/TestMapper/")
public class Test {
    @Autowired TestMapper testmapper;

	@Test
    public void test() {
        List<TestDolist = testMapper.queryAll();

        Assert.assertFalse(list.isEmpty());
    }

}

WireMock测试

WireMock是一个开源的Java库,用于模拟和测试HTTP服务。它可以轻松创建虚拟服务端点,并使用预定义的响应来模拟对外部服务的调用。WireMock测试是一种通过模拟对外部服务的调用来测试应用程序的方法。

使用WireMock测试的好处如下图所示:

好处描述
可靠性通过模拟特定请求和响应,可以确保测试的可靠性和一致性。
易于维护WireMock测试非常易于维护,因为它使用JSON文件来定义虚拟服务端点和响应。
可重用由于WireMock测试使用JSON定义服务端点和响应,可以轻松重用它们以进行其他测试。
轻量级WireMock测试是轻量级的,非常适合用于单元测试和集成测试。

与传统的mock对比,WireMock测试的优劣如下:

对比传统MockWireMock测试
易于创建需要手动编写模拟数据使用JSON文件定义模拟数据,易于创建和维护
灵活性无法模拟复杂的请求和响应可以模拟各种复杂的请求和响应
可读性模拟数据通常难以阅读使用JSON文件定义模拟数据,易于阅读和维护
可重用性模拟数据不能轻松重用使用JSON文件定义模拟数据,可以轻松重用它们以进行其他测试
集成问题集成问题可能会导致测试失败WireMock测试可以轻松集成到其他测试框架中
增加复杂性需要手动编写代码,增加复杂性WireMock测试使用简单的JSON定义模拟数据,易于管理
性能问题可能会影响应用程序的性能WireMock测试可以配置延迟和带宽限制,以避免影响应用程序的性能

WireMock的实现原理是通过创建一个本地服务器,在该服务器上模拟外部服务的响应,然后对我们的代码进行测试。所以我们需要配置WireMock服务器,然后定义模拟响应,最后在把配置好的服务器加入到我们的单元测试中。然后测试代码来验证我们的服务是否正确处理了这些响应。

正因为WireMock是在本地创建的服务器,所以我们在单元测试中需要把所有的外部的URL都指向本地。一个简单地例子说明下,我有一个http客户端,该客户端主要调用外部第三方接口。读取第三方的url可能从配置文件、redis、数据库获取。

public class ExternalApiClient {
 private String apiUrl;

 public ExternalApiClient() {
     // getApiUrl()方法是读取第三方的url的方法。
 apiUrl = getApiUrl()
 }

 private String getApiUrl() {
     // 假设是从Property获取
     return System.getProperty("external.api.url");
 }
 public String getSomeData() throws IOException {
 // 构建请求URL
 URL url = new URL(apiUrl + "/somedata");
 // 发送HTTP请求并返回响应
 HttpURLConnection connection = (HttpURLConnection) url.openConnection();
 connection.setRequestMethod("GET");
 // 处理响应
 ...
 }
}

JUnit测试中,你可以在@Before方法中添加以下代码来修改URL。

@Before
public void setup() {
 // 设置WireMock服务器地址和端口。指向localhost。端口号和path可以根据要求指定
 System.setProperty("external.api.url""http://localhost:8080/api");
}

当你设置好WireMock服务器的地址和端口并修改了请求URL后,你需要使用WireMock定义外部接口的响应。为了实现这一点,你需要在测试代码中创建一个WireMock服务器实例,并使用该实例定义外部接口的响应。以下是一个示例:

public class ExternalApiClientTest {
 private WireMockServer wireMockServer;

 @Before
 public void setup() {
// 设置WireMock服务器地址和端口
 System.setProperty("external.api.url""http://localhost:8080/api");
 // 创建WireMock服务器实例
 wireMockServer = new WireMockServer(options().port(8080));
 wireMockServer.start();

 // 定义外部接口"/api/somedata"的响应
 wireMockServer.stubFor(get(urlPathEqualTo("/api/somedata"))
 .willReturn(aResponse()
 .withStatus(200)
 .withHeader("Content-Type""application/json")
 .withBody("{"data": "test"}")));
 }

 @After
 public void cleanup() {
 // 停止WireMock服务器
 wireMockServer.stop();
 }

 @Test
 public void testGetSomeData() throws IOException {
 // 创建测试对象
 ExternalApiClient client = new ExternalApiClient();

 // 发送GET请求并处理响应
 String response = client.getSomeData();

 // 验证响应内容是否正确
 assertThat(response, containsString("test"));
 }
}

这样,你就可以使用WireMock框架来测试"ExternalApiClient"的行为,而不必依赖外部接口的可用性或者Mock外部接口。

引用:

  1. 单元测试
  2. Given-When-Then 
  3. 分支覆盖率
  4. 《软件测试核心技术:从理论到实践》
  5. zhuanlan.zhihu.com/p/268511977
  6. DbUnit
  7. Spring Test DBUnit
  8. H2
  9. WireMock