Cuckoo

模拟你的 Swift 对象!

Platform CocoaPods ready SPM ready License

简介

Cuckoo 的创建是由于缺少合适的 Swift 模拟框架。 我们构建的 DSL 与 Mockito 非常相似,因此任何来自 Java/Android 的人都可以立即上手使用它。

它是如何工作的

Cuckoo 有两个部分。 一个是运行时,另一个是一个 OS X 命令行工具,简称为 Cuckoonator

不幸的是,Swift 没有合适的反射机制,因此我们决定使用编译时生成器来遍历您指定的文件,并生成支持结构/类,这些结构/类将在您的测试目标中被运行时使用。

生成的文件包含足够的信息,可以为您提供适当的控制能力。 它们基于继承和协议遵循工作。 这意味着只有可覆盖的内容才能被模拟。 由于 Swift 的复杂性,检查所有极端情况并不容易,因此如果您发现一些意想不到的行为,请提交 issue。

更新日志

所有更改和新功能的列表可以在这里找到。

功能

Cuckoo 是一个强大的模拟框架,支持

不支持的内容

由于上述限制,Cuckoo 不支持不可覆盖的代码结构。 这包括

要求

Cuckoo 在以下平台上工作

Cuckoo

1. 安装

Swift Package Manager

URL: https://github.com/Brightify/Cuckoo.git

警告:请务必仅将 Cuckoo 添加到测试目标。

一切准备就绪后,转到测试目标的 Build Phases,并将插件 CuckooPluginSingleFile 添加到 Run Build Tool Plug-ins

CocoaPods

Cuckoo 运行时可通过 CocoaPods 获得。 要安装它,只需在 Podfile 中的测试目标中添加以下行

pod 'Cuckoo', '~> 2.0'

并将以下 Run script 构建阶段添加到测试目标的 Build Phases 中,在 Compile Sources 阶段之上

# Skip for indexing
if [ $ACTION == "indexbuild" ]; then
  exit 0
fi

# Skip for preview builds
if [ "${ENABLE_PREVIEWS}" = "YES" ]; then
  exit 0
fi

"${PODS_ROOT}/Cuckoo/run"

运行一次后,找到 GeneratedMocks.swift 并将其拖到您的 Xcode 测试目标组中。

重要提示:为了让您的模拟之旅更轻松,请务必确保运行脚本位于 Compile Sources 阶段之上。

注意: 从 Xcode 15 开始,Build Settings 中的 flag ENABLE_USER_SCRIPT_SANDBOXING 默认是 Yes。这意味着 Xcode 会沙箱化脚本,因此读取输入文件和写入输出文件将被禁止。因此,运行上面的脚本可能会无法访问文件。要防止 Xcode 沙箱化脚本,请将此选项更改为 No

也可以直接在 Run script 中的 Input Files 表单中指定输入文件。

注意:运行脚本中的所有路径都必须是绝对路径。变量 PROJECT_DIR 自动指向您的项目目录。请记住包含继承的类和协议的路径,以便模拟/存根父类和祖父母类。

2. Cuckoofile 自定义

在项目的根目录中,创建 Cuckoofile.toml 配置文件

# You can define a fallback output for all modules that don't define their own.
output = "Tests/Swift/Generated/GeneratedMocks.swift"

[modules.MyProject]
output = "Tests/Swift/Generated/GeneratedMocks+MyProject.swift"
# Standard imports added to the generated file(s).
imports = ["Foundation"]
# Public imports if needed due to imports being internal by default from Swift 6.
publicImports = ["ExampleModule"]
# @testable imports if needed.
testableImports = ["RxSwift"]
sources = [
    "Tests/Swift/Source/*.swift",
]
exclude = ["ExcludedTestClass"]
# Optionally you can use a regular expression to filter only specific classes/protocols.
# regex = ""

[modules.MyProject.options]
# glob = false
# Docstrings are preserved by default, comments are omitted.
keepDocumentation = false
# enableInheritance = false
# protocolsOnly = true
# omitHeaders = true

