单元测试框架Spock【开发实践】

一、基础知识与背景

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
  • 验证对象属性的断言(两个关键字):espectassert
  • 检查集合内容的断言: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来模拟:(以下以静态方法为例)

  1. 加上@RunWith(PowerMockRunner.class)注解和@PowerMockRunnerDelegate(Sputnik.class)注解
  2. 加上@PrepareForTest(静态方法所在类.class)注解
  3. 在mock静态方法前调用PowerMockito.mockStatic(静态方法所在类.class)
  4. mock静态方法
  5. 调用被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()

  1. 在then块中使用异常断言捕获指定异常(不会中断测试程序):def exception = thrown(expectedException)
  2. 使用异常对象调用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

  1. 通过MyBatis的SqlSession启动mapper实例(避免通过Spring启动加载上下文信息)。
  2. 通过内存数据库(如H2)隔离大家的数据库连接(完全隔离不会存在互相干扰的现象)。
  3. 通过DBUnit工具,用作对于数据库层的操作访问工具。
  4. 通过扩展Spock的注解,提供对于数据库创建和数据Data加载的方式。

参考文章列表

写有价值的单元测试
通义千问
吃透单元测试:Spock单元测试框架的应用与实践
Spock单元测试框架介绍以及在美团优选的实践

  • 42
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值