桩使用指南

mock/spy 对象和桩的使用方法多种多样。本文介绍了不同的模式和用例,便于用户编写 mock 框架的可维护且简洁的测试用例。

桩的工作原理

通过在测试用例内部调用 @On 宏来声明,该声明在特定测试用例执行完成之前有效。多个测试用例之间可以共享桩

mock 框架处理 mock/spy 对象成员调用时的顺序如下:

  • 查找特定成员的桩。后声明的桩优先于之前声明的桩。测试用例主体内部声明的桩优先于共享桩。
  • 应用每个桩的参数匹配器。如果所有参数都成功匹配,则执行该桩定义的操作。
  • 如果找不到桩,或者没有与实际参数匹配的桩,则应用默认行为:对于 mock 对象,上报未打桩调用错误;对于 spy 对象,调用监控实例的原始成员。

无论是否为单个成员定义了多个桩,每个桩都有自己的预期,需要满足这些预期才能通过测试。

@On(foo.bar(1)).returns(1)
@On(foo.bar(2)).returns(2)

foo.bar(2)
// 第一个桩已定义但从未使用,测试失败

重新定义桩

如果希望在测试中更改桩的行为,可以重新定义桩。

@On(service.request()).returns(testData)
// 使用服务

@On(service.request()).throws(Exception())
// 测试服务开始失败时会发生什么事情

同一成员定义多个桩

根据不同参数,可以使用多个桩来定义不同的行为。

示例:

@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)) // 测试必须调用正在测试的内容

鉴于桩优先级是从下到上,以下用法都不正确。

@On(storage.get(TEST_ID)).returns(Some(TEST_DATA)) // 不正确,这个桩永远不会被触发
@On(storage.get(_)).returns(None)                  // 在上面的桩始终会被隐藏

您还可以使用预期来检查调用的参数。

let renderer = spy(Renderer())

@On(renderer.render(_)).fails()
let isVisible = { c: Component => c.isVisible }
@On(renderer.render(argThat(isVisible))).callsOriginal() // 只允许可见的组件

共享 mock 对象和桩

测试需要大量使用 mock 对象时可以多个测试用例共享 mock 对象和/或桩。 可以在任何位置创建 mock 或 spy 对象。然而,如果误将 mock 对象从一个测试用例泄漏到另一个测试用例,可能导致顺序依赖问题或测试不稳定。因此,不建议这样操作,mock 框架也会检测这类情况。 在同一测试类下的测试用例之间共享 mock 或 spy 对象时,可以将它们放在该类的实例变量中。

桩声明中隐含了预期,因此更难处理共享桩。测试用例之间不能共享预期。 只有2个位置可以声明桩:

  • 测试用例主体(无论是 @Test 函数还是@Test类中的@TestCase):检查预期。
  • @Test 类中的 beforeAll 中:在测试用例之间共享桩。这样的桩不能声明预期,预期也不会被检查。不允许使用基数说明符。只允许 returns(value)throws(exception)fails()callsOriginal()无状态操作。可以将这些桩视为具有隐式 anyTimes() 基数。

如果测试用例的预期相同,则可以在测试用例主体中提取和调用函数(测试类中非测试用例的成员函数)。

使用 beforeAll

@Test
class TestFoo {
    let foo = mock<Foo>()

    //单元测试框架会在执行测试用例之前调用以下内容
    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"
    }
}

使用函数:

@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"

        //预期失败,桩已声明但从未使用
    }
}

要在测试类构造函数中声明桩。无法保证何时调用测试类构造函数。

捕获参数

mock 框架使用 captor(ValueListener) 参数匹配器捕获参数来检查传递到桩成员的实际参数。只要触发了桩,ValueListener 就会拦截相应的参数,并检查参数和/或添加验证参数。

每次调用时,还可以使用 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() 函数来检查参数。模式如下:

//创建捕获器
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 检查的参数。

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 宏中不允许使用此类参数匹配器。

自定义和使用参数匹配器

为了避免重复使用相同的参数匹配器,可以自定义参数匹配器。

如下示例为在测试用例之间共享匹配器:

@On(foo.bar(oddNumbers())).returns("Odd")
@On(foo.bar(evenNumbers())).returns("Even")
foo.bar(0) // "Even"
foo.bar(1) // "Odd"

由于每个匹配器都只是 Matchers 类的静态函数,因此可以使用扩展来自定义参数匹配器。新参数匹配器需要调用现有的(实例)。

extend Matchers {
    static func evenNumbers(): TypedMatcher<Int> {
        argThat { arg: Int => arg % 2 == 0}
    }

    static func oddNumbers(): TypedMatcher<Int> {
        argThat { arg: Int => arg % 2 == 1}
    }
}

函数参数匹配器可以包含参数。

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 含有可变属性和字段的接口或抽象类的场景。

执行 getsFieldsetsField 桩操作将字段绑定到特定的调用,这些操作可以将预期配置为任何其他操作。

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 对象可以配置增加默认行为,此时,当匹配不到任何桩时,将执行默认行为。这通过启用桩模式来实现。 有两种可用的模式 ReturnsDefaultsSyntheticFields 。 这些模式通过枚举类型 StubMode 表示。可以通过在创建 mock 对象时将其传递给 mock 函数来为特定的 mock 对象启用桩模式。

public func mock<T>(modes: Array<StubMode>): T

桩模式可用于在配置 mock 对象时减少代码,并且它们可以与显式桩自由组合。显式的桩始终优先于其默认行为。 请注意,使用桩模式不会对 mock 对象的成员强加任何期望。 当用例是检查是否仅调用 mock 对象的某些特定成员,则应谨慎使用桩模式。被测对象的行为可能会以不期望的方式发生变化,但测试仍可能通过。

ReturnsDefaults 模式

在此模式下,当成员的返回类型在如下表格中时,无需显式配置桩,即可调用。

let foo = mock<Foo>(ReturnsDefaults)
@Assert(foo.isPretty(), false)

此类成员返回的默认值也如如下表格所示。

类型默认值
Boolfalse
numbers0
Stringempty string
OptionNone
ArrayList, HashSet, Arraynew empty collection
HashMapnew empty map

ReturnsDefaults 模式仅对如下成员生效:

  • 返回值为支持类型(如上表)的成员函数。
  • 类型为支持类型(如上表)的属性读取器和字段。

SyntheticFields 模式

SyntheticFields 模式可简化对 SyntheticField 的配置动作,详见 设置属性和字段 章节。 SyntheticFields 将通过 mock 框架为所有属性和字段隐式创建对应类型的合成字段。但是,这些字段只能在被赋值后读取。仅对可变属性和字段生效。

let foo = mock<Foo>(SyntheticFields)
// can simply assign a value to a mutable property
foo.bar = "Hello"
@Assert(foo.bar, "Hello")

赋给属性和字段的值仅在相应的测试用例中可见。 当同时启用 SyntheticFieldsReturnsDefaults 时,赋的值优先于默认值。但是,只要字段或属性尚未被赋值,就可以使用默认值。

let foo = mock<Foo>(ReturnsDefaults, SyntheticFields)
@Assert(foo.bar, "")
foo.bar = "Hello"
@Assert(foo.bar, "Hello")