# If specified, Cuckoo can also get sources for the module from an Xcode target.
[modules.MyProject.xcodeproj]
# Path to folder with .xcodeproj, omit this if it's at the same level as Cuckoofile.
path = "Generator"
target = "Cuckoonator"

# You can define as many modules as you need, each with different sources/options/output.
[modules.AnotherProject]
# ...

3. 用法

Cuckoo 的用法类似于 MockitoHamcrest。 但是,由于生成模拟和 Swift 语言本身的原因,存在一些差异和限制。 所有受支持的功能列表可以在下面找到。 您可以在 tests 中找到完整的示例。

模拟初始化

可以使用与被模拟类型相同的构造函数来创建模拟。 模拟类的名称始终对应于带有 Mock 前缀的被模拟类/协议的名称(例如,协议 Greeter 的模拟称为 MockGreeter)。

let mock = MockGreeter()

Spy

Spy 是一种特殊的 Mocks,默认情况下每个调用都会转发给 victim。 当您需要一个 spy 时,请给 Cuckoo 一个类,然后您就可以在模拟实例上调用 enableSuperclassSpy()(或 withEnabledSuperclassSpy()),它将表现得像父类的 spy。

let spy = MockGreeter().withEnabledSuperclassSpy()

存根 (Stubbing)

可以通过将方法作为 when 函数的参数来完成存根。 存根调用必须在特殊的存根对象上完成。 您可以使用 stub 函数获取对它的引用。 此函数接受要存根的模拟的实例和一个闭包,您可以在其中进行存根。 此闭包的参数是存根对象。

注意:目前存根对象可以从闭包中逃逸。 您仍然可以使用它来存根调用,但在实践中不建议这样做,因为此行为将来可能会更改。

调用 when 函数后,您可以使用以下方法指定下一步要执行的操作

/// Invokes `implementation` when invoked.
then(_ implementation: IN throws -> OUT)

/// Returns `output` when invoked.
thenReturn(_ output: OUT, _ outputs: OUT...)

/// Throws `error` when invoked.
thenThrow(_ error: ErrorType, _ errors: Error...)

/// Invokes real implementation when invoked.
thenCallRealImplementation()

/// Does nothing when invoked.
thenDoNothing()

可用方法取决于存根方法的特征。 例如,thenThrow 方法不适用于不抛出或重新抛出的方法。

存根方法的示例如下所示

stub(mock) { stub in
  when(stub.greetWithMessage("Hello world")).then { message in
    print(message)
  }
}

对于属性,如下所示

stub(mock) { stub in
  when(stub.readWriteProperty.get).thenReturn(10)
  when(stub.readWriteProperty.set(anyInt())).then {
    print($0)
  }
}

请注意 getset,这些将在以后的验证中使用。

启用默认实现

除了存根之外,您还可以使用正在模拟的原始类的实例来启用默认实现。 每个未存根的方法/属性都将按照原始实现的方式运行。

通过简单地调用提供的方法来实现启用默认实现

let original = OriginalClass<Int>(value: 12)
mock.enableDefaultImplementation(original)

对于将类传递到方法中,无论您是模拟类还是协议,都不会发生任何变化。 但是,如果您使用 struct 来遵循我们正在模拟的原始协议,则存在差异

let original = ConformingStruct<String>(value: "Hello, Cuckoo!")
mock.enableDefaultImplementation(original)
// or if you need to track changes:
mock.enableDefaultImplementation(mutating: &original)

请注意,这仅涉及 structenableDefaultImplementation(_:)enableDefaultImplementation(mutating:) 在状态跟踪方面有所不同。

标准的非可变方法 enableDefaultImplementation(_:) 创建 struct 的副本以进行默认实现并使用它。 但是,可变方法 enableDefaultImplementation(mutating:) 采用对 struct 的引用,并且 original 的更改反映在默认实现调用中,即使在启用默认实现之后也是如此。

我们建议使用非可变方法来启用默认实现,除非您需要跟踪更改以确保代码中的一致性。

