- 编程语言
- 语言运行时
- 标准库
- 垃圾回收
- 并发编程
【免费下载链接】cangjie_runtime
仓颉编程语言运行时与标准库。
本篇技术指南围绕仓颉标准库std.unittest.mock的mock 框架基础概念与用法展开,系统讲解如何通过mock<T>/spy<T>创建测试替身,如何用@On宏(配置 API)为成员、静态声明与顶层声明打桩,以及参数匹配器、操作 API、预期(cardinality)与桩链(continuation)的完整语义。读完本文,你将掌握在仓颉单元测试中消除外部依赖、模拟异常场景并精确控制桩调用次数与顺序的实战能力。
本文以stdlib/doc/libs/std/unittest_mock/unittest_mock_samples/mock_framework_basics.md为骨架,并结合仓库内stdlib/libs/std/unittest/mock/目录下的真实源码实现(create.cj、action_selectors.cj、cardinality.cj、argument_matchers.cj、selectors.cj、macros.cj、configure.cj等)进行纵深印证。
创建 mock 对象
mock 构造函数可以通过调用mock<T>和spy<T>函数来创建两种对象:mock和spy,其中T表示被 mock 的类或接口。
public func mock<T>(): T public func spy<T>(objectToSpyOn: T): Tmock作为骨架对象,默认不对成员进行任何操作——也就是说,未打桩的成员调用会直接导致测试失败,这正是“mock 即空壳”的设计意图。
spy作为一种特殊的 mock 对象用于封装某个类或接口的当前实例。默认情况下,spy 对象将其成员调用委托给底层对象。其他方面,spy 和 mock 对象非常相似。
只有类(包括 final 类和 sealed 类)和接口支持通过这种方式 mock。
从源码看,create.cj 中
mock<T>()实际调用create<T>(Mock, []) { handler => unsafe { createMock<T>(handler) }},而spy<T>(objectToSpyOn)则调用create<T>(Spy, []) { handler => unsafe { createSpy<T>(handler, objectToSpyOn) }}。二者共用同一个create<T>工厂:框架为对象生成唯一 ID、注册MockObject,并将类型强制转换为T返回,从而保证 mock/spy 对象拥有与真实对象签名一致的 API。
关于 spy 与 mock 在使用层面的差异,参见下文使用 spy 和 mock 对象;关于如何 mock 顶级和静态声明,参见顶级和静态声明。
配置 API
配置 API是框架的核心,可以定义 mock/spy 对象成员(或顶层/静态声明)的行为(或重新定义 spy 对象(或顶层/静态声明)的行为)。
配置 API的入口是@On宏调用。
@On(storage.getComments(testId)).returns(testComments)示例中,如果 mock 对象storage接收到getComments方法的调用,并且指定了参数testId,则返回testComments。
如上行为即为打桩,桩(Stub,模拟还未实现或无法在测试环境中执行的组件)需在测试用例主体内部先定义。
源码印证:@On 宏的实现位于 mockmacro/macros.cj,它通过
parseAndGenerate解析桩调用表达式,再按调用形态分发到generateStubFunction/generateStubGetter/generateStubSetter,最终在ConfigureMock(configure.cj)中生成对应的MethodActionSelector/GetterActionSelector/SetterActionSelector。由此可见,@On并非运行时魔法,而是编译期宏展开后桥接到框架运行时配置的过程。
如下声明类型可以被打桩:
- 类和接口的实例成员(包括 final 成员)
- 静态函数、属性和字段
- 顶层函数和变量
以下声明不能打桩:
- 扩展成员
- Foreign 函数
- 局部函数和变量
- 构造器
- 常量
- 任意私有声明
一个完整的桩声明包含以下部分:
@On宏调用中描述的桩签名。- 用于描述桩行为的操作。
- (可选)用于设置预期的基数说明符(cardinality specifier,指定预期执行次数的表达式)。
- (可选)续体(continuation,支持链式调用的表达式)。
mock 框架拦截匹配桩签名的调用,并执行桩声明中指定的操作。
顶级和静态声明
与类或接口的成员不同,要打桩静态成员或顶层函数或变量时,不需要创建模拟对象。这些声明应该直接使用配置 API(例如@On宏)进行打桩。
如下是一个为顶层函数打桩的示例:
import std.unittest.mock.* import std.unittest.mock.mockmacro.* public class Entry { Entry(let id: Int64, let title: String, let description: String) {} static func parse(): Entry { Entry(1, "1", "1") } } public func loadLastEntryInCatalog(): Entry { return Entry.parse() } public func drawLastEntryWidget() { let lastEntry = loadLastEntryInCatalog() // drawing... } @Test class RightsTest { @TestCase func removeLastEntry() { @On(loadLastEntryInCatalog()).returns(Entry(1, "Test entry", "Test description")) drawLastEntryWidget() } }从源码看,configure.cj 中定义了
STATIC_DECLARATIONS_MOCK_KIND = MockKind.Spy,这意味着静态成员与顶层声明的打桩行为在框架内部按spy 语义处理——未打桩的声明会调用原始实现(详见下文使用 spy 和 mock 对象中的说明)。
桩签名
桩签名定义了与特定调用子集匹配的一组条件,包括以下部分:
- mock/spy 对象的引用,必须是单个标识符。(独立声明(顶层或静态函数、变量)不需要此部分)
- 成员以及独立声明的调用。
- 特定格式的参数调用,参见参数匹配器。
签名可以匹配以下实体:
- 方法
- 属性 getter
- 属性 setter
- 字段读操作
- 字段写操作
- 静态函数
- 静态属性 getter
- 静态属性 setter
- 静态字段读操作
- 静态字段写操作
- 顶层函数
- 顶层字段读操作
- 顶层字段写操作
只要对应声明被调用,并且所有参数(若有)都与相应的参数匹配器匹配时,桩签名就会匹配调用。
方法的桩的签名结构:<mock object name>.<method name>(<argument matcher>*)。
@On(foo.method(0, 1)) // 带参数 0 和 1 的方法调用 @On(foo.method(param1: 0, param2: 1)) // 带命名参数的方法调用当桩属性 getter/setter 或字段读/写操作时,使用<mock object name>.<property or field name>或<mock object name>.<property or field name> = <argument matcher>。
@On(foo.prop) // 属性 getter @On(foo.prop = 3) // 参数为 3 的属性 setter对于顶层函数和静态函数,签名是类似的:
- 顶层函数:
<function name>(<argument matcher>*) - 静态函数:
<class name>.<static method name>(<argument matcher>*)
顶层变量和静态属性或字段的签名如下:
- 顶层变量:
<top-level variable name>或<top-level variable name> = <argument matcher> - 静态属性或字段:
<class name>.<static property/field name>或<class name>.<static property/field name> = <argument matcher>
对运算符函数打桩时,运算符的接收者必须是对 mock/spy 对象的单个引用,而运算符的参数必须是参数匹配器。
@On(foo + 3) // 'operator func +',参数为 3 @On(foo[0]) // 'operator func []',参数为 0参数匹配器
每个桩签名必须包含所有参数的参数匹配器。单个参数匹配器定义了一个条件,用于接受所有可能参数值的某个子集。每个匹配器都是通过调用Matchers类的静态方法来定义的。例如Matchers.any()是一个允许任何参数的有效匹配器。为了方便起见,提供省略Matcher.前缀的语法糖。
预定义的匹配器包括:
| 匹配器 | 描述 | 语法糖 |
|---|---|---|
| any() | 任何参数 | _符号 |
eq(value: Equatable) | value结构相等(structural equality,对象的值相等,不一定内存相同)的参数 | 允许使用单个identifier和常量字面量 |
same(reference: Object) | reference引用相等(referential equality,对象的引用相等,内存相同)的参数 | 允许单个identifier |
ofType<T>() | 仅匹配T类型的值 | - |
argThat(predicate: (T) -> Bool) | 仅匹配由predicate筛选出的T类型的值 | - |
| none() | 匹配选项类型的None值 | - |
如果使用单个标识符作为参数匹配器,则优先选择结构相等的参数。
如果方法有默认参数,并且没有显式指定该参数,则使用 any() 匹配器。
示例:
let p = mock<Printer>() // 假设 print 采用 ToString 类型的单个参数。 @On(p.print(_)) // 可以使用“_”特殊字符代替 any() 。 @On(p.print(eq("foo"))) // 只匹配“foo”参数。 @On(p.print("foo")) // 常量字符串可以省略显式匹配器。 let predefined = "foo" // 可以传递单个标识符,而不是参数匹配器。 @On(p.print(predefined)) // 如果类型相等,则使用结构相等来匹配。 @On(p.print(ofType<Bar>())) // 仅匹配 Bar 类型的参数。 // 对于更复杂的匹配器,鼓励使用以下模式。 let hasQuestionMark = {arg: String => arg.contains("?")} @On(p.print(argThat(hasQuestionMark))) // 只匹配包含问号的字符串。正确选择函数重载依赖仓颉类型推断机制。可以使用ofType来解决与类型推断有关的编译时问题。
源码印证:这些匹配器全部定义在 argument_matchers.cj 的
Matchers类中。其中any()返回AnyMatcher(无条件接受一切参数);eq<T>通过target == arg做结构相等判断(要求T <: Equatable<T>);same<T>通过refEq(target, arg)做引用相等判断(要求T <: Object);ofType<T>()与argThat<T>(predicate)均基于TypedMatcher<T>与PredicateMatcher<T>实现。另外该文件还提供了argThatNot、capture、default等补充匹配器,以及withDescription/forParameter这类用于改善错误报告的修饰方法。
重要规则:函数调用作为参数匹配器时,会视为对匹配器的调用。
@On(foo.bar(calculateArgument())) // 不正确,calculateArgument() 不是匹配器。 let expectedArgument = calculateArgument() @On(foo.bar(expectedArgument)) // 正确,只要 'expectedArgument' 是可等价的和/或引用类型。操作 API
mock 框架提供 API 来指定桩操作。触发桩后,打桩声明会执行指定的操作。如果调用与相应的@On宏调用指定的签名匹配,则会触发桩。
每个桩函数必须指定一个操作。@On宏调用返回的ActionSelector子类型会定义可用操作。操作列表取决于所打桩的实体。
源码印证:action_selectors.cj 中
ActionSelector是sealed abstract class,其三个公开子类分别面向不同实体:MethodActionSelector<TRet>(方法)、GetterActionSelector<TRet>(属性 getter / 字段读)、SetterActionSelector<TArg>(属性 setter / 字段写)。每个选择器都只暴露与该实体语义相符的操作集合,这是“操作列表取决于所打桩实体”的源码级依据。
通用(操作)
适用于所有桩的操作。
throws(exception: Exception):抛出exception。throws(exceptionFactory: () -> Exception):调用exceptionFactory去构造桩触发时抛出的异常。fails():如果触发了桩,则测试失败。
注意:
throws用于测试桩声明抛出异常时的系统行为。fails用于测试桩声明是否未被调用。
@On(service.request()).throws(TimeoutException())函数和属性/字段 Getter 和顶层变量读操作
R表示对应成员的返回类型。
returns():不做任何操作并返回(),仅当R为Unit时可用。returns(value: R):返回value。returns(valueFactory: () -> R):调用valueFactory去构造桩触发时返回的值(注:原文档此处笔误为“抛出”,按源码语义应为构造返回值)。returnsConsecutively(values: Array<R>)、returnsConsecutively(values: ArrayList<R>):触发桩时,返回values中的下一个元素。
@On(foo.bar()).returns(2) // 返回 2 @On(foo.bar()).returnsConsecutively(1, 2, 3) // 依次返回 1,2,3源码印证:
returns(valueFactory)在 action_selectors.cj 中实现为sc.setAction(Action.Return { factory() }),即每次触发桩时重新调用工厂;returnsConsecutively内部将values.toArray()后逐次iterator.next()弹出,并自动把基数设置为Cardinality.times(copy.size),这正是它“依次返回且必须恰好调用 N 次”的语义来源。此外returns()(返回 Unit)通过extend MethodActionSelector<Unit>提供。
属性/字段 Setter 和顶层变量写操作
doesNothing():忽略调用,不做任何操作。类似于返回 Unit 的函数的returns()。
更多信息详见 桩使用指南:设置属性和字段和顶层变量。
spy 操作
对于 spy 对象,可以使用其他操作来委托监控实例。
callsOriginal():调用原始方法。getsOriginal():调用原始属性 getter 或获取原始实例中的字段值。setsOriginal():调用原始属性 setter 或设置原始实例中的字段值。
源码印证:
callsOriginal/getsOriginal/setsOriginal在 action_selectors.cj 中都会先调用mustBeSpy(actionName),若当前对象是Mock而非Spy,则抛出StubMisconfiguration(错误信息为OnlySpyCanCallOriginal)。也就是说,这三个操作被错误地用于纯 mock 对象时,会在编译期/运行时被框架主动拦截。
预期
定义桩时会隐式或显式地向桩附加预期。桩可以定义期望的基数。操作(fails和returnsConsecutively除外)返回CardinalitySelector的实例,该实例可以使用基数说明符自定义预期。
CardinalitySelector定义了如下函数:
once()atLeastOnce()anyTimes()times(expectedTimes: Int64)times(min!: Int64, max!: Int64)atLeastTimes(minTimesExpected: Int64)
anyTimes说明符用于提升预期,即如果桩从未被触发,测试也不会失败。其他说明符都暗示了测试代码中特定桩的调用次数上下限。只要桩被触发的次数比预期的多,测试就会立即失败。下限在测试代码执行完毕后进行检查。
示例:
import std.unittest.mock.* import std.unittest.mock.mockmacro.* class Foo { func bar() {} } @Test func tooFewInvocations() { let foo = mock<Foo>() @On(foo.bar()).returns().times(2) foo.bar() }输出:
Expectation failed Too few invocations for stub foo.bar() declared at example_test.cj:9. Required: exactly 2 times Actual: 1 Invocations handled by this stub occured at: example_test.cj:6源码印证:
Cardinality(cardinality.cj)用min!与可选max!表达调用次数区间,其check(timesInvoked)返回Less/Ok/More三种判定;调用次数超过上限立即失败、低于下限在测试代码执行完毕后检查,正是基于该判定逻辑。CardinalitySelector与Continuation的实现见 selectors.cj:once()与times(n)返回续体(可继续链式then()),而anyTimes()、atLeastOnce()、atLeastTimes()、times(min, max)返回Unit。
如果没有自定义预期,mock 框架使用默认预期:
| 操作 | 默认期望基数 | 允许自定义基数 |
|---|---|---|
| fails | 不可调用 | 否 |
| returns | atLeastOnce | 是 |
| returnsConsecutively | times(values.size) | 否 |
| throws | atLeastOnce | 是 |
| doesNothing | atLeastOnce | 是 |
| (calls/gets/sets)Original | atLeastOnce | 是 |
桩链
returnsConsecutively操作,once和times(n)基数说明符返回一个续体实例。顾名思义,续体表示可以继续使用链,即指定某操作在前一个操作完全完成时立即执行。
续体本身只提供了一个返回新ActionSelector的then()函数。链上的所有操作都适用相同的规则。如果调用了then(),则必须指定新的操作。
总预期为各个链预期之和。如果在测试中指定了一个复杂的链,那么链的所有部分都会被触发。
@On(foo.bar()).returnsConsecutively(1, 2, 3, 4) // 同下 @On(foo.bar()).returnsConsecutively(1, 2).then().returnsConsecutively(3, 4)// 指定了一个桩,总共必须调用 NUM_OF_RETRIES 次 @On(service.request()).throws(TimeoutException()).times(NUM_OF_RETRIES - 1). // 请求会先超时几次 then().returns(response).once() // 第一个成功响应之后,停止发送请求源码印证:selectors.cj 中
Continuation.then()会为链上的下一段操作新建一个Scenario并挂到同一StubChain上,因此“总预期为各个链预期之和”,链的每一段都会分别检查自己的基数预期。
使用 spy 和 mock 对象
spy对象和mock对象在配置上是类似的,只不过 spy 对象监控的是当前实例。
主要区别如下:成员调用没有触发任何桩时,spy 对象调用底层实例的原始实现,mock 对象抛出运行时错误(并且测试失败)。
mock 对象无需创建真正的依赖来测试 API,只需配置特定测试场景所需的行为。
spy 对象支持重写真实实例的可观察行为。只有通过 spy 对象引用的调用才会被拦截。创建 spy 对象对原始 spy 实例的引用无影响。spy 调用自己的方法不会被拦截。
let serviceSpy = spy(service) // 模拟超时,继续使用真正的实现 @On(serviceSpy.request()).throws(TimeoutException()).once().then().callsOriginal() // 测试代码必须使用'serviceSpy'引用注意:
静态成员或顶级函数/变量的打桩行为类似于 spy,即对于未打桩的声明,将调用原始成员或原顶级函数/变量,而不是像 mock 中那样抛出异常。
mock 依赖
接口始终可以被 mock。从另一个包 mock 一个类时,类本身和它的超类必须按特定方式编译,即只能 mock 预编译库(如stdlib)中的接口,不能 mock 类。
使用 cjc 编译
对于cjc来说,mock 是通过--mock标志来控制的。如果想 mock 特定包中的类p,添加--mock=on标志到 cjc 进行调用。
说明:
在编译依赖
p的包时,也必须添加此标志。
在测试中使用 mock 对象(cjc --test)不需要额外的标志。
使用 cjpm 编译
cjpm会自动检测 mock 使用,并生成正确的cjc调用,确保可以从任何从源代码编译的包中 mock 类。
还可以使用 cjpm 配置文件控制哪些包支持 mock。
小结与进一步阅读
本文覆盖了 mock 框架的完整基础骨架:mock<T>/spy<T>的对象创建、@On宏驱动的配置 API、桩签名的书写规则、Matchers参数匹配器、按实体区分的操作 API、基数说明符与默认预期表,以及续体(桩链)的链接语义。这些概念也是理解 mock 框架高阶用法的前提:
- 完整的可运行示例与典型测试流程,可参阅 mock 框架入门;
- 桩的优先级、重定义、多桩、共享桩、参数捕获与桩模式(
ReturnsDefaults/SyntheticFields)等进阶模式,可参阅 桩使用指南; - 调用次数、特定参数与调用顺序的交互验证,可参阅 mock 框架验证 API;
- 完整的公开 API(函数、类、枚举、异常)清单,可参阅 std.unittest.mock 包总览。
mock 框架的运行时实现集中在stdlib/libs/std/unittest/mock/与stdlib/libs/std/unittest/mock/mockmacro/目录下,读者可结合源码进一步研究@On宏的展开逻辑(mockmacro/macros.cj)、调用处理(internal/call_handler.cj)与失败报告(failure_report.cj)等内部机制。
- 编程语言
- 语言运行时
- 标准库
- 垃圾回收
- 并发编程
【免费下载链接】cangjie_runtime
仓颉编程语言运行时与标准库。
相关推荐
仓颉编程语言 Mock 测试框架基础:从 mock/spy 对象到 @On 桩配置与期望校验
仓颉编程语言 Mock 测试框架基础:从 mock/spy 对象到 @On 桩配置与期望校验 导读 std.unittest.mock 是仓颉标准库中内置于单元
编程语言语言运行时标准库垃圾回收并发编程仓颉标准库 std.unittest.mock 全解析:Mock/Spy 对象、桩配置与行为验证实战指南
仓颉标准库 std.unittest.mock 全解析:Mock/Spy 对象、桩配置与行为验证实战指南 仓颉(Cangjie)标准库中的 std.unitte
编程语言语言运行时标准库垃圾回收并发编程仓颉 mock 框架构造函数深度解析:mock\<T> 与 spy\<T> 的创建、桩模式与实战用法
仓颉 mock 框架构造函数深度解析:mock\<T 与 spy\<T 的创建、桩模式与实战用法 本文围绕仓颉标准库 std.unittest.mock 包中的
文档知识库教程OpenHarmony
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考