如何编写测试用例

代码质量管理是软件开发过程中的关键组成部分,比如我们常说的代码规范、代码可读性、单元测试和测试覆盖率等,对于研发人员来说单元测试和测试覆盖率是保障自己所编写代码的质量的重要手段;好的用例可以帮助研发人员确保代码质量和稳定性减少维护成本提高开发效率以及促进团队合作。之前看过一篇关于 OceanBase 质量之道的文章,文章中提到的工程理念就把测试作为非常重要的组成部分,是和研发同样重要的组成部分;也听过内部的同学说过,OB 最核心的是用例。

OceanBase工程理念:经过多年的摸索,OceanBase团队打造了独特的工程文化。测试和开发同时进行,功能测试不再是一个独立分开的过程,而是融入到开发环节,从源端控制引入bug的概率。资深测试人员的精力主要放在难度较大的bug的发现,测试体系建设和相关技术钻研、测试自动化实施。我们建立了一套高效的代码准入流程,防范了许多初级的问题,提升了团队整体的研发效率。

由此可见,测试用例对于项目的重要性。从实际的工作中,也会发现大多数的同学对于如何编写测试用例其实是比较模糊的,在以项目交付为核心思路的工程实践中,测试用例往往只占整个工程周期相当小的一部分,更多时候是依赖测试团队进行功能测试,属于纯黑盒测试。那么这种测试对于业务常规流程可以起到一定的作用,但是对于一些边界问题其实很难 cover 住;另外,基于黑盒模式的功能性测试对于研发团队本身来说,除了拿到准入的测试报告之外,并无其他帮助,当研发需要对代码进行重构或者升级某部分组件时,没有用例的保障,则会将风险直接带到线上环境去。

常见的测试方式

在既往的工作团队中,关于测试方式,包括我自己在内,在没系统了解过测试理论之前,对于各种测试方式也是模棱两可;因为测试方式的种类实在是多又杂。下面是梳理的常见的测试方式,按照不同的维度进行了分类。

分类维度 测试方式 说明
测试目标 功能测试 验证系统是否按照规格说明的功能需求进行操作和响应。
性能测试 评估系统在不同负载条件下的性能表现。
安全性测试 发现系统的安全漏洞和弱点,以确保系统不容易受到攻击。
回归测试 确保在对系统进行修改后,没有引入新的错误或破坏已有功能。
可用性测试 评估系统的用户界面和用户体验。
兼容性测试 验证系统在不同浏览器、操作系统和设备上的兼容性。
测试层次 单元测试 验证单个代码单元(通常是函数、方法、类等)的正确性。
组件测试 验证单个软件组件的功能性和正确性。
集成测试 验证不同组件、模块或服务之间的接口和协同工作。
系统测试 验证整个系统是否按照需求规格正常运行。
验收测试 由最终用户或客户进行的测试,以验证系统是否满足其需求和期望。
测试方法 手动测试 测试人员手动执行测试用例,模拟用户的操作。
自动化测试 使用自动化测试工具和脚本来执行测试用例,提高测试效率和一致性。
白盒测试 关注内部代码逻辑,通常由开发人员执行。
黑盒测试 关注输入和输出,不关心内部代码逻辑。
灰盒测试 结合了白盒测试和黑盒测试的特点。
执行时机 静态测试 在代码编写之前或编译之后执行,包括静态代码分析、代码审查等。
动态测试 在运行时执行,包括各种类型的功能测试、性能测试等。
测试对象 功能测试 测试系统的功能性。
非功能测试 测试系统的非功能性特征,如性能、安全性、可用性等。
白盒测试 测试代码的内部逻辑和结构。
黑盒测试 测试系统的输入和输出,不考虑内部实现。

每种测试方式都有其独特的目标和方法,可以在软件开发生命周期的不同阶段进行。不同的测试方式在不同的测试维度分类下会有一些重叠,这是正常的,但是他们的关注点是一致的。

在本篇文章中,主要更偏向于研发侧,所以从测试层次角度来看,更多的是关注单元测试(UT)、组件测试(CT)以及集成测试(IT)。总体来说,UT 关注代码单元的正确性,CT关注组件的功能性,IT关注不同组件的集成和协同工作。这些测试层次通常是渐进的,从UT开始,然后是CT,最后是IT。不同的测试方式在软件测试策略中起着不同的作用,这些测试手段的目的就是共同确保软件在各个层次上的质量和稳定性。

下面会通过一个具体的例子来阐述不同的测试方式,主要是针对单元测试、组件测试和集成测试;项目基于 Spingboot 2.4.12 版本,使用 Junit4 和 Mockito 两种测试工具包。

