☕️ Mokka
一组辅助工具,用于简化在 Swift 中编写测试模拟。
动机
由于 Swift 非常静态的特性,在 Swift 中模拟和存根要比在其它语言中难得多。没有像 OCMock
或 Mockito
这样的动态模拟框架。常见的做法是手动编写模拟对象,如下所示:
protocol Foo {
func doSomething(arg: String) -> Int
}
class FooMock: Foo {
var doSomethingHasBeenCalled = false
var doSomethingArgument = String?
var doSomethingReturnValue: Int!
func doSomething(arg: String) -> Int {
doSomethingHasBeenCalled = true
doSomethingArgument = arg
return doSomethingReturnValue
}
}
这有很多样板代码(这是一个简单的例子,不允许进行条件存根,例如)。这就是 Mokka 发挥作用的地方。
概述
Mokka 提供了一个名为 FunctionMock<Args>
的测试辅助类(以及一个返回函数的变体 ReturningFunctionMock<Args, ReturnValue>
),它可以处理以下内容:
- 记录函数/方法的调用以进行验证(方法是否被调用过?,方法被调用过多少次?)
- 捕获参数以进行验证(方法被什么参数调用过?)
- 存根返回值(也可以是条件性的)(当方法使用参数
"x"
调用时,该方法应该返回42
。)
使用这些辅助工具,定义模拟对象要方便得多。
class FooMock: Foo {
let doSomethingFunc = ReturningFunctionMock<String, Int>()
func doSomething(arg: String) -> Int {
return doSomethingFunc.recordCallAndReturn(arg)
}
}
您现在可以使用函数模拟对象进行验证
func testSomething() {
// ...
XCTAssertEqual(myMock.doSomethingFunc.callCount, 2)
XCTAssertEqual(myMock.doSomethingFunc.argument, "lorem ipsum")
// ...
}
并模拟返回值
func testSomething() {
// static return value
myMock.doSomethingFunc.returns(100)
// dynamic return value
myMock.doSomethingFunc.returns { $0 + 200 } // $0 is the argument(s) passed to the method
// conditional return value
myMock.doSomethingFunc.returns(123, when: { $0 == "foo" })
myMock.doSomethingFunc.returns(456, when: { $0 == "bar" })
myMock.doSomethingFunc.returns(789)
}
需求
- Xcode 10.2
- Swift 5.0
安装
CocoaPods
要使用CocoaPods安装Mokka,只需将Mokka
库添加到您的Podfile中的测试目标。
pod 'Mokka'
Swift包管理器
您可以使用Swift包管理器安装Mokka。只需将此仓库作为依赖项添加到您的Package.swift
文件中(不要忘记在您的测试目标中也将"Mokka"
添加为依赖项)。
dependencies: [
.package(url: "https://github.com/danielr/Mokka", from: "1.0.0")
// ...
]
Carthage
要用Carthage安装Mokka,请将以下内容添加到您的Cartfile中
github "danielr/Mokka"
然后将构建好的Mokka.framework
拖到您的项目中,并确保将其添加到单元测试目标,而不是主应用目标。如果您遇到“无法加载 bundler xxx,因为它已损坏或缺少必要的资源”之类的错误,那么您可能需要调整测试目标的运行时搜索路径。
文档
模拟类型
目前有三种模拟辅助工具可用
FunctionMock
: 允许记录对函数的调用(调用次数和参数),以及可选地模拟函数的行为。对于具有Void
返回值的函数,请使用此功能。ReturningFunctionMock
: 提供与FunctionMock
相同的功能,但增加了模拟返回值的能力。对于具有非空返回值的函数,请使用此功能。PropertyMock
: 允许为属性提供假值,并记录属性是否被读取或设置。
如何实现您的模拟
第一步是使用 Mokka 的辅助工具实现您的模拟。所有类型的模拟都采用相同的方法:您为模拟声明一个属性,并在您的功能实现中使用该模拟对象来记录对该函数的调用。
以下示例假设我们要模拟以下协议
protocol Engine {
func turnOn()
func turnOff()
var isOn: Bool { get }
func setSpeed(to value: Float) // kilometers per hour
func setSpeed(to value: Float, in unit: UnitSpeed)
func currentSpeed(in unit: UnitSpeed) -> Double
}
无返回值的函数
对于无返回值的函数,使用 FunctionMock<Args>
。此类有一个泛型参数,定义了参数的类型。
对于无 参数 的函数,该函数应为 Void
class EngineMock: Engine {
let turnOnFunc = FunctionMock<Void>(name: "turnOn()")
func turnOn() {
turnOnFunc.recordCall()
}
// ...
}
注意:模拟初始化器中的 name
参数是可选的。它是纯信息性的,可能在错误消息中很有用(例如,更好的断言错误消息)。提出名称是良好的实践,应使用标准的 Swift #selector 语法。
对于 单个参数 的函数,只需使用该参数的类型即可
class EngineMock: Engine {
let setSpeedFunc = FunctionMock<Float>(name: "setSpeed(to:)")
func setSpeed(to value: Float) {
setSpeedFunc.recordCall(value)
}
// ...
}
对于有 多个参数 的函数,您需要使用一个元组来表示参数(因为 Swift 当前不支持可变参数泛型)。尽管您不必这样做,但给元组元素命名是一种良好的实践,这使得在测试代码中引用它们时更加清晰。
class EngineMock: Engine {
let setSpeedInUnitFunc = FunctionMock<(value: Float, unit: UnitSpeed)>(name: "setSpeed(to:unit:)")
func setSpeed(to value: Float, in unit: UnitSpeed) {
setSpeedInUnitFunc.recordCall((value: value, unit: unit))
}
// ...
}
有返回值的函数
对于具有返回值的函数,使用 ReturningFunctionMock<Args, ReturnValue>
。这与 FunctionMock
类似,但添加了一个用于返回值类型的第二个泛型参数。它还提供了一个 recordCallAndReturn()
方法,而不是 recordCall()
方法。
class EngineMock: Engine {
let currentSpeedFunc = ReturningFunctionMock<UnitSpeed, Double>(name: "currentSpeed(in:)")
func currentSpeed(in unit: UnitSpeed) -> Double {
return currentSpeedFunc.recordCallAndReturn(unit)
}
// ...
}
对于返回方法的参数,相同的规则适用于非返回函数(见上文)。例如
- 一个没有参数并返回布尔值的函数的模拟可以声明为
ReturningFunctionMock
- 一个模拟有两个参数类型为
Int
和String?
并且返回Double
的函数可以声明为ReturningFunctionMock<(arg1: Int, arg2: String?), Double>
属性
在很多情况下,只需在你的模拟中声明一个具有默认值的存储属性来实现被模拟协议的属性要求就足够了。然而,当你想明确地跟踪属性是否被读取或写入时,Mokka 的 PropertyMock
就很有用。它是属性类型的泛型,并且在模拟实现中的使用相当直观:不使用存储属性,声明一个计算属性,并将获取器和设置器(如果它是可设置属性)委托给 PropertyMock
对象的 get()
和 set(_:)
方法。
class EngineMock: Engine {
let isOnProperty = PropertyMock<Bool>(name: "isOn")
var isOn: Bool {
get { return isOnProperty.get() }
set { isOnProperty.set(newValue) }
}
// ...
}
注意,您不需要为属性提供默认值(如果没有值,则 get()
方法将失败并抛出 preconditionFailure
)。
调用计数验证
模拟的一个常见用途是验证一个方法是否被调用,有时还特别关心它被调用的频率。为此,`FunctionMock` 和 `ReturningFunctionMock` 都提供了一些属性。
called: Bool
返回方法是否被调用(至少调用一次)。calledOnce: Bool
返回方法是否正好被调用一次。callCount: Int
方法被调用的次数。
XCTAssertTrue(engineMock.setSpeedFunc.called)
XCTAssertTrue(engineMock.setSpeedFunc.calledOnce)
XCTAssertEqual(engineMock.setSpeedFunc.callCount, 3)
参数验证
除了验证函数是否被调用,您通常还希望检查调用函数时使用的参数。您可以通过 arguments
属性来实现这一点
XCTAssertEqual(engineMock.setSpeedInUnitFunc.arguments.value, 100.0)
XCTAssertEqual(engineMock.setSpeedInUnitFunc.arguments.unit, .kilometersPerHour)
(这要求您遵循推荐的命名元组的成员的实践,见上文。如果您不这样做,您必须通过它们的索引来访问参数,例如 arguments.0
。)
对于单参数函数(没有参数元组)您也可以使用 argument
属性,看起来会更整洁。
XCTAssertEqual(engineMock.setSpeedFunc.argument, 100.0)
Mocking函数行为
有时有必要模拟一个函数的行为,例如引入一些重要的影响。一个常见的例子是调用代理方法。您可以通过提供一个闭包来实现,当调用该函数时,将执行该闭包。闭包将带有函数参数
let delegate = FooDelegateMock()
someMock.myFunction.stub { arg in
delegate.somethingHappened(with: arg)
}
模拟函数返回值
对于返回函数,能够模拟返回值至关重要。Mokka提供了3种方式进行模拟:静态返回值、动态返回值和条件返回值。让我们逐一查看这些。
提供静态返回值
对于大多数情况,提供应该由模拟实现返回的简单静态值就足够了
engineMock.currentSpeedFunc.returns(100.0)
动态提供返回值
有时提供动态生成的返回值是方便的,通常依赖于函数的参数。您可以通过提供一个闭包来实现
engineMock.currentSpeedFunc.returns { unit in
// always return 100 km/h, converted to the requested target unit
let kmhValue = Measurement(value: 100, unit: UnitSpeed.kilometersPerHour)
return kmhValue.converted(to: unit).value
}
条件提供返回值
静态和动态返回值也可以条件性地提供
engineMock.currentSpeedFunc.returns(100.00, when: { $0 == .kilometersPerHour })
engineMock.currentSpeedFunc.returns(62.137, when: { $0 == .milesPerHour })
engineMock.currentSpeedFunc.returns(0) // otherwise
模拟属性
这是如何在测试代码中使用由 PropertyMock
支持的属性的方法
someMock.fooProperty.value = 10 // use value to access the underlying property value
// do something
XCTAssertTrue(someMock.fooProperty.hasBeenRead)
XCTAssertFalse(someMock.fooProperty.hasBeenSet
示例
您可以在 MokkaExample 中找到一个简单的示例项目。
它包括
- 受测试主题(《车辆`)
- 两个模拟协议(《引擎`和《电池`)
这是一个最小化示例,但应该足以让您开始了解Mokka的概念。
作者
Mokka是由Daniel Rinser创建和维护的,@danielrinser。
许可协议
Mokka适用于MIT 许可协议。