@Mockable

Mockable 是一个 Swift 宏驱动的测试框架,它为你的协议提供自动模拟实现。它提供了一个直观的声明式语法,简化了单元测试中模拟服务的流程。生成的模拟实现可以使用编译条件从发布版本中排除。

目录

赞助

我正在开发 Mockable 作为一个有趣的业余项目。我相信开源的力量,并且很高兴与你分享它。如果 Mockable 为你节省了时间,并且你想表达你的感谢,我将非常感谢你的支持。

文档

阅读 Mockable文档,获取详细的安装和配置指南以及使用示例。

安装

可以使用 Swift Package Manager 安装该库。

Mockable 目标添加到所有包含你想模拟的协议的目标。Mockable 不依赖于 XCTest 框架,因此可以将其添加到任何目标。

阅读文档中的 安装指南,了解有关如何将 Mockable 与你的项目集成的更多详细信息。

配置

由于 @Mockable 是一个 peer macro,生成的代码将始终与其所附加的协议位于相同的范围内。

为了解决这个问题,宏展开被包含在一个预定义的编译时标志 MOCKING 中,可以利用该标志将生成的模拟实现从发布版本中排除。

⚠️由于 MOCKING 标志默认情况下未在你的项目中定义,因此除非你配置它,否则你将无法使用模拟实现。

当使用框架模块或非模块化项目时

在你的目标构建设置中,为调试构建配置定义该标志

  1. 打开你的 Xcode 项目
  2. 转到你目标的构建设置
  3. 找到 Swift Compiler - Custom Flags
  4. 在调试配置下添加 MOCKING 标志。

当使用 SPM 模块或测试包时

在你模块的包清单中,在目标定义下,如果构建配置设置为 debug,你可以定义 MOCKING 编译时条件

.target(
    ...
    swiftSettings: [
        .define("MOCKING", .when(configuration: .debug))
    ]
)

当使用 XcodeGen

在你的 XcodeGen 规范中定义该标志

settings:
  ...
  configs:
    debug:
      SWIFT_ACTIVE_COMPILATION_CONDITIONS: MOCKING

当使用 Tuist

如果你使用 Tuist,你可以在你的目标的设置中的 configurations 下定义 MOCKING 标志。

.target(
    ...
    settings: .settings(
        configurations: [
            .debug(
                name: .debug, 
                settings: [
                    "SWIFT_ACTIVE_COMPILATION_CONDITIONS": "$(inherited) MOCKING"
                ]
            )
        ]
    )
)

阅读文档中的 配置指南,了解有关如何在你的项目中设置 MOCKING 标志的更多详细信息。

用法

示例

给定一个用 @Mockable 宏注解的协议

import Mockable

@Mockable
protocol ProductService {
    var url: URL? { get set }
    func fetch(for id: UUID) async throws -> Product
    func checkout(with product: Product) throws
}

将生成一个名为 MockProductService 的模拟实现,可以在单元测试中使用,例如

import Mockable

lazy var productService = MockProductService()
lazy var cartService = CartServiceImpl(productService: productService)

func testCartService() async throws {
    let mockURL = URL(string: "apple.com")
    let mockError: ProductError = .notFound
    let mockProduct = Product(name: "iPhone 15 Pro")

    given(productService)
        .fetch(for: .any).willReturn(mockProduct)
        .checkout(with: .any).willThrow(mockError)

    try await cartService.checkout(with: mockProduct, using: mockURL)

    verify(productService)
        .fetch(for: .value(mockProduct.id)).called(.atLeastOnce)
        .checkout(with: .value(mockProduct)).called(.once)
        .url(newValue: .value(mockURL)).setCalled(.once)
}

语法

Mockable 具有声明式语法,它利用构建器来构造 givenwhenverify 子句。当构造任何这些子句时,你总是遵循相同的语法

子句类型(服务).函数构建器.行为构建器

在以下示例中,我们使用先前介绍的产品服务

let id = UUID()
let error: ProductError = .notFound

given(productService).fetch(for: .value(id)).willThrow(error)

我们指定以下内容

参数

函数构建器具有原始要求中的所有参数,但将它们封装Parameter<Value> 类型中。当构造可模拟子句时,你必须为函数的每个参数指定参数条件。有三个可用的选项

计算属性没有参数,但是可变属性在函数构建器中获得一个 (newValue:) 参数,该参数可用于约束具有匹配条件的属性赋值的功能。这些 newValue 条件只会影响 performOnGetperformOnSetgetCalledsetCalled 子句,但不会影响 return 子句。

以下是使用不同参数条件的示例

// throw an error when `fetch(for:)` is called with `id`
given(productService).fetch(for: .value(id)).willThrow(error)

// print "Ouch!" if product service is called with a product named "iPhone 15 Pro"
when(productService)
  .checkout(with: .matching { $0.name == "iPhone 15 Pro" })
  .perform { print("Ouch!") }

// assert if the fetch(for:) was called exactly once regardless of what id parameter it was called with
verify(productService).fetch(for: .any).called(.once)

Given(给定)

可以使用 given(_ service:) 子句指定返回值。有三个可用的 return 构建器

提供的返回值按 FIFO 顺序用完,最后一个始终保留用于任何进一步的调用。以下是使用 return 子句的示例

// Throw an error for the first call and then return 'product' for every other call
given(productService)
    .fetch(for: .any).willThrow(error)
    .fetch(for: .any).willReturn(product)

