一、基础知识与背景
1.1 什么是单元测试
单元测试指对软件中的最小可测试单元进行的检查和验证。这里的“最小可测试单元”通常是指软件代码中的基本组成元素,如一个函数、一个方法、一个类或一个模块,具体取决于所使用的编程语言和项目结构。
1.2 单元测试的目的
尽早在尽量小的范围内暴露错误,降低修复成本。
提高测试的代码覆盖率,降低上线时的紧张指数。
减少代码中的bug数量,bug数量和kpi挂钩,直接关系到升职加薪。
1.3 主流的单元测试工具
工具 | 原理 | 最小Mock单元 | 对被Mock方法的限制 | 上手难度 | IDE支持 |
---|---|---|---|---|---|
Mockito | 动态代理 | 类 | 不能Mock私有/静态和构造方法 | 较容易 | 很好 |
Spock | 动态代理 | 类 | 不能Mock私有/静态和构造方法 | 较复杂 | 一般 |
PowerMock | 自定义类加载器 | 类 | 支持任何方法 | 较复杂 | 较好 |
JMockit | 运行时字节码修改 | 类 | 不能Mock构造方法 | 较复杂 | 一般 |
TestableMock | 运行时字节码修改 | 方法 | 支持任何方法 | 很容易 | 一般 |
1.4 为什么要用Spock
Spock测试框架基于Groovy,并吸收了许多其他测试框架的优点,其主要特点如下:
- 使用Groovy这种动态语言来编写测试代码,无缝兼容Java(Groovy最终会编译为class文件),Intellij idea已经集成支持Groovy的插件。
- 遵从BDD(行为驱动开发)模式,让测试代码更规范,有助于提升代码的质量,测试代码更具可读性,降低后期维护难度。
- 支持Mock对象和Stubbing行为。
- 通过where块来实现数据驱动测试,大大减小测试代码的数量,还有助于提升测试的代码覆盖率。
1.5 一些术语
- BBD模式:强调以自然语言描述测试行为,使得测试代码更易于理解且与业务需求紧密关联。测试用例通常由given-when-then三段式结构组成,分别代表设置测试环境和初始数据、触发被测试方法或操作、断言预期结果或系统状态。
- Mock对象:Mock对象是对真实对象的一种模拟,它具有与真实对象相似的接口,但在测试期间并不执行实际的操作。而是根据测试的需要,预先设定好对方法调用的响应。使用Mock对象来模拟或替换系统中某些不容易构造或获取的对象,通过创建其替代品(即 Mock 对象)来模拟其预期行为,使测试能够专注于被测代码的逻辑,而不受外部因素干扰。
- 数据驱动测试:数据驱动测试(Data-Driven Testing, DDT)是一种软件测试方法,其核心理念是将测试逻辑与测试数据分离,使得测试用例可以根据不同的数据集重复执行相同的测试流程。这种方法旨在提高测试覆盖率、减少重复工作,并增强测试的灵活性与可维护性。
- 代码覆盖率:衡量的是在执行测试集时,程序中哪些代码行、分支、条件、函数等编程元素被执行过,以及它们被执行的比例。
- 断言:断言用于在代码位置上强制检查某个条件是否为真。如果条件为真(即预期的情况发生),程序继续正常执行。如果条件为假(即出现了未预期的状态),断言会触发一个错误,导致程序停止执行,并伴随着详细的错误消息,指出断言失败的具体位置以及相关的上下文信息。
二、使用Spock框架进行单元测试
2.1 SpringBoot集成Spock框架Demo
@SpringBootTest
class UserServiceSpec extends Specification {
@Autowired
private UserService userService
// 测试方法
def "test description"() {
// given块
given: "initial conditions"
// 设置前置条件或数据
// when块
when: "action is performed"
// 执行被测试方法或操作
// then块
then: "expected outcome"
// 验证结果或状态
// where块
where:
value | expected
"input1" | "output1"
"input2" | "output2"
}
}
- Spock测试类的工程目录:
src/test/groovy
。 - Spock测试类的文件格式:
XxxSpec.groovy
。Spock测试类的类名建议也使用大驼峰命名格式,且使用Spec作为后缀名。Spock测试类的扩展名为groovy。 - Spock测试类的格式:如上图,Spock测试类看起来像没有权限修饰符的java类,且必须继承自Specification。Spock测试类的内部和java不同,使用了Groovy语法,由一个或多个测试方法组成。
- Spock测试类上加
@SpringBootTest
的作用:用于启动Spring应用上下文,在单元测试时不需要,在集成测试时测试类可以利用@Autowired
注入所需的Bean。
2.2 测试方法的基本结构
测试方法的方法体由given-when-then
三段式结构组成,用于使用一组或多组数据来进行同种测试。
2.2.1 方法名
使用def
来定义测试方法,方法名置于def
之后、方法体之前,是一个双引号包起来的字符串,需要清晰明了地描述本测试行为或场景。
2.2.2 given块(数据块)
given块使用given : "given block description"
来定义,其中块名/描述部分是可省略的。
given块用于设置测试的前置条件或初始化数据,跨行定义块内元素,不需要空两格。使用def
来定义块内初始化数据,格式如def 变量名 = 变量值
,使用的是弱类型,无需指明变量类型。如果变量需要跨测试方法共享,则需要使用@Shared
注解标记。
def "test some method"() {
given: "given block description"
def localData = "local data"
@Shared
def sharedData = "shared data"
// ...
}
2.2.3 when块(行动块)
when块的定义方式参考given块。
when块用于执行被测试的方法或操作,使用given块中的变量来触发待测试的行为得到结果,并将结果放入变量中。
def "test some method"() {
// ...
when: "when block description"
// 以input为参数来调用testMethod()方法,并将结果放入result变量
def result = testMethod(input)
// ...
}
2.2.4 then块(验证块)
then块的定义方式参考given块。
在then块中使用断言来验证when块中得到的结果是否符合预期。Spock提供了丰富的断言:
- 比较值的断言:
==
,!=
,>
,<
,>=
,<=
- 异常检查断言:
thrown()
- 条件断言(组合多个断言使用):
and
,&&
,or
,||
,not
- 验证对象属性的断言(两个关键字):
espect
,assert
- 检查集合内容的断言:
contains()
,containsAll()
,isEmpty()
,size()
,every(it > 0)
补充:上述断言中,有括号的都是方法,可以通过被检查的集合/对象点出来,需要在expect块中使用。
def "test some method"() {
// ...
then: "then block description"
// 比较值的断言
result != "expected output"
// 异常断言,验证是否抛出了指定异常
thrown(IllegalArgumentException)
// 条件断言
num1 == 10 && num2 == -10
// assert关键字,后面跟一个布尔表达式
asset num == 10
expect:
// 检查集合内容的断言
nums.contains(10)
nums.every(it == 10)
// ...
}
2.3 Spock类的生命周期方法
def setupSpec() {
// 执行一次,整个Specification开始前
}
def setup() {
// 每个测试方法执行前
}
def cleanup() {
// 每个测试方法执行后
}
def cleanupSpec() {
// 整个Specification结束后
}
2.4 Mock对象和Stubbing行为
在测试方法的given块或setup()方法中Mock对象和Stubbing行为。
2.4.1 Mock对象
// 可以使用Mock()来获得指定类的Mock对象
def dependencyMock = Mock(DependencyClass)
// 也可以使用Stub()来获得指定类的Mock对象
def dependencyStub = Stub(DependencyClass)
2.4.2 Stubbing行为
Stubbing行为指调用mock对象指定方法时返回预设的值或触发预设的行为(如抛异常)。
// 预设方法返回固定值(下划线用于匹配任意参数,无需参数则不用使用)
dependencyMock.someMethod(_) >> "mocked response"
// 预设方法返回列表
dependencyMock.getListMethod(_) >> ["str1", "str2"]
// 预设方法抛出异常
dependencyMock.anotherMethod(_) >> { throw new IllegalArgumentException() }
// 预设方法根据参数动态返回值
dependencyMock.methodWithArgs(_ as String, _ as Integer) >> { String s, int i -> "$s - $i" }
// 预设方法多次调用时的不同返回值
(1..3).each { index ->
dependencyMock.repeatedMethod(_) >> "response_$index"
}
// 使用closure模拟方法内部行为
dependencyMock.complexMethod(_) >> {
// 执行一些逻辑,返回结果
}
2.4.3 Mock静态方法(以及final方法和private方法)
Spock基于动态代理实现,无法模拟静态方法、final方法和私有方法。但在Spock中可以通过powermock来模拟:(以下以静态方法为例)
- 加上
@RunWith(PowerMockRunner.class)
注解和@PowerMockRunnerDelegate(Sputnik.class)
注解 - 加上
@PrepareForTest(静态方法所在类.class)
注解 - 在mock静态方法前调用
PowerMockito.mockStatic(静态方法所在类.class)
- mock静态方法
- 调用被mock的静态方法
@RunWith(PowerMockRunner.class)
@PowerMockRunnerDelegate(Sputnik.class)
@PrepareForTest(ClassWithStaticMethod.class)
def "test method using mocked static"() {
given:
PowerMockito.mockStatic(ClassWithStaticMethod.class)
1 * ClassWithStaticMethod.staticMethod() >> "mocked result"
when:
def result = ClassWithStaticMethod.staticMethod()
then:
result == "mocked result"
}
2.5 使用where块实现数据驱动测试
2.5.1 where块(数据表格)的使用
在测试方法中使用where块定义一组或多组输入/输出数据,实现数据驱动测试(多组数据进行同种测试),每个数据行都会触发一次given-when-then流程。
使用where块进行驱动测试,需要按照一定格式来定义given-when-then
,简而言之就是要“参数化”,在given
块中的定义的变量的值要用参数(和变量不同,无需使用def
定义),在when
块中得到的结果值要用变量存储,在then
块中的验证要基于上述的变量和参数来验证。完成given-when-then
的“参数化”后,在where子句中以表格的方式设置输入/输出数据即即可。
def "test some method"() {
// ...
where:
// 表头(可以有多个变量)
value | expected
// 表头对应的数据行
"input1" | "output1"
"input2" | "output2"
}
搭配@Unroll
注解来进行数据驱动测试
不加@Unroll
注解,那么会把所有的测试用例返回一个结果,即所有测试用例都通过了则显示通过,否则不通过,不通过时不会显示是具体哪条数据行没有通过。
通过在测试方法上加@Unroll
,测试时会显示各个数据行的测试结果。每个数据行对应一个结果行,显示的是测试方法名。其中,测试方法名中可以使用#变量名
来获取本次此时的数据,用于定位测试结果的数据行。
class IDNumberUtilsGroovyTest extends Specification {
@Unroll
def "身份证号:#idNO 的生日,年龄是:#result"() {
expect: "执行以及结果验证"
IDNumberUtils.getBirthAge(idNO) == result
where: "数据驱动测试"
idNO || result
"410225199208091234" || ["birthday": "1992-08-09", "age": "29"]
"410225199308091234" || ["birthday": "1993-08-09", "age": "28"]
"410225199408091234" || ["birthday": "1994-08-09", "age": "27"]
"410225199508091234" || ["birthday": "1995-08-09", "age": "26"]
"410225199608091234" || ["birthday": "1996-08-09", "age": "25"]
}
}
三、几个特殊场景下的测试Demo
3.1 测试异常信息
最笨的方法是在测试类中使用try-catch
来测试异常信息,当设计多个异常时,无疑需要多个try-catch
,使得代码不够简洁。
一般的做法是使用JUnit的ExpectedException方式,或者使用@Test(expected = BusinessException.class)
注解,但只能验证异常类型和异常信息,无法验证异常code。
这里,我们可以使用Spock内置的异常断言thrown()
:
- 在then块中使用异常断言捕获指定异常(不会中断测试程序):
def exception = thrown(expectedException)
。 - 使用异常对象调用errorCode和errorMessage来获取code和massage,然后验证异常的code和massage。
then: "捕获异常并设置需要验证的异常值"
def exception = thrown(expectedException)
// 参数化code和massage
exception.errorCode == expectedErrCode
exception.errorMessage == expectedMessage
}
3.2 测试DAO层
3.2.1 测试DAO层的特殊性
DAO层的测试不能使用Mock,否则无法验证SQL是否正确,需要去查询数据库测试。
最容易想到的方法是使用@SpringBootTest
注解启动Spring应用上下文,从而可以获取Mybatis、Mapper实例进行数据库操作。这种方式很方便,但存在一定的缺点:
- 启动上下文耗时长。
- 若因为其他地方的问题导致启动失败,会导致无法进行DAO层的测试。
- 需要到数据库尽可能隔离,避免污染数据,不同DAO层方法的测试之间相互影响。
3.2.2 测试DAO层的方案
(虽然很规范,但是很麻烦~我还是选@SpringBootTest
)
- 通过MyBatis的SqlSession启动mapper实例(避免通过Spring启动加载上下文信息)。
- 通过内存数据库(如H2)隔离大家的数据库连接(完全隔离不会存在互相干扰的现象)。
- 通过DBUnit工具,用作对于数据库层的操作访问工具。
- 通过扩展Spock的注解,提供对于数据库创建和数据Data加载的方式。
参考文章列表
写有价值的单元测试
通义千问
吃透单元测试:Spock单元测试框架的应用与实践
Spock单元测试框架介绍以及在美团优选的实践