Mockable

Mockable

Swift协议自动模拟测试框架

Mockable是基于Swift宏的测试框架,可自动生成协议的模拟实现。它采用声明式语法,简化了单元测试中的服务模拟。框架支持返回值注册、行为验证和放松模式,可通过编译条件排除模拟实现。Mockable为Swift开发提供了高效的协议模拟方案,有助于提升代码测试效率。

Mockable单元测试Swift宏协议模拟自动生成Github开源项目
<p> <img width="300" src="https://github.com/Kolos65/Mockable/assets/26504214/4e19e4fc-8453-4320-a061-e672dcc95023" alt="@Mockable"/> </p>

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

目录

赞助

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

<a href="https://www.buymeacoffee.com/kolos"><img src="https://img.buymeacoffee.com/button-api/?text=给我买杯啤酒&emoji=🍺&slug=kolos&button_colour=FF5F5F&font_colour=ffffff&font_family=Arial&outline_colour=000000&coffee_colour=FFDD00" /></a>

文档

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

安装

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

Mockable提供两个库产品:

  • Mockable:包含@Mockable宏的核心库。
  • MockableTest:依赖于XCTest框架的测试工具(仅与测试目标链接)。

使用该库:

  1. Mockable添加到所有包含您想要模拟的协议的目标中。
  2. MockableTest添加到您的测试目标中。

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

配置

由于@Mockable是一个对等宏, 生成的代码将始终与附加到的协议处于相同的作用域。

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

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

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

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

  1. 打开您的Xcode项目
  2. 转到您目标的构建设置
  3. 找到Swift编译器 - 自定义标志
  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 MockableTest 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)

我们指定以下内容:

  • given:我们想要注册返回值
  • (productService):我们指定要为哪个可模拟服务注册返回值
  • .fetch(for: .value(id)):我们想要模拟fetch(for:)方法,并将行为限制在匹配id参数的调用上
  • .willThrow(error):如果使用指定的参数值调用fetch(for:),我们希望抛出一个错误

参数

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

  • .any:匹配对指定函数的每次调用,忽略实际参数值。
  • .value(Value):匹配在指定参数中具有相同值的调用。
  • .matching((Value) -> Bool):使用提供的闭包来过滤函数调用。

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

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

// 当使用`id`调用`fetch(for:)`时抛出错误 given(productService).fetch(for: .value(id)).willThrow(error) // 如果产品服务被调用时传入名为"iPhone 15 Pro"的产品,则打印"Ouch!" when(productService) .checkout(with: .matching { $0.name == "iPhone 15 Pro" }) .perform { print("Ouch!") } // 断言fetch(for:)是否恰好被调用一次,不管它被调用时使用了什么id参数 verify(productService).fetch(for: .any).called(.once)

Given

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

  • willReturn(_ value:):将存储给定的返回值并用于模拟后续调用。
  • willThrow(_ error:):将存储给定的错误并在后续调用中抛出。仅适用于可抛出的函数和属性。
  • willProduce(_ producer):将使用提供的闭包进行模拟。闭包的签名与被模拟的函数相同,例如,一个接受整数、返回字符串并可能抛出错误的函数将接受类型为(Int) throws -> String的闭包。

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

// 第一次调用时抛出错误,然后每次都返回'product' given(productService) .fetch(for: .any).willThrow(error) .fetch(for: .any).willReturn(product) // 如果id参数以0结尾则抛出错误,否则返回产品 given(productService) .fetch(for: .any).willProduce { id in if id.uuidString.last == "0" { throw error } else { return product } }

When

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

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

// 记录fetch(for:)的调用 when(productService).fetch(for: .any).perform { print("fetch(for:)被调用") } // 记录url被访问时 when(productService).url().performOnGet { print("url被访问") } // 记录url被设置为nil时 when(productService).url(newValue: .value(nil)).performOnSet { print("url被设置为nil") }

Verify

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

  • called(_:):根据给定值断言调用次数。
  • getCalled(_:):仅适用于可变属性,断言属性访问次数。
  • setCalled(_:):仅适用于可变属性,断言属性赋值次数。

以下是一些断言示例:

