mock 框架入门

mock 框架是仓颉单元测试框架,提供 API 用于创建和配置mock 对象 (也可称为“骨架对象”,即 API 函数体为空的对象),这些 mock 对象与真实对象拥有签名一致的 API 。mock 测试技术支持隔离测试代码,测试用例使用 mock 对象编码,实现外部依赖消除。

本文概述了如何使用 mock 框架测试及测试中所需的核心 API 。

概述

mock 框架具有以下特性:

  • 创建 mock 对象和 spy 对象:测试时无需修改生产代码。
  • API 配置简单:可配置 mock/spy 对象的行为。
  • 单元测试框架部分:无缝集成单元测试框架的其他特性,错误输出可读。
  • 自动验证配置行为:大多数情况下不需要多余的验证代码。
  • 提供验证 API:用于测试系统内部的复杂交互。

用户使用场景包括:

  • 简化测试设置和代码。
  • 测试异常场景。
  • 用轻量级 mock 对象替换代价高的依赖,提高测试性能。
  • 验证测试复杂场景,如调用的顺序/数量。

使用 mock 框架

mock 框架本身是仓颉标准库中单元测试的一部分。使用 mock 框架前,需将 unittest.mock.*unittest.mock.mockmacro.* 导入到测试文件中。

如果使用 cjpm 工具,仅需运行 cjpm test 命令即可自动启用 mock 框架。