UT、CT 和 IT

在具体看案例之前,先把几个测试工具跑出来,做个简单了解。

测试工具

下面的案例中主要涉及到的测试工具和框架包括:spring-boot-starter-testjunit4Mockito

spring-boot-starter-test

官方文档:https://docs.spring.io/spring-boot/docs/1.5.7.RELEASE/reference/html/boot-features-testing.html

spring-boot-starter-test 是 Spring Boot 提供的一个用于测试的依赖库,它简化了 Spring Boot 应用程序的测试过程,提供了许多有用的工具和类,帮助开发人员编写高效、可靠的单元测试和集成测试。就目前而言,JAVA 技术栈的项目是绕不开 Spring 这套体系的,而绝大多数情况下,在 spring 或者 springBoot 项目中,我们需要依赖 spring 容器刷新之后去测试相应的逻辑,spring-boot-starter-test 就是做这个事情的。

1
2
3
4
5
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

junit4

JUnit 4 是一个用于 Java 编程语言的单元测试框架。目前版本是 JUnit 5,目前我们项目中使用的是 JUnit4。以下是 JUnit 4 中一些常用的特性和概念:

  • 注解驱动的测试:JUnit 4使用注解来标记测试方法,以指定哪些方法应该被运行为测试。常见的测试注解包括 @Test 用于标记测试方法、@Before 用于标记在每个测试方法之前运行的方法、@After 用于标记在每个测试方法之后运行的方法等。对于全局资源的初始化和释放可以通过 @BeforeClass 和 @AfterClass 来搞定。
  • 测试套件:JUnit 4允许你将多个测试类组合在一起,形成一个测试套件,然后可以一次运行所有测试类。这对于组织和管理测试非常有用。
  • 断言:JUnit 4提供了一系列的断言方法,用于验证测试中的条件是否为真。如果条件不满足,断言将引发测试失败。常见的断言方法包括 assertEquals、assertTrue、assertFalse、assertNull、assertNotNull 等。
  • 运行器(Runners):JUnit 4引入了运行器的概念,允许你扩展测试的执行方式。JUnit 4提供了一些内置的运行器,例如 BlockJUnit4ClassRunner 用于普通的 JUnit 测试类,还有一些用于特定用途的运行器,如 Parameterized 用于参数化测试。目前在 springboot 中,使用了 SpringRunner 其实也是 BlockJUnit4ClassRunner 的子类。

关于 Junit 的运行机制可以参考我之前写的一篇文章:你知道 Junit 是怎么跑的吗?

Mockito

Mockito 是一个用于模拟对象的框架,用于创建和配置模拟对象,以模拟外部依赖。Mockito 的主要焦点是模拟外部依赖,以便在单元测试中隔离被测试的代码,并确保它与外部依赖正确交互。和 JUnit 4 的区别在于,JUnit 4 是一个单元测试框架,用于编写和运行测试用例,JUnit 4 的主要焦点是定义和执行测试,以及管理测试生命周期。
关于 Mockito 的运行机制可以参考我之前写的一篇文章:聊一聊 Mockito

单元测试(UT)

在前面的测试分类中,单元测试主要是验证单个代码单元(通常是函数、方法、类等)的正确性;在实际的项目中,单元测试主要是对于一个封装好的工具类的测试。如在 DateUtil 工具类中有一个方法:

1
2
3
4
5
6
7
public static String getDate(Date date, String pattern) {
if (null == date) {
return null;
}
SimpleDateFormat sdf = new SimpleDateFormat(pattern);
return sdf.format(date);
}

右击选中方法,goto -> test,也可以通过相应的快捷键直接创建当前选中方法的测试用例。
image.png
相应的测试代码如下:

1
2
3
4
5
6
7
8
public class DateUtilTest {
@Test
public void test_getDate() {
int dateYear = DateUtil.getDateYear(new Date());
String yyyy = DateUtil.getDate(new Date(), "YYYY");
Assert.assertEquals(String.valueOf(dateYear), yyyy);
}
}

这里覆盖了正常的情况,对于传入 date 为 null 的分支并未覆盖到;所以对于强调覆盖率必须满足一定阈值的情况(之前的一个项目中,在 CI 流程中会对当前提供的代码覆盖率进行严格把控,比如行覆盖率比如达到 75% 才能被 merge),则对于不同分支逻辑也需要提供对应的用例。

1
2
3
4
5
6
// 当date 为 null 时,期望返回 null
@Test
public void test_getDate_when_date_null_thenReturn_null() {
String result = DateUtil.getDate(null, "YYYY");
Assert.assertEquals(null, result);
}

组件测试(CT)/集成测试(IT)