verify(productService) // 断言fetch(for:)被调用1到5次 .fetch(for: .any).called(.from(1, to: 5)) // 断言checkout(with:)恰好被调用10次 .checkout(with: .any).called(10) // 断言url属性至少被访问2次 .url().getCalled(.moreOrEqual(to: 2)) // 断言url属性从未被设置为nil .url(newValue: .value(nil)).setCalled(.never)

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

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

await verify(productService) // 断言fetch(for:)在默认超时时间(1秒)内被调用1到5次 .fetch(for: .any).calledEventually(.from(1, to: 5)) // 断言checkout(with:)在3秒内恰好被调用10次 .checkout(with: .any).calledEventually(10, before: .seconds(3)) // 断言url属性在默认超时时间(1秒)内至少被访问2次 .url().getCalledEventually(.moreOrEqual(to: 2)) // 断言url属性被设置为nil一次 .url(newValue: .value(nil)).setCalledEventually(.once)

宽松模式

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

然而,通常更倾向于避免这种严格的默认行为,转而采用更宽松的设置,例如,void或可选返回值不需要显式的given注册。 使用 MockerPolicy(这是一个选项集)来隐式模拟:

  • 只模拟一种返回值类型:.relaxedMockable
  • 构造自定义策略集:[.relaxedVoid, .relaxedOptional]
  • 或选择完全放松模式:.relaxed

你有两种选择来覆盖库的默认严格行为:

  • 模拟实现级别,你可以在初始化器中为每个单独的模拟实现覆盖模拟策略:
    let relaxedMock = MockService(policy: [.relaxedOptional, .relaxedVoid])
  • 项目级别,你可以通过更改 MockerPolicy 的默认属性来设置在每种情况下使用的自定义默认策略:
    MockerPolicy.default = .relaxedVoid

.relaxedMockable 策略与 Mockable 协议结合使用,可以为自定义(甚至内置)类型设置隐式返回值:

struct Car { var name: String var seats: Int } extension Car: Mockable { static var mock: Car { Car(name: "Mock Car", seats: 4) } // 默认为 [mock],但我们可以 // 提供自定义的汽车数组: 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: .relaxedMockable) // 无需显式注册即可隐式模拟: let car = mock.getCar() let cars = mock.getCars() } }

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

处理非可比较类型

Mockable 内部使用 Matcher 来比较参数。 默认情况下,匹配器能够比较任何遵循 Equatable 的自定义类型(在泛型函数中使用时除外)。 在特殊情况下,当你

  • 有非可比较的参数类型
  • 需要为自定义类型测试特定的相等逻辑
  • 有使用自定义具体类型的泛型函数

你可以使用 Matcher.register() 函数注册你的自定义类型。 以下是如何操作:

// 将可比较类型注册到匹配器,因为我们在泛型函数中使用它 Matcher.register(SomeEquatableType.self) // 将非可比较类型注册到匹配器 Matcher.register(Product.self, match: { $0.name == $1.name }) // 将元类型注册到匹配器 Matcher.register(HomeViewController.Type.self) // 移除所有先前注册的自定义类型 Matcher.reset()

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

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

记得使用 register() 函数将注明的类型添加到你的 Matcher 中。

支持的功能

  • 零样板代码模拟生成</br>
  • 从生产目标中排除模拟实现</br>
  • 具有关联类型的协议</br>
  • 具有受约束关联类型的协议</br>
  • 初始化器要求</br>
  • 泛型函数参数返回值</br>
  • 带有 where 子句的泛型函数</br>
  • 计算属性和可变属性要求</br>
  • @escaping 闭包参数</br>
  • 隐式解包可选类型</br>
  • throwing、rethrowing 和 async 要求</br>
  • 自定义非可比较类型

限制