链式存根

可以进行链式存根。 当您需要为多个调用定义不同的行为时,这很有用。 最后一个行为将用于之后的所有调用。 语法如下

when(stub.readWriteProperty.get).thenReturn(10).thenReturn(20)

相当于

when(stub.readWriteProperty.get).thenReturn(10, 20)

readWriteProperty 的第一次调用将返回 10,之后的所有调用将返回 20

您可以根据自己的喜好组合存根方法。

存根的覆盖

在寻找存根匹配项时,Cuckoo 优先考虑 when 的最后一次调用。 这意味着使用相同函数和匹配器多次调用 when 会有效地覆盖之前的调用。 此外,更通用的参数匹配器必须在特定的参数匹配器之前使用。

when(stub.countCharacters(anyString())).thenReturn(10)
when(stub.countCharacters("a")).thenReturn(1)

在此示例中,使用 a 调用 countCharacters 将返回 1。 如果您颠倒了存根的顺序,则输出将始终为 10

在实际代码中的用法

完成前面的步骤后,可以调用存根方法。 您需要将此模拟注入到您的生产代码中。

注意:调用未存根的模拟将导致错误。 对于 spy,将执行真实代码。

验证

对于验证调用,有一个函数 verify。 它的第一个参数是被模拟的对象,可选的第二个参数是调用匹配器。 然后是带有其参数的调用。

verify(mock).greetWithMessage("Hello world")

属性的验证类似于其存根。

您可以使用函数 verifyNoMoreInteractions 检查模拟上是否没有更多交互。

使用 Swift 的泛型类型,可以使用泛型参数作为返回类型。 为了正确验证这些方法,您需要能够指定返回类型。

// Given:
func genericReturn<T: Codable>(for: String) -> T? { ... }

// Verify
verify(mock).genericReturn(for: any()).with(returnType: String?.self)
参数捕获

您可以使用 ArgumentCaptor 在调用验证中捕获参数(不建议在存根中这样做)。 这是一个示例代码

mock.readWriteProperty = 10
mock.readWriteProperty = 20
mock.readWriteProperty = 30

let argumentCaptor = ArgumentCaptor<Int>()
verify(mock, times(3)).readWriteProperty.set(argumentCaptor.capture())
argumentCaptor.value // Returns 30
argumentCaptor.allValues // Returns [10, 20, 30]

如您所见,方法 capture() 用于创建调用的匹配器,然后您可以通过属性 valueallValues 获取参数。 value 返回最后一个捕获的参数,如果没有则返回 nil。 allValues 返回包含所有捕获值的数组。

3. 匹配器

Cuckoo 使用匹配器将您的模拟连接到您的测试代码。

A) 已知类型的自动匹配器

您可以模拟任何符合 Matchable 协议的对象。 标准的内置类型(如 IntString)已经通过 Cuckoo 符合要求。 自动符合性合成适用于 Array 等。

B) 自定义匹配器

如果 Cuckoo 不知道您要比较的类型,则必须使用 ParameterMatcher 编写自己的方法 equal(to:)。 将此方法添加到您的测试文件中

func equal(to value: YourCustomType) -> ParameterMatcher<YourCustomType> {
  return ParameterMatcher { tested in
    // Implementation of equality test for your custom type.

  }
}

⚠️尝试匹配未知或非 Matchable 类型的对象可能会导致

Command failed due to signal: Segmentation fault: 11

有关详细信息或示例(使用 Alamofire),请参见此 issue

参数匹配器

ParameterMatcher 本身也符合 Matchable。 您可以创建自己的 ParameterMatcher 实例,或者如果您想直接使用自定义类型,则可以使用 Matchable 协议。 可以通过以下函数获得 ParameterMatcher 的标准实例

/// Returns an equality matcher.
equal<T: Equatable>(to value: T)

/// Returns an identity matcher.
equal<T: AnyObject>(to value: T)

