Mokka

Bitrise build status Code coverage CocoaPods Swift Version License Twitter

一个帮助您更轻松地在 Swift 中编写测试 Mock 对象的辅助工具集合。

动机

由于 Swift 的高度静态特性,相比其他语言,Mock 和 Stub 更加困难。没有像 OCMockMockito 这样的动态 Mock 框架。通常的做法是手动编写 Mock 对象,如下所示:

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
    }
}

这需要大量的样板代码(而且这只是一个简单的示例,例如,它不允许有条件的 Stub)。这就是 Mokka 的用武之地。

概述

Mokka 提供了一个名为 FunctionMock<Args> 的测试辅助类(以及一个用于返回函数的变体,称为 ReturningFunctionMock<Args, ReturnValue>),它负责处理:

有了这些辅助工具,定义 Mock 对象就方便多了

class FooMock: Foo {
    let doSomethingFunc = ReturningFunctionMock<String, Int>()
    func doSomething(arg: String) -> Int {
        return doSomethingFunc.recordCallAndReturn(arg)
    }
}

现在你可以使用该函数 Mock 对象进行验证

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)
}

要求

安装

CocoaPods

要通过 CocoaPods 安装 Mokka,只需将 Mokka pod 添加到 Podfile 中的测试目标

pod 'Mokka'

Swift Package Manager

您可以使用 Swift Package Manager 安装 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 拖到您的项目中,并确保它已添加到单元测试目标,而不是主应用程序目标。 如果您看到类似 "The bundle xxx couldn’t be loaded because it is damaged or missing necessary resources." 的错误,那么您可能需要调整测试目标的 Runtime Search Paths

文档

Mock 的类型

目前有三种类型的 Mock 辅助工具可用:

如何实现您的 Mock

第一步是使用 Mokka 的辅助工具实现您的 Mock。 对于所有类型的 Mock,通用方法是相同的:为 Mock 声明一个属性,并在您的函数实现中使用该 Mock 对象来记录对该函数的调用。

对于下面的示例,我们假设我们要 Mock 以下协议

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()
    }
	
    // ...
}

注意:Mock 初始化程序中的 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)
    }
	
    // ...
}

对于返回方法的参数,适用的规则与非返回函数相同(请参见上文)。 例如

属性

在许多情况下,只需在 Mock 中声明一个带有默认值的存储属性来实现 Mock 协议的属性需求就足够了。 但是,当您希望能够明确地跟踪是否已读取或写入属性时,Mokka 的 PropertyMock 会很有帮助。 它是属性类型的泛型,并且在 Mock 实现中的使用是不言自明的:不要使用存储属性,而是声明一个计算属性,并将 getter 和 setter(如果是可设置的属性)委托给 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)。

调用计数验证

Mock 的常见用例是验证方法是否已被调用,有时特别是被调用了多少次。 为此,FunctionMockReturningFunctionMock 都提供了一些属性:

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)

Stub

有时需要 Stub 函数的行为,例如引入一些重要的副作用。 一个常见的例子是调用委托方法。 您可以通过提供一个将在调用该函数时执行的闭包来做到这一点。 将为闭包提供函数参数

let delegate = FooDelegateMock()
someMock.myFunction.stub { arg in
    delegate.somethingHappened(with: arg)
}

伪造返回值

对于返回函数,能够伪造返回值至关重要。 Mokka 提供了 3 种方法来实现此目的:静态返回值、动态返回值和条件返回值。 让我们看一下每一个。

提供静态返回值

在大多数情况下,提供一个应由 Mock 实现返回的简单静态值就足够了

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

Mock 属性

这是在测试代码中如何使用由 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 许可证 下可用。