- 编程语言
- 语言运行时
- 标准库
- 垃圾回收
- 并发编程
【免费下载链接】cangjie_runtime
仓颉编程语言运行时与标准库。
本篇指南以仓颉标准库std.unittest.mock的mock 框架桩(Stub)为核心,系统讲解桩的声明方式、查找与匹配顺序、预期校验、共享策略、参数捕获与自定义匹配器、属性/字段/顶层变量打桩,以及ReturnsDefaults与SyntheticFields两种桩模式。读完本文,你将能基于@On宏编写可维护、简洁、无顺序依赖的 mock 测试用例,并理解桩在框架内部的匹配与默认行为机制。
文中所有代码示例均可直接放入@Test测试用例中运行;框架源码位于 stdlib/libs/std/unittest/mock 目录,关键实现文件将随章节给出,便于对照研读。
桩的工作原理
桩通过在测试用例内部调用@On宏来声明,该声明在特定测试用例执行完成之前有效。多个测试用例之间可以共享桩。@On宏定义于 stdlib/libs/std/unittest/mock/mockmacro/macros.cj,宏展开阶段会把桩调用解析为函数调用(Function)、属性读取(Getter)或属性写入(Setter)三种形态,再生成对应的桩注册代码。
mock 框架处理 mock/spy 对象成员(或静态成员、顶层函数、顶层变量)调用时的顺序如下:
- 查找特定声明的桩。后声明的桩优先于之前声明的桩;测试用例主体内部声明的桩优先于共享桩。
- 应用每个桩的参数匹配器。如果所有参数都成功匹配,则执行该桩定义的操作。
- 如果找不到桩,或者没有与实际参数匹配的桩,则应用默认行为:
- 对于mock 对象,上报未打桩调用错误(
UnstubbedInvocationException,见 stdlib/libs/std/unittest/mock/exceptions.cj); - 对于spy 对象,调用被监视实例的原始成员;
- 对于静态成员、顶层函数或顶层变量,调用原始对应声明。
- 对于mock 对象,上报未打桩调用错误(
这一查找逻辑在 stdlib/libs/std/unittest/mock/framework.cj 中体现:框架从sessions数组末尾(最新会话)向开头遍历各会话的桩链,命中后执行PerformScenario;全部未命中则进入handleUnstubbed默认分支——spy 返回CallBase(调用原始实现),mock 则上报未打桩调用错误。
无论是否为单个成员定义了多个桩,每个桩都有自己的预期,需要满足这些预期才能通过测试。
@On(foo.bar(1)).returns(1) @On(foo.bar(2)).returns(2) foo.bar(2) // 第一个桩已定义但从未使用,测试失败注意:foo.bar(2)只触发第二个桩,第一个桩因为从未被调用而未满足其隐式预期(默认atLeastOnce),测试仍然失败。这是"桩与预期"耦合的典型体现——定义桩的同时也在声明"这个行为必须被用到"。
重新定义桩
如果希望在测试中更改桩的行为,可以重新定义桩。后声明的桩会隐藏先声明的桩,因此可以在同一个测试用例中分阶段描述被测系统的行为演变:
@On(service.request()).returns(testData) // 使用服务 @On(service.request()).throws(Exception()) // 测试服务开始失败时会发生什么事情注意:重新定义后,第一个桩(returns(testData))将不再被匹配到,若它已被使用过且设定了严格基数,则相应预期已经满足;若尚未使用,则测试会因预期未满足而失败。重新定义桩非常适合模拟"系统先成功、后失败"或"先失败、后成功"的故障注入场景。
同一声明定义多个桩
根据不同参数,可以使用多个桩来定义不同的行为。典型场景是"一个默认行为 + 若干特殊参数行为":
@On(storage.get(_)).returns(None) // 1 @On(storage.get(TEST_ID)).returns(Some(TEST_DATA)) // 2示例中,storage为除TEST_ID之外的所有参数返回None。这里必须理解两条限制:
- 如果从未使用
TEST_ID参数调用get,则测试失败,因为桩2未使用; - 如果始终使用
TEST_ID参数调用get,则测试失败,因为桩1未使用。
这些限制确保测试代码是"纯净"的,让开发人员知道桩何时变为未使用。如果用例不需要此功能,则使用anyTimes()基数说明符来提升这些预期:
// 实现经常更改,但不希望测试中断 // 使用 anyTimes 提升与测试本身无关的预期 @On(storage.get(_)).returns(None).anyTimes() @On(storage.get(TEST_ID)).returns(Some(TEST_DATA)) // 测试必须调用正在测试的内容anyTimes()由CardinalitySelector提供(见 stdlib/libs/std/unittest/mock/cardinality.cj),表示"触发任意次数(含 0 次)均满足预期",适合放行与测试目标无关的支撑性调用。
鉴于桩优先级是从下到上(后声明者优先),以下用法都不正确:
@On(storage.get(TEST_ID)).returns(Some(TEST_DATA)) // 不正确,这个桩永远不会被触发 @On(storage.get(_)).returns(None) // 在上面的桩始终会被隐藏上例中,泛化的get(_)桩声明在后、优先级更高,永远先于get(TEST_ID)被匹配,因此get(TEST_ID)桩形同虚设且必然因预期未满足导致测试失败。把更具体的桩写在更泛化的桩之后,是组织多个桩时的基本纪律。
您还可以使用预期来检查调用的参数。下例用fails()兜底拒绝所有调用,再用argThat(isVisible)放行可见组件的渲染:
let renderer = spy(Renderer()) @On(renderer.render(_)).fails() let isVisible = { c: Component => c.isVisible } @On(renderer.render(argThat(isVisible))).callsOriginal() // 只允许可见的组件这里fails()桩的语义是"只要触发即测试失败",用来防止任何不可见组件被渲染;而更具体的argThat(isVisible)桩声明在后、优先级更高,可见组件会先命中它并调用原始实现。
共享 mock 对象和桩
测试需要大量使用 mock 对象时可以多个测试用例共享 mock 对象和/或桩。可以在任何位置创建 mock 或 spy 对象。然而,如果误将 mock 对象从一个测试用例泄漏到另一个测试用例,可能导致顺序依赖问题或测试不稳定。因此,不建议这样操作,mock 框架也会检测这类情况——框架通过MockScopeViolation报告在创建会话之外访问 mock 对象的越界行为(见 stdlib/libs/std/unittest/mock/framework.cj)。
在同一测试类下的测试用例之间共享 mock 或 spy 对象时,可以将它们放在该类的实例变量中。
桩声明中隐含了预期,因此更难处理共享桩。测试用例之间不能共享预期。可以声明桩的位置:
- 测试用例主体(无论是
@Test函数还是@Test类中的@TestCase):检查预期。 @Test类的BeforeAll宏修饰的函数或者beforeAll函数中:在测试用例之间共享桩。这样的桩不能声明预期,预期也不会被检查;不允许使用基数说明符;只允许returns(value)、throws(exception)、fails()、callsOriginal()等无状态操作。可以将这些桩视为具有隐式anyTimes()基数。- 如果测试用例的预期相同,则可以在测试用例主体中提取和调用函数(测试类中非测试用例的成员函数)。
说明:
不要在测试类构造函数中声明桩。否则可能导致框架运行内部错误。
在测试用例主体(@Test类中的@TestCase)中声明桩的示例——把共享默认桩提取为类成员函数,由各用例显式调用:
import std.unittest.mock.* import std.unittest.mock.mockmacro.* class Foo { public func bar(x: Int64): String { match (x) { case 0 => "zero" case 1 => "one" case _ => "default" } } } @Test class TestFoo { let foo = mock<Foo>() func setupDefaultStubs() { @On(foo.bar(_)).returns("default") } @TestCase func testZero() { setupDefaultStubs() @On(foo.bar(0)).returns("zero") foo.bar(0) // 返回"zero" foo.bar(1) // 返回"default" } @TestCase func testOne() { setupDefaultStubs() @On(foo.bar(0)).returns("zero") foo.bar(0) // 返回"zero" // 预期失败,桩已声明但从未使用 } }在@Test类的beforeAll函数中共享桩的示例——每个测试用例隐式共享"默认返回"default""的行为,无需逐用例重复声明:
import std.unittest.mock.* import std.unittest.mock.mockmacro.* @Test class TestFoo2 { let foo = mock<Foo>() // 单元测试框架会在执行测试用例之前调用以下内容 @BeforeAll public func beforeAll(): Unit { // 在所有测试用例之间共享默认行为 // 此桩无需在每个测试用例中使用 @On(foo.bar(_)).returns("default") } @TestCase func testZero() { @On(foo.bar(0)).returns("zero") // 本测试用例中需要使用此桩 foo.bar(0) // 返回 "zero" foo.bar(1) // 返回 "default" } @TestCase func testOne() { @On(foo.bar(0)).returns("one") foo.bar(0) // 返回 "one" } }注意beforeAll中共享的桩具有隐式anyTimes()基数,因此它不会因为"某些用例没用到"而失败;而用例主体内声明的桩(如testOne中的returns("one"))仍然受严格预期约束。两个示例共同印证了"测试用例主体声明的桩优先于共享桩"的规则。
捕获参数
mock 框架使用captor(ValueListener)参数匹配器捕获参数来检查传递到桩声明的实际参数。只要触发了桩,ValueListener就会拦截相应的参数,并检查参数和/或添加验证参数。ValueListener<T>接口定义于 stdlib/libs/std/unittest/mock/captor.cj,其实现ValueListenerImpl内部维护了回调列表callBacks与已收集值列表values,每次supplyValue被调用时依次执行回调并追加值。
每次调用时,还可以使用ValueListener.onEach静态函数来验证某个条件。接受 lambda 后,触发桩时都会调用这个 lambda,lambda 用于接收参数的值:
let renderer = spy(TextRenderer()) let markUpRenderer = MarkupRenderer(renderer) // 创建验证器 let validator = ValueListener.onEach { str: String => @Assert(str == "must be bold") } // 使用 'capture' 参数匹配器绑定参数到验证器 @On(renderer.renderBold(capture(validator))).callsOriginal() // 如果从来没有调用过,则测试失败 markUpRenderer.render("text inside tag <b>must be bold</b>")另外ValueListener还提供了allValues()和lastValue()函数来检查参数。allValues()返回已收集值的快照Array<T>,lastValue()返回最近一次参数值Option<T>。模式如下:
// 创建捕获器 let captor = ValueListener<String>.new() // 使用'capture'参数匹配器绑定参数到捕获器 @On(renderer.renderBold(capture(captor))).callsOriginal() markUpRenderer.render("text inside tag <b>must be bold</b>") let argumentValues = captor.allValues() @Assert(argumentValues.size == 1 && argumentValues[0] == "must be bold")argThat匹配器是一个结合了参数过滤和捕获的重载函数。argThat(listener, filter)接受ValueListener实例和filter谓词,listener只收集通过filter检查的参数(源码见 stdlib/libs/std/unittest/mock/argument_matchers.cj):
let filter = { arg: String => arg.contains("bold") } let captor = ValueListener<String>.new() // 失败,除非参数被拦截,但下面已经声明了桩 @On(renderer.renderBold(_)).fails() // 只收集包含 "bold" 的字符串 @On(renderer.renderBold(argThat(captor, filter))).callsOriginal() markUpRenderer.render("text inside tag <b>must be bold</b>") // 可以使用 'captor' 对象检查所有过滤参数 @Assert(captor.lastValue() == "must be bold")参数捕获器可以与 mock 和 spy 对象一起使用。但是,在@Called宏中不允许使用此类参数匹配器——框架会在api_usage_error.cj中报告"捕获参数的匹配器不允许出现在@Called宏内"的用法错误(见 stdlib/libs/std/unittest/mock/api_usage_error.cj),因为验证语句需要的是可重放的签名描述,而非带副作用的监听器。
自定义和使用参数匹配器
为了避免重复使用相同的参数匹配器,可以自定义参数匹配器。由于每个匹配器都只是Matchers类的静态函数,因此可以使用**扩展(extend)**来自定义参数匹配器。新参数匹配器需要调用现有的(实例)匹配器。
如下示例为在测试用例之间共享匹配器:
@On(foo.bar(oddNumbers())).returns("Odd") @On(foo.bar(evenNumbers())).returns("Even") foo.bar(0) // "Even" foo.bar(1) // "Odd"Matchers类集中定义了全部内置匹配器(any、eq、same、ofType、argThat、capture、none等,见 stdlib/libs/std/unittest/mock/argument_matchers.cj)。自定义匹配器通过extend Matchers添加静态函数实现:
import std.unittest.mock.* extend Matchers { static func evenNumbers(): TypedMatcher<Int> { argThat {arg: Int => arg % 2 == 0} } static func oddNumbers(): TypedMatcher<Int> { argThat {arg: Int => arg % 2 == 1} } }函数参数匹配器可以包含参数:
import std.unittest.mock.* extend Matchers { // 只接受Int参数。 static func isDivisibleBy(n: Int): TypedMatcher<Int> { argThat {arg: Int => arg % n == 0} } }大多数匹配器函数都指定了返回类型TypedMatcher<T>。这样的匹配器只接受类型为T的参数。在桩声明中使用参数匹配器调用时,类型为T的值应该是被打桩函数或属性 setter 的有效参数。换句话说,类型T应该是参数子类型或与参数实际类型相同。
设置属性和字段和顶层变量
字段、属性和顶层变量打桩的方式与函数相同,可以依相同操作来配置返回值。
setter 类似于返回Unit的函数。特殊操作doesNothing()可用于 setter。可变属性打桩的常用模式如下:
@On(foo.prop).returns("value") // 配置getter @On(foo.prop = _).doesNothing() // 忽略setter调用极少场景下,我们期望可变属性的行为与字段的行为相同。要创建合成字段(框架生成的字段),请使用SyntheticField.create静态函数。合成字段存储由 mock 框架来管理。适用于 mock 含有可变属性和字段的接口或抽象类的场景。SyntheticField<T>类定义于 stdlib/libs/std/unittest/mock/fields.cj,其create(initialValue!: T)以命名参数接收初始值,底层由FieldStorage按字段描述 ID 维护显式字段的值(stdlib/libs/std/unittest/mock/fields.cj)。
执行getsField和setsField桩操作将字段或顶层变量绑定到特定的调用,这些操作可以将预期配置为任何其他操作:
import std.unittest.mock.* import std.unittest.mock.mockmacro.* interface Foo { mut prop bar: String } @Test func test() { let foo = mock<Foo>() let syntheticField = SyntheticField.create(initialValue: "initial") @On(foo.bar).getsField(syntheticField) // 对属性的读取访问即为读取合成字段 @On(foo.bar = _).setsField(syntheticField) // 为属性写入新值 // 此时'bar'属性表现为字段 }注意:
如果多个测试用例之间共享
SyntheticField对象,则该字段本身的值会在每个测试用例之前重置为initialValue,避免在测试之间共享可变状态。
桩的模式
通常,当一些调用匹配不到任何桩时将抛出异常。但是,对于某些常见情况,mock 对象可以配置增加默认行为,此时,当匹配不到任何桩时,将执行默认行为。这通过启用桩模式来实现。
有两种可用的模式ReturnsDefaults和SyntheticFields。这些模式通过枚举类型StubMode表示。StubMode枚举定义于 stdlib/libs/std/unittest/mock/create.cj。可以通过在创建 mock 对象时将其传递给mock函数来为特定的 mock 对象启用桩模式:
public func mock<T>(modes: Array<StubMode>): T桩模式可用于在配置 mock 对象时减少代码,并且它们可以与显式桩自由组合。显式的桩始终优先于其默认行为。框架源码中Invocation.isDefaultEnabled仅在 mock 对象启用了ReturnsDefaults且调用不是 setter 时才为真,且显式桩链的匹配优先于默认值分支(见 stdlib/libs/std/unittest/mock/framework.cj)。
注意:
使用桩模式不会对 mock 对象的成员强加任何期望。 当用例是检查是否仅调用 mock 对象的某些特定成员,则应谨慎使用桩模式。被测对象的行为可能会以不期望的方式发生变化,但测试仍可能通过。 当前桩模式不支持打桩静态成员和顶级函数/变量。
ReturnsDefaults 模式
在此模式下,当成员的返回类型在如下表格中时,无需显式配置桩,即可调用:
let foo = mock<Foo>(ReturnsDefaults) @Assert(foo.isPretty(), false)此类成员返回的默认值如下表所示:
| 类型 | 默认值 |
|---|---|
| Bool | false |
| numbers | 0 |
| String | empty string |
| Option | None |
| ArrayList, HashSet, Array | new empty collection |
| HashMap | new empty map |
这些默认值由HasDefaultValueForStub<T>接口及其扩展逐一提供,实现在 stdlib/libs/std/unittest/mock/internal/default_values.cj:数值类型返回0、Bool返回false、String返回""、Option<T>返回None、Array/ArrayList/HashSet返回空集合、HashMap返回空映射,此外Unit返回()。
ReturnsDefaults模式仅对如下成员生效:
- 返回值为支持类型(如上表)的成员函数。
- 类型为支持类型(如上表)的属性读取器和字段。
SyntheticFields 模式
SyntheticFields模式可简化对SyntheticField的配置动作,详见 设置属性和字段和顶层变量 章节。
SyntheticFields将通过 mock 框架为所有属性和字段隐式创建对应类型的合成字段。但是,这些字段只能在被赋值后读取。仅对可变属性和字段生效。框架在handleUnstubbed中处理该模式:setter 调用写入FieldStorage的自动字段(以 mock 对象 ID + 成员名作为AutoFieldId键),getter 优先返回已写入的值,未赋值且无法返回默认值时上报"在赋值前读取"错误(见 stdlib/libs/std/unittest/mock/framework.cj 与 stdlib/libs/std/unittest/mock/fields.cj)。
let foo = mock<Foo>(SyntheticFields) // can simply assign a value to a mutable property foo.bar = "Hello" @Assert(foo.bar, "Hello")赋给属性和字段的值仅在相应的测试用例中可见(框架按会话维护fieldStorage,用例之间互不泄漏)。
当同时启用SyntheticFields和ReturnsDefaults时,赋的值优先于默认值。但是,只要字段或属性尚未被赋值,就可以使用默认值:
let foo = mock<Foo>(ReturnsDefaults, SyntheticFields) @Assert(foo.bar, "") foo.bar = "Hello" @Assert(foo.bar, "Hello")小结
桩是std.unittest.mockmock 框架的核心配置手段。把握住四条主线即可写出稳健的测试用例:
- 优先级纪律:后声明的桩优先、用例主体桩优先于共享桩,多个桩共存时把具体匹配器写在泛化匹配器之后;
- 预期意识:每个桩默认带有
atLeastOnce预期,未使用的桩会导致测试失败,可用anyTimes()放行与测试目标无关的支撑调用; - 作用域边界:测试用例主体内声明桩可检查预期;
beforeAll/@BeforeAll中声明无状态共享桩(隐式anyTimes());不要在任何构造函数中声明桩; - 默认行为兜底:
ReturnsDefaults提供常用类型的默认值,SyntheticFields把可变属性/字段当作可读写的合成字段,二者均可与显式桩自由组合且显式桩优先。
配合参数捕获(ValueListener、capture、argThat)、自定义匹配器(extend Matchers)以及getsField/setsField字段绑定操作,即可覆盖从简单返回值、异常抛出到复杂交互验证的绝大多数单测场景。更底层的匹配与分发机制可继续研读 framework.cj、argument_matchers.cj 与 mockmacro/macros.cj;配套的基础概念、验证 API 与快速入门可分别参阅 mock 基础概念及用法、mock 框架验证 API 与 mock 框架快速入门。
- 编程语言
- 语言运行时
- 标准库
- 垃圾回收
- 并发编程
【免费下载链接】cangjie_runtime
仓颉编程语言运行时与标准库。
相关推荐
仓颉标准库 std.argopt 参数解析异常 ArgumentParseException:声明、构造与实战捕获
仓颉标准库 std.argopt 参数解析异常 ArgumentParseException:声明、构造与实战捕获 导读 ArgumentParseExcept
人工智能AI 技能文档开发工具仓颉 AOP 开发实战:基于 stdx.aspectCJ 实现函数入口插桩、退出插桩与函数体替换
仓颉 AOP 开发实战:基于 stdx.aspectCJ 实现函数入口插桩、退出插桩与函数体替换 导读 stdx.aspectCJ 是仓颉标准库扩展(stdx)
文档知识库教程OpenHarmonyClearerVoice-Studio 语音处理工具包实战:ClearVoice 统一推理平台与 SpeechScore 评测指南
ClearerVoice Studio 语音处理工具包实战:ClearVoice 统一推理平台与 SpeechScore 评测指南 ClearerVoice S
文档知识库教程OpenHarmony
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考