/// Returns a matcher using the supplied function.
equal<T>(to value: T, equalWhen equalityFunction: (T, T) -> Bool)

/// Returns a matcher matching any Int value.
anyInt()

/// Returns a matcher matching any String value.
anyString()

/// Returns a matcher matching any T value or nil.
any<T>(type: T.Type = T.self)

/// Returns a matcher matching any closure.
anyClosure()

/// Returns a matcher matching any throwing closure.
anyThrowingClosure()

/// Returns a matcher matching any non nil value.
notNil()

Cuckoo 还为序列和字典提供了许多便利匹配器,允许您检查序列是否是某个序列的超集,是否包含其至少一个元素,或者是否与其完全分离。

Matchable 可以像这样使用 orand 方法进行链式调用

verify(mock).greetWithMessage("Hello world".or("Hallo Welt"))

调用匹配器

你可以使用 CallMatcher 的实例作为 verify 函数的第二个参数。它的主要功能是断言调用发生的次数。但是 matches 函数有一个 [StubCall] 类型的参数,这意味着你可以使用自定义的 CallMatcher 来检查桩调用或进行一些副作用操作。

注意:调用匹配器会在参数匹配器之后应用。因此,你只会得到所需方法且参数正确的桩调用。

标准的调用匹配器包括:

/// Returns a matcher ensuring a call was made `count` times.
times(_ count: Int)

/// Returns a matcher ensuring no call was made.
never()

/// Returns a matcher ensuring at least one call was made.
atLeastOnce()

/// Returns a matcher ensuring call was made at least `count` times.
atLeast(_ count: Int)

/// Returns a matcher ensuring call was made at most `count` times.
atMost(_ count: Int)

Matchable 类似,你可以使用 orand 方法链式调用 CallMatcher。但是,你不能将 MatchableCallMatcher 混合使用。

重置 Mock

以下函数用于重置 mock 对象的桩和/或调用。

/// Clears all invocations and stubs of given mocks.
reset<M: Mock>(_ mocks: M...)

/// Clears all stubs of given mocks.
clearStubs<M: Mock>(_ mocks: M...)

/// Clears all invocations of given mocks.
clearInvocations<M: Mock>(_ mocks: M...)

桩对象

桩用于抑制真实代码的执行。桩与 Mock 的不同之处在于,它们不支持桩的设置和验证。它们可以使用与 mock 类型相同的构造函数创建。桩类的名称始终与 mock 类/协议的名称相对应,并带有 Stub 后缀 (例如,协议 Greeter 的桩被称为 GreeterStub)。

let stub = GreeterStub()

当在桩上调用方法或访问/设置属性时,不会发生任何事情。如果一个方法或属性需要返回值,DefaultValueRegistry 会提供一个默认值。可以使用桩来设置 mock 对象的隐式(无)行为,而无需使用 thenDoNothing(),例如:MockGreeter().spy(on: GreeterStub())

DefaultValueRegistry

DefaultValueRegistry 被桩用于获取返回类型的默认值。它只知道默认的 Swift 类型、集合、数组、字典、可选类型和元组(最多 6 个值)。更多值的元组可以通过扩展添加。自定义类型在使用前必须使用 DefaultValueRegistry.register<T>(value: T, forType: T.Type) 注册。此外,Cuckoo 设置的默认值也可以通过此方法覆盖。如果集合、数组等的泛型类型已经注册,则它们不必注册。

DefaultValueRegistry.reset() 将注册表恢复到 register 方法进行任何更改之前的干净状态。

类型推断

Cuckoo 对所有变量进行简单的类型推断,从而可以使你这边的源代码更加简洁。类型推断总共有 3 种方式尝试从变量中提取类型名称

// From the explicitly declared type:
let constant1: MyType

// From the initial value:
let constant2 = MyType(...)

// From the explicitly specified type `as MyType`:
let constant3 = anything as MyType

Cuckoo 运行脚本

构建选项

这些选项仅用于下载或构建生成器,不会干扰生成的 mock 对象的结果。