我们目前基于 SpringBoot test 的测试,大体可以归类于组件测试;这种情况只需要针对当前服务自己的组件进行设计用例;对于可能涉及到的上下游依赖,一般可以通过 mock 的方式来绕过,从而使得当前应用的用例 focus 在自己的业务逻辑上。

使用 mock 代替实际请求

场景描述:UserCaseService 中有个 getUserCaseList 方法,通过传入一个 UserCaseRequest 参数,然后去另一个服务拉取当前用户的事件列表;代码如下:

1
2
3
4
5
6
7
8
9
public Response<CaseResponse> getUserCaseList(UserCaseRequest request) {
Map<String, Object> param = new HashMap<>();
param.put("phone",request.getPhone());
param.put("pageNo",request.getPageNo());
param.put("pageSize",request.getPageSize());
JSONObject result = HttpUtils.useHHMApi("/miniapp/user/case", param);
Response<CaseResponse> response = result.toJavaObject(result, Response.class);
return response;
}

在 HttpUtils 中,底层是对 RestTemplate 的封装:

1
2
3
4
5
6
7
8
9
10
11
public static JSONObject request(String url, Map<String, Object> headers, Map<String, Object> param) {
HttpHeaders head = new HttpHeaders();
if (!ObjectUtils.isNull(headers)) {
for (String h : headers.keySet()) {
head.add(h, String.valueOf(headers.get(h)));
}
}
// HttpUtils.useHHMApi 底层实际发起拉取数据的地方
String entity = restTemplate.postForObject(url, new HttpEntity<Map>(param, head), String.class);
return JSONObject.parseObject(entity);
}

在上面那段代码中,会具体发送 http 请求到另一个服务去拉取数据。对于这种场景:

  • 1、需要保障用例不会受到对方服务的影响都能顺利执行。
  • 2、关注的是 getUserCaseList 这个方法本身的逻辑(这里举例,代码做了相应的简化)

因此,和实际运行的逻辑不同在于,在编写测试用例时,对于底层发起的 http 调用其实不是主要关注的,可以基于约定好的成功/失败的数据报文结构,通过 mock 的方式来代替实际 http 请求发送。

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void test_getUserCaseList() {
// 提供 mock 条件
Mockito.when(restTemplate.postForObject(Mockito.any(String.class), Mockito.any(HttpEntity.class), Mockito.any(Class.class))).thenReturn(MockData.mockMiniAppUserCaseResponseData(true));

UserCaseRequest request = new UserCaseRequest();
request.setPhone("15215608668");
request.setPageNo(1);
request.setPageSize(10);
Response<UserCaseResponse> response = naturalService.getUserCaseList(request);
Assert.assertEquals("200", response.getCode());
}

通过这种形式,则可以有效屏蔽因为三方服务对于我们自己当前用例的影响(核心的还是要关注在自己的业务逻辑上);

准备条件可以在 @Before 中体现
@Before
public void before() {
RestTemplate restTemplate = Mockito.mock(RestTemplate.class);
HttpUtils.setRestTemplate(restTemplate);
}

SpringBootTest 说明

在 test_getUserCaseList 中,naturalService 是一个spring bean,因此执行此用例我们需要依赖 spring 容器环境。

1
2
3
4
5
6
7
8
9
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 
classes = ServerApplication.class)
@RunWith(SpringRunner.class)
public class UserCaseTest {
@Autowired
private NaturalService naturalService;

// your test case
}

