单测存在的痛点
- 运行一个 case 耗时过长(测试上下文中有太多依赖|设计中的耦合性太高)
- 一个方法中有太多 test case(数据对象情况复杂,被测试的方法做了太多事情)
- 太多的 setup/teardown(表示被测试类的耦合性太高)
- 数据在数据库中,有操作权限的成员太多极易被修改,极易造成混乱(H2 数据库)
- 改变一个地方,多处测试受影响,也许是测试的设计问题,也许是实现代码中有过多依赖
Spock 测试框架简介
Spock 是一个测试框架,它的核心特性有以下几个:
- 可以应用于 java 或 groovy 应用的单元测试框架。
- 测试代码使用基于 groovy 语言扩展而成的规范说明语言(specification language)。
- 通过 junit runner 调用测试,兼容绝大部分junit的运行场景(ide,构建工具,持续集成等)。
- 框架的设计思路参考了 JUnit,jMock,RSpec,Groovy,Scala,Vulcans……
Spock in 5 minutes
所需依赖
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- Mandatory dependencies for using Spock -->
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-spring</artifactId>
<version>1.1-groovy-2.4</version>
<scope>test</scope>
</dependency>
<!-- enables mocking of classes (in addition to interfaces) -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.9.3</version>
<scope>test</scope>
</dependency>
<!-- enables mocking of classes without default constructor -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.6</version>
<scope>test</scope>
</dependency>
基本结构
Spock 的测试类均派生自 Specification
类,命名遵循 Java 规范。每个测试方法可以直接用文本作为方法名,方法内部由 given-when-then
的三段式块(block)组成。除此以外,还有 and、where、expect
等几种不同的块。
@Title("测试的标题")
@Narrative("""关于测试的大段文本描述""")
// 标明被测试的类是Adder
@Subject(TestedClassName)
// 当测试方法间存在依赖关系时
// 标明测试方法将严格按照其在源代码中声明的顺序执行
@Stepwise
class TestCaseClass extends Specification {
@Shared //在测试方法之间共享的数据
SomeClass sharedObj
def setupSpec() {
//TODO: 设置每个测试类的环境
}
def setup() {
//TODO: 设置每个测试方法的环境,每个测试方法执行一次
}
@Ignore("忽略这个测试方法")
@Issue(["问题#23","问题#34"])
def "测试方法1" () {
given: "给定一个前置条件"
//TODO: code here
and: "其他前置条件"
expect: "随处可用的断言"
// TODO: code here
when: "当发生一个特定的事件"
// TODO: code here
and: "其他的触发条件"
then: "产生的后置结果"
// TODO: code here
and: "同时产生的其他结果"
where: "不是必需的测试数据"
input1 | input2 || output
... | ... || ...
}
// 只测试这个方法,而忽略所有其他方法
@IgnoreRest
// 设置测试方法的超时时间,默认单位为秒
@Timeout(value = 50, unit = TimeUnit.MILLISECONDS)
def "测试方法2"() {
// TODO: code here
}
def cleanup() {
// TODO: 清理每个测试方法的环境,每个测试方法执行一次
}
def cleanupSepc() {
// TODO: 清理每个测试类的环境
}
}
断言
- 在 then 块里,不需要
assertEquals("断言提示", left, right)
这样的方式,直接写left == right
这样的逻辑表达式即可。 - 借助 Groovy 的语法,Spock 使用 N * method() 来判定该方法是否被调用了 N 次。而
N * method() >> true
则表示方法 method 被调用 N 次,且每次该方法的返回值均为 true。
参数化测试
Spock 使用 where 块,为测试方法提供表格化的测试数据。其中表头为测试方法中要用在断言中的变量名称或者表达式,用|
分隔输入参数,用||
分隔输入与输出。这些参数,可以用 #参数名
的方式在 @Unroll
描述或者测试方法名里定义,或者在测试方法的参数列表里定义,然后在 where 块中使用。
// where 块中的每行参数都转换为一个独立的测试用例
@Unroll("test #para0, #para1")
def "测试方法3"(int first, int second) {
// ... ...
where: "parameterized sample"
para0 | para1 | para2 || para3 | first | second
10 | 2 | 3 || 7 | 2 | 5
}
简单实例
被测试类(src/main/java/SumUtils.java)
public class SumUtils {
public static int sum(int a, int b) {
return a + b;
}
}
测试类(src/test/groovy/SumUtilsTest.groovy)
@Title("测试加法工具类")
@Subject(SumUtils)
class SumUtilsTest extends Specification {
@Unroll
def "test Sum"() {
expect:
res == SumUtils.sum(a , b)
where:
a | b | res
1 | 1 | 2
0 | 0 | 0
-1 | -1 | -2
0 | -1 | 0
}
}
常用 Spock 语法
Mocking
Mocking 是描述规范下的对象与其协作者之间(强制)交互的行为。
1 * subscriber.receive("hello")
| | | |
| | | argument constraint
| | method constraint
| target constraint
cardinality
创建 Mock 对象
def subscriber = Mock(Subscriber)
def subscriber2 = Mock(Subscriber)
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()
注入 Mock 对象
class PublisherSpec extends Specification {
Publisher publisher = new Publisher()
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()
def setup() {
publisher.subscribers << subscriber // << is a Groovy shorthand for List.add()
publisher.subscribers << subscriber2
}
调用频率约束(cardinality)
1 * subscriber.receive("hello") // exactly one call
0 * subscriber.receive("hello") // zero calls
(1..3) * subscriber.receive("hello") // between one and three calls (inclusive)
(1.._) * subscriber.receive("hello") // at least one call
(_..3) * subscriber.receive("hello") // at most three calls
_ * subscriber.receive("hello") // any number of calls, including zero
// (rarely needed; see 'Strict Mocking')
目标约束(target constraint)
1 * subscriber.receive("hello") // a call to 'subscriber'
1 * _.receive("hello") // a call to any mock object
方法约束(method constraint)
1 * subscriber.receive("hello") // a method named 'receive'
1 * subscriber./r.*e/("hello") // a method whose name matches the given regular expression (here: method name starts with 'r' and ends in 'e')
参数约束(argument constraint)
1 * subscriber.receive("hello") // an argument that is equal to the String "hello"
1 * subscriber.receive(!"hello") // an argument that is unequal to the String "hello"
1 * subscriber.receive() // the empty argument list (would never match in our example)
1 * subscriber.receive(_) // any single argument (including null)
1 * subscriber.receive(*_) // any argument list (including the empty argument list)
1 * subscriber.receive(!null) // any non-null argument
1 * subscriber.receive(_ as String) // any non-null argument that is-a String
1 * subscriber.receive(endsWith("lo")) // any non-null argument that is-a String
1 * subscriber.receive({ it.size() > 3 && it.contains('a') })
// an argument that satisfies the given predicate, meaning that
// code argument constraints need to return true of false
// depending on whether they match or not
// (here: message length is greater than 3 and contains the character a)
Stubing
Stubbing 是让协作者以某种方式响应方法调用的行为。在对方法进行存根化时,不关心该方法的调用次数,只是希望它在被调用时返回一些值,或者执行一些副作用。
subscriber.receive(_) >> "ok"
| | | |
| | | response generator
| | argument constraint
| method constraint
target constraint
如:subscriber.receive(_) >> "ok"
意味,不管什么实例,什么参数,调用 receive 方法皆返回字符串 ok
返回固定值
使用 >>
操作符,返回固定值
subscriber.receive(_) >> "ok"
返回值序列
返回一个序列,迭代且依次返回指定值。如下所示,第一次调用返回 ok,第二次调用返回 error,以此类推
subscriber.receive(_) >>> ["ok", "error", "error", "ok"]
动态计算返回值
subscriber.receive(_) >> { args -> args[0].size() > 3 ? "ok" : "fail" }
subscriber.receive(_) >> { String message -> message.size() > 3 ? "ok" : "fail" }
产生副作用
subscriber.receive(_) >> { throw new InternalError("ouch") }
链式响应
subscriber.receive(_) >>> ["ok", "fail", "ok"] >> { throw new InternalError() } >> "ok"
完整单测实例
@Title("招行业务类测试")
@Subject(CmbPayLocalServiceImpl)
class CmbPayLocalServiceImplTest extends Specification {
CmbPayLocalServiceImpl cmbPayLocalService = new CmbPayLocalServiceImpl()
ConfigBaseService configBaseService = Mock()
WeChatPayHelper helper = Mock()
CodeUrlMapLocalService codeUrlMapLocalService = Mock()
void setup() {
cmbPayLocalService.configBaseService = configBaseService
cmbPayLocalService.weChatPayHelper = helper
cmbPayLocalService.codeUrlMapLocalService = codeUrlMapLocalService
}
def "GetCodeUrl"() {
given: "定义一些变量"
def enterpriseNum = "AC310008"
def mchId = "308999170120019"
def fgTradeNo = System.currentTimeMillis().toString()
def start = "20190813130000"
def end = "20190815130000"
def param = new WeChatQrCodePayParam(
enterpriseNum: enterpriseNum,
mchId: mchId,
outTradeNo: System.currentTimeMillis().toString(),
body: "Groovy 测试",
totalFee: "1",
notifyUrl: ""
)
def record = new TransRecord<>(
enterpriseNum: enterpriseNum,
mchId: mchId,
fgTradeNo: fgTradeNo,
state: "1"
)
configBaseService.getConfig(_) >> ""
codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true
when:
Map<String, Object> result = cmbPayLocalService.getCodeUrl(param, record)
then:
def url = result.get("url")
}
@Unroll
def "GetCodeUrlWithWheretime"() {
when:
def enterpriseNum = "AC310008"
def mchId = "308999170120019"
def fgTradeNo = System.currentTimeMillis().toString()
def param = new WeChatQrCodePayParam(
enterpriseNum: enterpriseNum,
mchId: mchId,
outTradeNo: System.currentTimeMillis().toString(),
body: "Groovy 测试",
totalFee: "1",
notifyUrl: ""
)
def record = new TransRecord<>(
enterpriseNum: enterpriseNum,
mchId: mchId,
fgTradeNo: fgTradeNo,
state: "1"
)
configBaseService.getConfig(_) >> "308999170120019"
codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true
then:
StringUtils.isNotBlank((String)cmbPayLocalService.getCodeUrl(param, record).get("CodeUrl"))
where:
start | end | url
"20190813130000" | "20190815130000" | true
"20190813130000" | "20191015130000" | true
"20190813130000" | "20191015130000" | true
"20190813130000" | "20191015130000" | true
}
}
组件:Groovy
Groovy 是 Java 平台上设计的面向对象编程语言。这门动态语言拥有类似 Python、Ruby 和 Smalltalk 中的一些特性,可以作为Java平台的脚本语言使用。
Groovy 的语法与 Java 非常相似,以至于多数的 Java 代码也是正确的 Groovy 代码。Groovy 代码动态的被编译器转换成 Java 字节码。由于其运行在 JVM 上的特性,Groovy 可以使用其他 Java 语言编写的库。
- 语句结束用的分号; 是可选的
- 通过
def
关键字定义变量和方法 - 通常用一对双引号表示一个字符串常量,"这是一个字符串常量",使用
\
作为转义符 - Groovy 将 0、null、空的数组或空字符串视为 false,非 0 值、有效的引用、非空的数组和非空的字符串则均视为 true
- 使用关键字
def
表示动态类型,类似 JavaScript 中的var
- 类的访问修饰默认都是 public,而 field 默认是 private(但是可以用类实例来直接访问)
- 使用 name, ${age > 18}"`
- 对象初始化方式类似 JSON,整个表达式用圆括号包围,field 与值以冒号间隔成对出现,数组或序列用中括号包围,数组索引从 0 开始,用[:]表示一个空的 Map
- 使用
_
作为参数占位符。它既可以用来指代参数、方法,也可以指代返回值或者where
块中的测试参数
// 定义字符串
String str = ""
// 定义列表
List list = [1, 2, 3, 4, 5]
// 定义 map
Map map = [key1:val1, key2:val2]
融合付规范
必须遵守 AIR 原则
说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。
- A:Automatic(自动化)
单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。
- I:Independent(独立性)
保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 反例:method2需要依赖method1的执行,将执行结果作为method2的输入。
- R:Repeatable(可重复)
单元测试是可以重复执行的,不能受到外界环境的影响。 说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。 正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。
保证测试粒度足够小
对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。
说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。
也有按照场景写单元测试用例的,一个方法对应多个场景。
核心业务、核心应用、核心模块的增量代码确保单测通过
说明:新增代码及时补充单元测试,如果新增代码影响了原有单元测试,请及时修正。
单测的基本目标
语句覆盖率达到 70%;核心模块的语句覆盖率和分支覆盖率都要达到 100%;
说明:在工程规约的应用分层中提到的DAO层,Manager层,可重用度高的Service,都应该进行单元测试。
单测代码遵守 BCDE 原则
以保证被测试模块的交付质量
- B:Border,边界值测试,包括循环、 特殊取,边界值测试包括循环、 特殊取特殊时间点、数据顺序等。
- C:Correct,正确的输入,并得到预期结果。 ,正确的输入并得到预期结果。
- D:Design,与设计文档相结合,来编写单元测试。 ,与设计文档相结合来编写单元测试。
- E:Error,强制错误信息输入(如:非法数据、异常流程业务允许等),并得 ,强制错误信息输入(如:非法数据、异常流程业务允许等),并得到预期结果。
与数据库交互规范
对于数据库相关的查询,更新,删除等操作,不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。
反例:删除某一行数据的单元测试,在数据库中,先直接手动增加一行作为删除目标,但是这一行新增数据并不符合业务插入规则,导致测试结果异常。
可以使用 h2 内存数据库保证单测不污染测试数据库
不可测的代码建议做必要的重构
对于不可测的代码建议做必要的重构,使代码变得可测,避免为了达到测试要求而书写不规范测试代码。
确定单测范围
开发人员需要和测试人员一起确定单元测试范围,单元测试最好覆盖所有测试用例(UC)。
心态转变
- 那是测试同学干的事情。
- 单元测试代码是多余的。 汽车的整体功能与各单元部件的测试正常否是强相关。
- 单元测试代码不需要维护。 一年半载后,那么几乎处于废弃状态。
- 单元测试与线上故障没有辩证关系。好的单元测试能最大限度规避线上故障。