// Throw an error if the id parameter ends with a 0, return a product otherwise
given(productService)
    .fetch(for: .any).willProduce { id in
        if id.uuidString.last == "0" {
            throw error
        } else {
            return product
        }
    }

When(当...时)

可以使用 when(_ service:) 子句添加副作用。有三种副作用

以下是使用副作用的一些示例

// log calls to fetch(for:)
when(productService).fetch(for: .any).perform {
    print("fetch(for:) was called")
}

// log when url is accessed
when(productService).url().performOnGet {
    print("url accessed")
}

// log when url is set to nil
when(productService).url(newValue: .value(nil)).performOnSet {
    print("url set to nil")
}

Verify(验证)

你可以使用 verify(_ service:) 子句验证你的模拟服务的调用。

Mockable 通过使用 Pointfree 的 swift-issue-reporting 来动态报告具有适当测试框架的测试失败,从而支持 XCTestSwift Testing

有三种验证方式

以下是一些示例断言

verify(productService)
    // assert fetch(for:) was called between 1 and 5 times
    .fetch(for: .any).called(.from(1, to: 5))
    // assert checkout(with:) was called between exactly 10 times
    .checkout(with: .any).called(10)
    // assert url property was accessed at least 2 times
    .url().getCalled(.moreOrEqual(to: 2))
    // assert url property was never set to nil
    .url(newValue: .value(nil)).setCalled(.never)

如果你正在测试异步代码并且无法编写同步断言,你可以使用上述验证的异步对应项

以下是一些异步验证的示例

await verify(productService)
    // assert fetch(for:) was called between 1 and 5 times before default timeout (1 second)
    .fetch(for: .any).calledEventually(.from(1, to: 5))
    // assert checkout(with:) was called between exactly 10 times before 3 seconds
    .checkout(with: .any).calledEventually(10, before: .seconds(3))
    // assert url property was accessed at least 2 times before default timeout (1 second)
    .url().getCalledEventually(.moreOrEqual(to: 2))
    // assert url property was set to nil once
    .url(newValue: .value(nil)).setCalledEventually(.once)

宽松模式

默认情况下,你必须为所有要求指定一个返回值;否则,将抛出一个致命错误。这样做的原因是帮助发现(从而验证)编写单元测试时调用的每个函数。

但是,通常更喜欢避免这种严格的默认行为,而选择更宽松的设置,例如,void 或 optional 返回值不需要显式的 given 注册。

使用 MockerPolicy(这是一个 option set)来隐式模拟

你有两个选项来覆盖库的默认严格行为

.relaxedMocked policy 与 Mocked 协议结合使用,可以为自定义(甚至是内置)类型设置一个隐式返回值

struct Car {
    var name: String
    var seats: Int
}

extension Car: Mocked {
    static var mock: Car {
        Car(name: "Mock Car", seats: 4)
    }

    // Defaults to [mock] but we can 
    // provide a custom array of cars:
    static var mocks: [Car] {
        [
            Car(name: "Mock Car 1", seats: 4),
            Car(name: "Mock Car 2", seats: 4)
        ]
    }
}

@Mockable
protocol CarService {
    func getCar() -> Car
    func getCars() -> [Car]
}

func testCarService() {
    func test() {
        let mock = MockCarService(policy: .relaxedMocked)
        // Implictly mocked without a given registration:
        let car = mock.getCar()
        let cars = mock.getCars()
    }
}

⚠️宽松模式不适用于泛型返回值,因为类型系统无法找到适当的泛型重载。

处理不可比较的类型

Mockable 在内部使用 Matcher 来比较参数。默认情况下,matcher 能够比较任何符合 Equatable 的自定义类型(除非在泛型函数中使用)。在特殊情况下,当你

你可以使用 Matcher.register() 函数注册你的自定义类型。 这是具体方法。

// register an equatable type to the matcher because we use it in a generic function
Matcher.register(SomeEquatableType.self)

// register a non-equatable type to the matcher
Matcher.register(Product.self, match: { $0.name == $1.name })

// register a meta-type to the matcher
Matcher.register(HomeViewController.Type.self)

// remove all previously registered custom types
Matcher.reset()

如果你在测试中看到类似这样的错误:

找不到类型 "SomeType" 的比较器。所有不可比较的类型都必须使用 Matcher.register(_) 注册。

请记住使用 register() 函数将提示的类型添加到你的 Matcher 中。

支持的特性

限制

贡献

如果您在使用该项目时遇到任何问题或有改进建议,请随时提出 issue。 我很重视您的反馈,并致力于使这个项目尽可能健壮和用户友好。

如果名为 MOCKABLE_DEV 的环境变量设置为 true,则包清单设置为仅包含测试目标和测试依赖项。 这样做是为了防止过于热心的 Swift Package Manager 在有人使用 Mockable 作为包依赖项时下载测试依赖项和插件,例如 swift-macro-testingSwiftLint

要在“开发模式”下使用 Xcode 打开包,您需要设置 MOCKABLE_DEV=true 环境变量。 使用 Scripts/open.sh 打开项目(或将其内容复制到您的终端),以便在贡献时能够运行测试和 lint 代码。

许可证

Mockable 在 MIT 许可证下提供。 有关更多详细信息,请参见 LICENSE 文件。