@SpringBootTest 在官文档中被描述用于 integration testing 使用的注解,其目的是用于启动一个 ApplicationContext,达到在无需部署应用程序或连接到其他基础设施即可执行集成测试。已上面的代码为例,其中:

  • webEnvironment 用于描述运行环境,主要包括以下几种类型:

    类型 描述
    MOCK 这个选项不启动真正的 Web 服务器,而是使用模拟的 Servlet 上下文来运行测试。这意味着你的应用程序的 Web 层(控制器、过滤器等)将在一个模拟的环境中运行,不会实际处理 HTTP 请求和响应。这种环境适用于单元测试和切片测试,通常用于测试应用程序的业务逻辑。
    RANDOM_PORT 这个选项会启动一个嵌入式的 Web 服务器,并随机选择一个可用的端口。测试将通过实际的 HTTP 请求和响应与应用程序的 Web 层交互。这种环境适用于端到端测试,可以测试整个 Web 栈,包括控制器、服务、数据访问等。
    DEFINED_PORT 这个选项也会启动嵌入式的 Web 服务器,但它会使用一个预定义的端口号。你可以通过 server.port 配置属性来指定端口号。与 WebEnvironment.RANDOM_PORT 不同,这个选项的端口号是固定的。这对于需要在已知端口上运行测试的情况很有用。
    NONE 这个选项完全不启动 Web 服务器。它用于纯粹的单元测试,不涉及任何 Web 层的逻辑。在这种环境中,通常只测试应用程序的业务逻辑和服务层,不测试与 HTTP 请求和响应相关的内容。
  • classes 属性用于指定要加载的配置类,这些配置类将用于初始化 Spring Boot 应用程序上下文。通过 classes 属性,可以控制在测试中加载的 Spring Bean 配置,以适应不同的测试需求。在上述案例中,ServerApplication.class 是当前项目的启动类,表示在测试中加载整个应用程序上下文。

  • @RunWith 用于指定测试运行器(Runner),JUnit 4 默认运行器是 BlockJUnit4ClassRunner ,在 Spring 中对应的是 BlockJUnit4ClassRunner 的子类 SpringJUnit4ClassRunner,而上述代码中的 SpringRunner 和 SpringJUnit4ClassRunner 是一样的,从 SpringRunner 类的源码注释中可以看到,SpringRunner是 SpringJUnit4ClassRunner 的别名(SpringRunner is an alias for the SpringJUnit4ClassRunner)。

使用 H2 内存数据库来代替实际库

在编写用例时,大多数情况下,我们需要依赖数据库的数据进行场景描述;但是一般情况下,即使是测试库,用于作为测试用例的依赖也是不合适的。因此在实践过程中,一般会使用 H2 来代替实际使用的类似 Mysql 数据库来进行测试,实现数据层面的环境隔离。使用 H2 作为测试用例依赖数据库也比较简答,在 pom 中引入如下 H2 的依赖。然后在测试时指定对应 H2 的配置文件代替 Mysql 的配置文件即可。制定配置参考下一小节。

1
2
3
4
5
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>

使用指定的测试配置文件

如前面提到,如何我们期望测试用例的环境和实际的环境隔离,则可以使用一个单独的配置文件来描述。比如使用 H2 代替实际的数据库。

  • 测试配置文件 application-test.yaml

    1
    2
    3
    4
    5
    6
    7
    8
    spring:
    # 使用 H2 作为数据源
    datasource:
    url: jdbc:h2:mem:customdb
    driverClassName: org.h2.Driver
    username: root
    password: password
    # 省略其他配置
  • 指定配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, 
    classes = ServerApplication.class)
    @RunWith(SpringRunner.class)
    @PropertySource(value = {"classpath:application-test.yaml"})
    public class UserCaseTest {
    @Autowired
    private NaturalService naturalService;

    // your test case
    }

    做好测试资源的清理

    做好测试资源清理是一个好用例具备的基本前提;如何两个研发同事需要依赖某一个表的数据进行用例设计,如果每个人都没有做好自己用例的资源清理,则在实际的用例执行过程中则会出现用例之间的相互干扰。另外,如过对于一些团队,没有使用 H2 来代替实际的测试库,那么在用例不断执行的过程中,会给测试库产生相当于的测试脏数据。基于上面两个前提,所以我们在设计用例时,特别是涉及到数据或者状态变更的场景时,一定要做好相应的资源清理。如:用户注册的场景逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @Test
    public void test_register(){
    UserDto userDto = new UserDto();
    userDto.setPhone("test number");
    userDto.setName("test");
    userDto.setNickName("test");
    userDto.setPassword("test pwd");
    // 注册用户
    bool success = userService.register(userDto);
    Assert.assertTrue(success);
    }

    在上面这段用例,可能会出现的情况:

  • 如果用户表中没有做基于名字或者手机号的唯一性校验,则在我们的表中可能会出现很多 name 为 test 的用户。(每执行一次,则产生一条记录)

  • 如果用户表做了唯一性约束,那么当第一次执行完之后,第二次执行时则可能会报错,当前用例会执行失败。

所以,在优化这个用例时,就可以将用例执行完之后的数据清除掉。具体做法有两种:

  • 1、在当前用例中执行,比如通过 try finally,在 finally 块中执行删除插入的数据
  • 2、在 @After 中执行删除插入的数据(@After 注解描述的方法,会在每个用例执行完之后执行,通过用于做资源清理)

    小结

    本篇主要针对如何编写测试用例进行了简单的介绍;包括场景的测试方式分类、测试工具;并通过几个小的测试用例对单元测试、组件测试和集成测试做了分析。最后针对日常研发中,如何做好测试编写和如何做好测试资源释放给了目前主流方案的建议和使用说明。
作者

卫恒

发布于

2023-09-08

更新于

2023-09-08

许可协议

评论