如果直接使用 cjc ,参见[使用 cjc](#使用 cjc)。

示例

常见 mock 测试用例:

  • 调用[mock 构造函数](#创建 mock 对象)创建 mock/spy 对象。
  • 调用[配置 API](#配置 api)设置 mock 行为。
  • 使用 mock 对象替代测试代码依赖。
  • (可选)调用验证API来验证测试代码与 mock/spy 对象之间的交互。

以如下简单API为例:

public interface Repository {
    func requestData(id: UInt64, timeoutMs: Int): String
}

public class Controller {
    public Controller(
        private let repo: Repository
    ) {}

    public func findData(id: UInt64): ?String {
        try {
            return repo.requestData(id, 100)
        }
        catch (e: TimeoutException) {
            return None
        }
    }
}

public class TimeoutException <: Exception {}

如果 Repository 实现不理想,比如可能包含复杂的依赖关系,实现在其他包中,或者测试太慢,mock 框架可以在不创建依赖的情况下测试 Controller

测试 findData 方法。

//导入mock框架包
from std import unittest.mock.*
from std import unittest.mock.mockmacro.*

@Test
class ControllerTest {
    let testId: UInt64 = 100
    let testResponse = "foo"

    @TestCase
    func testFindSuccessfully() {
        //只需要创建mock,不用创建真正的Repository
        let repository = mock<Repository>()

        //使用@On宏配置testData行为
        @On(repository.requestData(testId, _)).returns(testResponse)

        //创建真正的Controller测试以便测试实际的实现
        let controller = Controller(repository)

        //运行测试代码
        let result = controller.findData(testId)

        //对结果运行断言
        @Assert(result == Some(testResponse))
    }

    @TestCase
    func testTimeout() {
        let repository = mock<Repository>()

        //设置getData抛出异常
        @On(repository.requestData(testId, _)).throws(TimeoutException())

        let controller = Controller(repository)

        //底层实现抛出异常时,测试行为
        let result = controller.findData(testId)

        //对结果运行断言
        @Assert(result == None)
    }
}

创建 mock 对象

mock 构造函数可以通过调用 mock<T>spy<T> 函数来创建两种对象:mockspy,其中 T 表示被 mock 的类或接口。

public func mock<T>(): T
public func spy<T>(objectToSpyOn: T): T

mock 作为骨架对象,默认不对成员进行任何操作。 spy 作为一种特殊的 mock 对象用于封装某个类或接口的当前实例。默认情况下,spy 对象将其成员调用委托给底层对象。 其他方面,spy 和 mock 对象非常相似。

只有(包括 final 类和 sealed 类)和接口支持 mock 。

参阅[使用 mock 和 spy 对象](#使用 spy 和 mock 对象)。

配置 API

配置 API 是框架的核心,可以定义 mock/spy 对象成员的行为(或重新定义 spy 对象)。 配置 API 的入口是 @On 宏调用。

@On(storage.getComments(testId)).returns(testComments)

示例中,如果 mock 对象 storage 接收getComments 方法的调用,并且指定了参数 testId ,则返回 testComment

如上行为即为打桩,桩(Stub, 模拟还未实现或无法在测试环境中执行的组件)需在测试用例主体内部先定义。

只有类和接口的实例成员(包括 final 成员)才能打桩。以下实体不能打桩:

  • 静态成员
  • 扩展成员
  • 顶层函数,包括外部函数

一个完整的桩声明包含以下部分:

  1. @On 宏调用中描述的桩签名
  2. 用于描述桩行为的[操作](#操作 API)。
  3. (可选)用于设置预期的基数说明符( cardinality specifier, 指定预期执行次数的表达式)。
  4. (可选)续体( continuation, 支持链式调用的表达式)。

mock 框架拦截匹配桩签名的调用,并执行桩声明中指定的操作。只能拦截 spy 对象和 mock 对象的成员。

桩签名

桩签名定义了与特定调用子集匹配的一组条件,包括以下部分:

  • mock/spy 对象的引用,必须是单个标识符
  • 成员调用
  • 特定格式的参数调用,参见参数匹配器

签名可以匹配以下实体:

  • 方法
  • 属性 getter
  • 属性 setter
  • 字段读操作
  • 字段写操作

只要 mock/spy 对象调用相应的成员,并且所有参数(若有)都与相应的参数匹配器匹配时,桩签名就会匹配调用。

方法的桩的签名结构:<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

对运算符函数打桩时,运算符的接收者必须是对 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 来解决与类型推断有关的编译时问题。

重要规则:函数调用作为参数匹配器时,会视为对匹配器的调用。

@On(foo.bar(calculateArgument())) //不正确,calculateArgument()不是匹配器

let expectedArgument = calculateArgument()
@On(foo.bar(expectedArgument))    //正确,只要 'expectedArgument' 是可等价的和/或引用类型

操作 API

mock 框架提供 API 来指定桩操作。触发桩后,打桩成员会执行指定的操作。如果调用与相应的 @On 宏调用指定的签名匹配,则会触发桩。

每个桩函数必须指定一个操作。 @On 宏调用返回的 ActionSelector 子类型会定义可用操作。操作列表取决于所打桩的实体。

通用(操作)

适用于所有桩的操作。

  • throws(exception: Exception):抛出 exception
  • throws(exceptionFactory: () -> Exception):调用 exceptionFactory 去构造桩触发时抛出的异常。
  • fails():如果触发了桩,则测试失败。

throws 用于测试桩成员抛出异常时的系统行为。fails 用于测试桩成员是否未被调用。

@On(service.request()).throws(TimeoutException())

方法和属性/字段 Getter

R 表示对应成员的返回类型。

  • returns():不做任何操作并返回 (),仅当 RUnit 时可用。
  • returns(value: R):返回 value
  • returns(valueFactory: () -> R):调用 valueFactory 去构造桩触发时抛出的异常。
  • returnsConsecutively(values: Array<R>), returnsConsecutively(values: ArrayList<R>):触发桩时,返回 values 中的下一个元素。
@On(foo.bar()).returns(2) //返回 0
@On(foo.bar()).returnsConsecutively(1, 2, 3) //依次返回 1,2,3

属性/字段 Setter

  • doesNothing():忽略调用,不做任何操作。类似于返回 Unit 的函数的 returns()。 更多信息详见这里.

spy 操作

对于 spy 对象,可以使用其他操作来委托监控实例。

  • callsOriginal() :调用原始方法。
  • getsOriginal() :调用原始属性 getter 或获取原始实例中的字段值。
  • setsOriginal() :调用原始属性 setter 或设置原始实例中的字段值。

预期

定义桩时会隐式或显式地向桩附加预期。桩可以定义期望的基数。操作failsreturnsConcesecutively 除外)返回CardinalitySelector 的实例,该实例可以使用基数说明符自定义预期。

CardinalitySelector 定义了如下函数:

  • once()
  • atLeastOnce()
  • anyTimes()
  • times(expectedTimes: Int64)
  • times(min!: Int64, max!: Int64)
  • atLeastTimes(minTimesExpected: Int64)

anyTimes 说明符用于提升预期,即如果桩从未被触发,测试也不会失败。其他说明符都暗示了测试代码中特定桩的调用次数上下限。只要桩被触发的次数比预期的多,测试就会立即失败。下限在测试代码执行完毕后进行检查。

示例:

// example_test.cj
@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

如果没有自定义预期,mock框架使用默认预期:

操作默认期望基数允许自定义基数
fails不可调用
returnsatLeastOnce
returnsConsecutivelytimes(values.size)
throwsatLeastOnce
doesNothingatLeastOnce
(calls/gets/sets)OriginalatLeastOnce

桩链

returnsConsecutively 操作,oncetimes(n) 基数说明符返回一个续体实例。顾名思义,续体表示可以继续使用链,即指定某操作在前一个操作完全完成时立即执行。

续体本身只提供了一个返回新 ActionSelectorthen() 函数。链上的所有操作都适用相同的规则。如果调用了 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() //第一个成功响应之后,停止发送请求

使用 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'引用

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 。