mock 框架入门
mock 框架是仓颉单元测试框架,提供 API 用于创建和配置mock 对象 (也可称为“骨架对象”,即 API 函数体为空的对象),这些 mock 对象与真实对象拥有签名一致的 API 。mock 测试技术支持隔离测试代码,测试用例使用 mock 对象编码,实现外部依赖消除。
本文概述了如何使用 mock 框架测试及测试中所需的核心 API 。
概述
mock 框架具有以下特性:
- 创建 mock 对象和 spy 对象:测试时无需修改生产代码。
- API 配置简单:可配置 mock/spy 对象的行为。
- 单元测试框架部分:无缝集成单元测试框架的其他特性,错误输出可读。
- 自动验证配置行为:大多数情况下不需要多余的验证代码。
- 提供验证 API:用于测试系统内部的复杂交互。
用户使用场景包括:
- 简化测试设置和代码。
- 测试异常场景。
- 用轻量级 mock 对象替换代价高的依赖,提高测试性能。
- 验证测试复杂场景,如调用的顺序/数量。
使用 mock 框架
mock 框架本身是仓颉标准库中单元测试的一部分。使用 mock 框架前,需将 unittest.mock.*
和 unittest.mock.mockmacro.*
导入到测试文件中。
如果使用 cjpm 工具,仅需运行 cjpm test
命令即可自动启用 mock 框架。
如果直接使用 cjc ,参见[使用 cjc](#使用 cjc)。
示例
常见 mock 测试用例:
- 调用[mock 构造函数](#创建 mock 对象)创建 mock/spy 对象。
- 调用[配置 API](#配置 api)设置 mock 行为。
- 使用 mock 对象替代测试代码依赖。
- (可选)调用验证API来验证测试代码与 mock/spy 对象之间的交互。
以如下简单API为例:
public interface Repository {
func requestData(id: UInt64, timeoutMs: Int): String
}
public class Controller {
public Controller(
private let repo: Repository
) {}
public func findData(id: UInt64): ?String {
try {
return repo.requestData(id, 100)
}
catch (e: TimeoutException) {
return None
}
}
}
public class TimeoutException <: Exception {}
如果 Repository
实现不理想,比如可能包含复杂的依赖关系,实现在其他包中,或者测试太慢,mock 框架可以在不创建依赖的情况下测试 Controller
。
测试 findData
方法。
//导入mock框架包
from std import unittest.mock.*
from std import unittest.mock.mockmacro.*
@Test
class ControllerTest {
let testId: UInt64 = 100
let testResponse = "foo"
@TestCase
func testFindSuccessfully() {
//只需要创建mock,不用创建真正的Repository
let repository = mock<Repository>()
//使用@On宏配置testData行为
@On(repository.requestData(testId, _)).returns(testResponse)
//创建真正的Controller测试以便测试实际的实现
let controller = Controller(repository)
//运行测试代码
let result = controller.findData(testId)
//对结果运行断言
@Assert(result == Some(testResponse))
}
@TestCase
func testTimeout() {
let repository = mock<Repository>()
//设置getData抛出异常
@On(repository.requestData(testId, _)).throws(TimeoutException())
let controller = Controller(repository)
//底层实现抛出异常时,测试行为
let result = controller.findData(testId)
//对结果运行断言
@Assert(result == None)
}
}
创建 mock 对象
mock 构造函数可以通过调用 mock<T>
和 spy<T>
函数来创建两种对象:mock和spy,其中 T
表示被 mock 的类或接口。
public func mock<T>(): T
public func spy<T>(objectToSpyOn: T): T
mock 作为骨架对象,默认不对成员进行任何操作。 spy 作为一种特殊的 mock 对象用于封装某个类或接口的当前实例。默认情况下,spy 对象将其成员调用委托给底层对象。 其他方面,spy 和 mock 对象非常相似。
只有类(包括 final 类和 sealed 类)和接口支持 mock 。
参阅[使用 mock 和 spy 对象](#使用 spy 和 mock 对象)。
配置 API
配置 API 是框架的核心,可以定义 mock/spy 对象成员的行为(或重新定义 spy 对象)。
配置 API 的入口是 @On
宏调用。
@On(storage.getComments(testId)).returns(testComments)
示例中,如果 mock 对象 storage
接收到 getComments
方法的调用,并且指定了参数 testId
,则返回 testComment
。
如上行为即为打桩,桩(Stub, 模拟还未实现或无法在测试环境中执行的组件)需在测试用例主体内部先定义。
只有类和接口的实例成员(包括 final 成员)才能打桩。以下实体不能打桩:
- 静态成员
- 扩展成员
- 顶层函数,包括外部函数
一个完整的桩声明包含以下部分:
@On
宏调用中描述的桩签名。- 用于描述桩行为的[操作](#操作 API)。
- (可选)用于设置预期的基数说明符( cardinality specifier, 指定预期执行次数的表达式)。
- (可选)续体( continuation, 支持链式调用的表达式)。
mock 框架拦截匹配桩签名的调用,并执行桩声明中指定的操作。只能拦截 spy 对象和 mock 对象的成员。
桩签名
桩签名定义了与特定调用子集匹配的一组条件,包括以下部分:
- mock/spy 对象的引用,必须是单个标识符
- 成员调用
- 特定格式的参数调用,参见参数匹配器。
签名可以匹配以下实体:
- 方法
- 属性 getter
- 属性 setter
- 字段读操作
- 字段写操作
只要 mock/spy 对象调用相应的成员,并且所有参数(若有)都与相应的参数匹配器匹配时,桩签名就会匹配调用。
方法的桩的签名结构:<mock object name>.<method name>(<argument matcher>*)
。
@On(foo.method(0, 1)) // 带参数 0 和 1 的方法调用
@On(foo.method(param1: 0, param2: 1)) // 带命名参数的方法调用
当桩属性 getter/setter 或字段读/写操作时,使用 <mock object name>.<property or field name>
或 <mock object name>.<property or field name> = <argument matcher>
。
@On(foo.prop) //属性 getter
@On(foo.prop = 3) //参数为3的属性 setter
对运算符函数打桩时,运算符的接收者必须是对 mock/spy 对象的单个引用,而运算符的参数必须是参数匹配器。
@On(foo + 3) // 'operator func +',参数为3
@On(foo[0]) // 'operator func []',参数为0
参数匹配器
每个桩签名必须包含所有参数的参数匹配器。单个参数匹配器定义了一个条件,用于接受所有可能参数值的某个子集。
每个匹配器都是通过调用 Matchers
类的静态方法来定义的。
例如 Matchers.any()
是一个允许任何参数的有效匹配器。为了方便起见,提供省略 Matcher.
前缀的语法糖。
预定义的匹配器包括:
匹配器 | 描述 | 语法糖 |
---|---|---|
any() | 任何参数 | _ 符号 |
eq(value: Equatable) | value 结构相等( structural equality ,对象的值相等,不一定内存相同)的参数 | 允许使用单个 identifier 和常量字面量 |
same(reference: Object) | reference 引用相等(referential equality, 对象的引用相等,内存相同)的参数 | 允许单个identifier |
ofType<T>() | 仅匹配 T 类型的值 | |
argThat(predicate: (T) -> Bool) | 仅匹配由 predicate 筛选出的 T 类型的值 | |
none() | 匹配选项类型的 None 值 |
如果使用单个标识符作为参数匹配器,则优先选择结构相等的参数。
如果方法有默认参数,并且没有显式指定该参数,则使用 any()
匹配器。
示例:
let p = mock<Printer>() //假设print采用ToString类型的单个参数
@On(p.print(_)) //可以使用“_”特殊字符代替any()
@On(p.print(eq("foo"))) //只匹配“foo”参数
@On(p.print("foo")) //常量字符串可以省略显式匹配器
let predefined = "foo" //可以传递单个标识符,而不是参数匹配器
@On(p.print(predefined)) //如果类型相等,则使用结构相等来匹配
@On(p.print(ofType<Bar>())) //仅匹配Bar类型的参数
//对于更复杂的匹配器,鼓励使用以下模式
let hasQuestionMark = { arg: String => arg.contains("?") }
@On(p.print(argThat(hasQuestionMark))) //只匹配包含问号的字符串
正确选择函数重载依赖仓颉类型推断机制。可以使用 ofType
来解决与类型推断有关的编译时问题。
重要规则:函数调用作为参数匹配器时,会视为对匹配器的调用。
@On(foo.bar(calculateArgument())) //不正确,calculateArgument()不是匹配器
let expectedArgument = calculateArgument()
@On(foo.bar(expectedArgument)) //正确,只要 'expectedArgument' 是可等价的和/或引用类型
操作 API
mock 框架提供 API 来指定桩操作。触发桩后,打桩成员会执行指定的操作。如果调用与相应的 @On
宏调用指定的签名匹配,则会触发桩。
每个桩函数必须指定一个操作。
@On
宏调用返回的 ActionSelector
子类型会定义可用操作。操作列表取决于所打桩的实体。
通用(操作)
适用于所有桩的操作。
throws(exception: Exception)
:抛出exception
。throws(exceptionFactory: () -> Exception)
:调用exceptionFactory
去构造桩触发时抛出的异常。fails()
:如果触发了桩,则测试失败。
throws
用于测试桩成员抛出异常时的系统行为。fails
用于测试桩成员是否未被调用。
@On(service.request()).throws(TimeoutException())
方法和属性/字段 Getter
R 表示对应成员的返回类型。
returns()
:不做任何操作并返回()
,仅当R
为Unit
时可用。returns(value: R)
:返回value
。returns(valueFactory: () -> R)
:调用valueFactory
去构造桩触发时抛出的异常。returnsConsecutively(values: Array<R>)
,returnsConsecutively(values: ArrayList<R>)
:触发桩时,返回values
中的下一个元素。
@On(foo.bar()).returns(2) //返回 0
@On(foo.bar()).returnsConsecutively(1, 2, 3) //依次返回 1,2,3
属性/字段 Setter
doesNothing()
:忽略调用,不做任何操作。类似于返回 Unit 的函数的returns()
。 更多信息详见这里.
spy 操作
对于 spy 对象,可以使用其他操作来委托监控实例。
callsOriginal()
:调用原始方法。getsOriginal()
:调用原始属性 getter 或获取原始实例中的字段值。setsOriginal()
:调用原始属性 setter 或设置原始实例中的字段值。
预期
定义桩时会隐式或显式地向桩附加预期。桩可以定义期望的基数。操作( fails
和 returnsConcesecutively
除外)返回CardinalitySelector
的实例,该实例可以使用基数说明符自定义预期。
CardinalitySelector 定义了如下函数:
once()
atLeastOnce()
anyTimes()
times(expectedTimes: Int64)
times(min!: Int64, max!: Int64)
atLeastTimes(minTimesExpected: Int64)
anyTimes
说明符用于提升预期,即如果桩从未被触发,测试也不会失败。其他说明符都暗示了测试代码中特定桩的调用次数上下限。只要桩被触发的次数比预期的多,测试就会立即失败。下限在测试代码执行完毕后进行检查。
示例:
// example_test.cj
@Test
func tooFewInvocations() {
let foo = mock<Foo>()
@On(foo.bar()).returns().times(2)
foo.bar()
}
输出:
Expectation failed
Too few invocations for stub foo.bar() declared at example_test.cj:9.
Required: exactly 2 times
Actual: 1
Invocations handled by this stub occured at:
example_test.cj:6
如果没有自定义预期,mock框架使用默认预期:
操作 | 默认期望基数 | 允许自定义基数 |
---|---|---|
fails | 不可调用 | 否 |
returns | atLeastOnce | 是 |
returnsConsecutively | times(values.size) | 否 |
throws | atLeastOnce | 是 |
doesNothing | atLeastOnce | 是 |
(calls/gets/sets)Original | atLeastOnce | 是 |
桩链
returnsConsecutively 操作,once 和 times(n) 基数说明符返回一个续体实例。顾名思义,续体表示可以继续使用链,即指定某操作在前一个操作完全完成时立即执行。
续体本身只提供了一个返回新 ActionSelector
的 then()
函数。链上的所有操作都适用相同的规则。如果调用了 then()
,则必须指定新的操作。
总预期为各个链预期之和。如果在测试中指定了一个复杂的链,那么链的所有部分都会被触发。
@On(foo.bar()).returnsConsecutively(1, 2, 3, 4)
//同下
@On(foo.bar()).returnsConsecutively(1, 2).then().returnsConsecutively(3, 4)
//指定了一个桩,总共必须调用 NUM_OF_RETRIES 次
@On(service.request()).throws(TimeoutException()).times(NUM_OF_RETRIES - 1). //请求会先超时几次
then().returns(response).once() //第一个成功响应之后,停止发送请求
使用 spy 和 mock 对象
spy 对象和 mock 对象在配置上是类似的,只不过 spy 对象监控的是当前实例。
主要区别如下:成员调用没有触发任何桩时,spy 对象调用底层实例的原始实现,mock 对象抛出运行时错误(并且测试失败)。
mock 对象无需创建真正的依赖来测试 API ,只需配置特定测试场景所需的行为。
spy 对象支持重写真实实例的可观察行为。只有通过 spy 对象引用的调用才会被拦截。创建 spy 对象对原始 spy 实例的引用无影响。spy 调用自己的方法不会被拦截。
let serviceSpy = spy(service)
//模拟超时,继续使用真正的实现
@On(serviceSpy.request()).throws(TimeoutException()).once().then().callsOriginal()
//测试代码必须使用'serviceSpy'引用
mock 依赖
接口始终可以被 mock 。从另一个包 mock 一个类时,类本身和它的超类必须按特定方式编译, 即只能 mock 预编译库(如 stdlib )中的接口,不能 mock 类。
使用 cjc 编译
对于 cjc 来说,mock 是通过 --mock
标志来控制的。
如果想 mock 特定包中的类 p
,添加 --mock=on
标志到 cjc 进行调用。
在编译依赖
p
的包时,也必须添加此标志。
在测试中使用mock对象( cjc--test
)不需要额外的标志。
使用 cjpm 编译
cjpm 会自动检测 mock 使用,并生成正确的 cjc 调用,确保可以从任何从源代码编译的包中 mock 类。
还可以使用 cjpm 配置文件控制哪些包支持 mock 。