  • 静态要求:协议上不能使用静态成员,因此不支持。</br>
  • 协议继承:由于宏系统的限制,不会实现继承的协议要求。</br>
  • Rethrows 函数:Rethrowing 函数要求总是用非抛出函数实现。</br>
  • 非逃逸函数参数:非逃逸闭包参数无法存储,因此不支持。</br>
  • 下标暂不支持。</br>
  • 运算符暂不支持。</br>

贡献

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

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

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

许可证

Mockable 根据 MIT 许可证提供。请查看 LICENSE 文件了解更多详情。

编辑推荐精选

Keevx

Keevx

AI数字人视频创作平台

Keevx 一款开箱即用的AI数字人视频创作平台,广泛适用于电商广告、企业培训与社媒宣传,让全球企业与个人创作者无需拍摄剪辑,就能快速生成多语言、高质量的专业视频。

即梦AI

即梦AI

一站式AI创作平台

提供 AI 驱动的图片、视频生成及数字人等功能,助力创意创作

扣子-AI办公

扣子-AI办公

AI办公助手,复杂任务高效处理

AI办公助手,复杂任务高效处理。办公效率低?扣子空间AI助手支持播客生成、PPT制作、网页开发及报告写作,覆盖科研、商业、舆情等领域的专家Agent 7x24小时响应,生活工作无缝切换,提升50%效率!

TRAE编程

TRAE编程

AI辅助编程,代码自动修复

Trae是一种自适应的集成开发环境(IDE),通过自动化和多元协作改变开发流程。利用Trae,团队能够更快速、精确地编写和部署代码,从而提高编程效率和项目交付速度。Trae具备上下文感知和代码自动完成功能,是提升开发效率的理想工具。

AI工具TraeAI IDE协作生产力转型热门
蛙蛙写作

蛙蛙写作

AI小说写作助手,一站式润色、改写、扩写

蛙蛙写作—国内先进的AI写作平台,涵盖小说、学术、社交媒体等多场景。提供续写、改写、润色等功能,助力创作者高效优化写作流程。界面简洁,功能全面,适合各类写作者提升内容品质和工作效率。

AI辅助写作AI工具蛙蛙写作AI写作工具学术助手办公助手营销助手AI助手
问小白

问小白

全能AI智能助手,随时解答生活与工作的多样问题

问小白,由元石科技研发的AI智能助手,快速准确地解答各种生活和工作问题,包括但不限于搜索、规划和社交互动,帮助用户在日常生活中提高效率,轻松管理个人事务。

热门AI助手AI对话AI工具聊天机器人
Transly

Transly

实时语音翻译/同声传译工具

Transly是一个多场景的AI大语言模型驱动的同声传译、专业翻译助手,它拥有超精准的音频识别翻译能力,几乎零延迟的使用体验和支持多国语言可以让你带它走遍全球,无论你是留学生、商务人士、韩剧美剧爱好者,还是出国游玩、多国会议、跨国追星等等,都可以满足你所有需要同传的场景需求,线上线下通用,扫除语言障碍,让全世界的语言交流不再有国界。

讯飞智文

讯飞智文

一键生成PPT和Word,让学习生活更轻松

讯飞智文是一个利用 AI 技术的项目,能够帮助用户生成 PPT 以及各类文档。无论是商业领域的市场分析报告、年度目标制定,还是学生群体的职业生涯规划、实习避坑指南,亦或是活动策划、旅游攻略等内容,它都能提供支持,帮助用户精准表达,轻松呈现各种信息。

AI办公办公工具AI工具讯飞智文AI在线生成PPTAI撰写助手多语种文档生成AI自动配图热门
讯飞星火

讯飞星火

深度推理能力全新升级,全面对标OpenAI o1

科大讯飞的星火大模型,支持语言理解、知识问答和文本创作等多功能,适用于多种文件和业务场景,提升办公和日常生活的效率。讯飞星火是一个提供丰富智能服务的平台,涵盖科技资讯、图像创作、写作辅助、编程解答、科研文献解读等功能,能为不同需求的用户提供便捷高效的帮助,助力用户轻松获取信息、解决问题,满足多样化使用场景。

热门AI开发模型训练AI工具讯飞星火大模型智能问答内容创作多语种支持智慧生活
Spark-TTS

Spark-TTS

一种基于大语言模型的高效单流解耦语音令牌文本到语音合成模型

Spark-TTS 是一个基于 PyTorch 的开源文本到语音合成项目,由多个知名机构联合参与。该项目提供了高效的 LLM(大语言模型)驱动的语音合成方案,支持语音克隆和语音创建功能,可通过命令行界面(CLI)和 Web UI 两种方式使用。用户可以根据需求调整语音的性别、音高、速度等参数,生成高质量的语音。该项目适用于多种场景,如有声读物制作、智能语音助手开发等。

下拉加载更多