当执行 run 脚本 且不带任何参数时,它会简单地搜索 cuckoonator 文件,如果该文件不存在,则从源代码构建它,然后在之后运行生成器。

要从 GitHub 下载生成器而不是构建它,请使用 --download 选项作为第一个参数 (例如,run --download)。如果您在构建过程中遇到较长时间的问题(尤其是在 CI 中),这可能是解决问题的方法。

注意:如果在使用 --download 选项时遇到 Github API 速率限制,run 脚本 会引用环境变量 GITHUB_ACCESS_TOKEN。 将此行(用您的 GitHub 令牌替换 Xs,无需其他权限)添加到 run 调用上方的脚本构建阶段

export GITHUB_ACCESS_TOKEN="XXXXXXX"

构建选项 --clean 强制构建或下载指定的版本,即使生成器已经存在。如果需要,运行脚本还会将生成器同步到正确的版本(通过从源代码构建或使用 --download 下载)。

我们建议仅在尝试修复编译问题时使用 --clean,因为它会强制每次都构建(或下载),这会使测试时间比需要的长得多。

Objective-C 支持

为了支持 Objective-C,Cuckoo 封装了 OCMock 并使用其经过实战检验的功能,为 Swift 带来对 Mock 各种 Objective-C 类和协议的支持。

例如

let tableView = UITableView()
// stubbing the class is very similar to stubbing with Cuckoo, note the `objcStub` method.
let mock = objcStub(for: UITableViewController.self) { stubber, mock in
  stubber.when(mock.numberOfSections(in: tableView)).thenReturn(1)
  stubber.when(mock.tableView(tableView, accessoryButtonTappedForRowWith: IndexPath(row: 14, section: 2))).then { args in
    // An unfortunate drawback is that arguments will miss type information, so the closure needs to cast them manually.
    let (tableView, indexPath) = (args[0] as! UITableView, args[1] as! IndexPath)
    print(tableView, indexPath)
  }
}

// Invoking is exactly the same as you'd expect.
XCTAssertEqual(mock.numberOfSections(in: tableView), 1)
mock.tableView(tableView, accessoryButtonTappedForRowWith: IndexPath(row: 14, section: 2))

// `objcVerify` replaces `verify` to test the interaction with methods/variables.
objcVerify(mock.numberOfSections(in: tableView))
objcVerify(mock.tableView(tableView, accessoryButtonTappedForRowWith: IndexPath(row: 14, section: 2)))

Cuckoo 测试中提供了详细的用法,以及此 Swift-ObjC 桥的 DO 和 DON'T。

贡献

Cuckoo 对所有人开放,我们希望您帮助我们制作最好的 Swift Mock 库。对于 Cuckoo 的开发,请按照以下步骤操作

  1. 确保您已安装最新稳定版本的 Xcode。
  2. 克隆 Cuckoo 仓库。
  3. 对于初始设置,打开终端并在克隆的 Cuckoo 仓库的根目录运行 make init。这将安装必要的工具(包括 Tuist,如果尚未安装),生成项目,安装依赖项并在 Xcode 中打开它。
  4. 对于后续使用或切换分支时,在克隆的 Cuckoo 仓库的根目录运行 make。这将生成项目,安装依赖项并在 Xcode 中打开它。
  5. 选择 Cuckoo-iOSCuckoo-tvOSCuckoo-macOS 的任何 scheme,并通过运行测试 (⌘+U) 进行验证。
  6. 浏览或提交包含您的更改的 pull request。

注意:每当您检出另一个分支时,请确保再次运行 make,该项目利用 Tuist 进行项目生成。

该项目由两部分组成 - 运行时和代码生成器。在 Xcode 中打开 Cuckoo.xcworkspace 时,您会看到以下目录: - Source - 运行时源文件 - Tests - 运行时部分的测试 - Generator.xcodeproj - 包含生成器源代码的项目(使用 Generator scheme 运行它)

感谢您的帮助!

灵感来源

使用的库

许可证

Cuckoo 在 MIT 许可证下可用。