Mockable 是一个 Swift 宏驱动的测试框架,它为你的协议提供自动模拟实现。它提供了一个直观的声明式语法,简化了单元测试中模拟服务的流程。生成的模拟实现可以使用编译条件从发布版本中排除。
我正在开发 Mockable 作为一个有趣的业余项目。我相信开源的力量,并且很高兴与你分享它。如果 Mockable 为你节省了时间,并且你想表达你的感谢,我将非常感谢你的支持。
阅读 Mockable 的 文档,获取详细的安装和配置指南以及使用示例。
可以使用 Swift Package Manager 安装该库。
将 Mockable 目标添加到所有包含你想模拟的协议的目标。Mockable 不依赖于 XCTest 框架,因此可以将其添加到任何目标。
阅读文档中的 安装指南,了解有关如何将 Mockable 与你的项目集成的更多详细信息。
由于 @Mockable 是一个 peer macro,生成的代码将始终与其所附加的协议位于相同的范围内。
为了解决这个问题,宏展开被包含在一个预定义的编译时标志 MOCKING 中,可以利用该标志将生成的模拟实现从发布版本中排除。
⚠️ 由于MOCKING标志默认情况下未在你的项目中定义,因此除非你配置它,否则你将无法使用模拟实现。
在你的目标构建设置中,为调试构建配置定义该标志
在你模块的包清单中,在目标定义下,如果构建配置设置为 debug,你可以定义 MOCKING 编译时条件
.target(
...
swiftSettings: [
.define("MOCKING", .when(configuration: .debug))
]
)
在你的 XcodeGen 规范中定义该标志
settings:
...
configs:
debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: MOCKING
如果你使用 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 具有声明式语法,它利用构建器来构造 given、when 和 verify 子句。当构造任何这些子句时,你总是遵循相同的语法
子句类型(服务).函数构建器.行为构建器
在以下示例中,我们使用先前介绍的产品服务
let id = UUID()
let error: ProductError = .notFound
given(productService).fetch(for: .value(id)).willThrow(error)
我们指定以下内容
given:我们要注册返回值(productService):我们指定要为其注册返回值的可模拟服务.fetch(for: .value(id)):我们想模拟 fetch(for:) 方法,并将我们的行为限制在具有匹配 id 参数的调用上.willThrow(error):如果使用指定的参数值调用 fetch(for:),我们希望抛出一个错误函数构建器具有原始要求中的所有参数,但将它们封装在 Parameter<Value> 类型中。当构造可模拟子句时,你必须为函数的每个参数指定参数条件。有三个可用的选项
.any:匹配对指定函数的每次调用,忽略实际的参数值。.value(Value):匹配指定参数中具有相同值的调用。.matching((Value) -> Bool):使用提供的闭包来过滤函数调用。计算属性没有参数,但是可变属性在函数构建器中获得一个
(newValue:)参数,该参数可用于约束具有匹配条件的属性赋值的功能。这些newValue条件只会影响performOnGet、performOnSet、getCalled和setCalled子句,但不会影响 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(_ service:) 子句指定返回值。有三个可用的 return 构建器
willReturn(_ value:):将存储给定的返回值并使用它来模拟后续调用。willThrow(_ error:):将存储给定的错误并在后续调用中抛出它。仅适用于抛出函数和属性。willProduce(_ producer):将使用提供的闭包进行模拟。该闭包具有与模拟函数相同的签名,因此例如,一个接受整数并返回字符串且可以抛出的函数将接受类型为 (Int) throws -> String 的闭包。提供的返回值按 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(_ service:) 子句添加副作用。有三种副作用
perform(_ action):将注册一个在调用模拟函数时执行的操作。performOnGet(_ action:):仅适用于可变属性,将在属性访问时执行提供的操作。performOnSet(_ action:):仅适用于可变属性,将在属性赋值时执行提供的操作。以下是使用副作用的一些示例
// 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(_ service:) 子句验证你的模拟服务的调用。
Mockable 通过使用 Pointfree 的 swift-issue-reporting 来动态报告具有适当测试框架的测试失败,从而支持 XCTest 和 Swift Testing。
有三种验证方式
called(_:):根据给定的值断言调用计数。getCalled(_:):仅适用于可变属性,断言属性访问计数。setCalled(_:):仅适用于可变属性,断言属性赋值计数。以下是一些示例断言
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)
如果你正在测试异步代码并且无法编写同步断言,你可以使用上述验证的异步对应项
calledEventually(_:before:):等待直到超时或满足调用计数。getCalledEventually(_:before:):等待直到超时或满足属性访问计数。setSalledEventually(_:before:):等待直到超时或满足属性赋值计数。以下是一些异步验证的示例
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[.relaxedVoid, .relaxedOptional].relaxed。你有两个选项来覆盖库的默认严格行为
let relaxedMock = MockService(policy: [.relaxedOptional, .relaxedVoid])
MockerPolicy.default = .relaxedVoid
.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-testing 或 SwiftLint。
要在“开发模式”下使用 Xcode 打开包,您需要设置 MOCKABLE_DEV=true 环境变量。 使用 Scripts/open.sh 打开项目(或将其内容复制到您的终端),以便在贡献时能够运行测试和 lint 代码。
Mockable 在 MIT 许可证下提供。 有关更多详细信息,请参见 LICENSE 文件。