Lzh on GitHub

创建测试替身

Mockery 的主要目标是帮助我们创建测试替身。它可以创建存根(stubs)、模拟(mocks)和间谍(spies)。

Mockery 的主要目标是帮助我们创建测试替身。它可以创建存根(stubs)、模拟(mocks)和间谍(spies)。

存根模拟 的创建方式相同。两者的区别在于,存根 在被调用时仅返回预设结果,而 模拟 需要对其预期接收的方法调用设置期望。

间谍 是一种测试替身,它会记录所接收的调用,并允许我们在事后检查这些调用。

创建测试替身对象时,我们可以传入一个标识符作为测试替身的名称。如果不传入标识符,则测试替身名称将为 unknown。此外,标识符不必是类名。一个好的实践,也是我们的建议,是始终使用我们要创建测试替身的底层类的名称来命名测试替身。

如果我们用于测试替身的标识符是一个已存在类的名称,则测试替身将继承该类的类型(通过继承),即模拟对象将通过类型提示或 instanceof 检查。对于必须是特定类型的测试替身以满足代码的期望,这是非常有用的。

存根和模拟

存根模拟 通过调用 \Mockery::mock() 方法创建。下面的示例展示了如何创建一个名为 “foo” 的存根或模拟对象:

$mock = \Mockery::mock('foo');

这样创建的模拟对象是可能的最松散形式,并且是 \Mockery\MockInterface 的实例。

使用 Mockery 创建的所有测试替身都是 \Mockery\MockInterface 的实例,无论是 存根模拟 还是 间谍

要创建一个没有名称的 存根模拟 对象,可以调用无参数的 mock() 方法:

$mock = \Mockery::mock();

正如前面所说,我们不推荐创建没有名称的存根或模拟对象。

类、抽象类、接口

创建存根或模拟对象的推荐方式是使用我们希望创建测试替身的现有类的名称:

$mock = \Mockery::mock('MyClass');

这个存根或模拟对象将通过继承具有 MyClass 类型。

存根模拟 对象可以基于任何具体类、抽象类甚至接口。主要目的是确保模拟对象继承特定类型以便进行类型提示。

$mock = \Mockery::mock('MyInterface');

存根模拟 对象将实现 MyInterface 接口。

标记为 final 的类,或包含标记为 final 方法的类,不能完全模拟。Mockery 支持为这些情况创建部分模拟(partial mocks)。部分模拟将在文档后面解释。

Mockery 还支持基于单个已存在类创建存根或模拟对象,该类必须实现一个或多个接口。我们可以通过向 \Mockery::mock() 方法提供以逗号分隔的类和接口列表作为第一个参数来实现:

$mock = \Mockery::mock('MyClass, MyInterface, OtherInterface');

这个 存根模拟 对象现在将是 MyClass 类型并实现 MyInterfaceOtherInterface 接口。

类名不必是列表中的第一个成员,但为了可读性,使用此惯例是友好的。

我们也可以通过将接口列表作为第二个参数传递给 mock 来告诉模拟对象实现所需接口:

$mock = \Mockery::mock('MyClass', 'MyInterface, OtherInterface');

在所有方面,这与前面的示例相同。

间谍

Mockery 支持的第三种测试替身是 间谍。间谍与模拟对象的主要区别在于,我们在调用发生后验证对测试替身的调用。我们在不必关心对象将收到的所有调用时,会使用间谍。

间谍会对所有接收的方法调用返回 null。无法指定方法调用的返回值。如果这样做,那就相当于使用模拟对象,而不是间谍。

我们通过调用 \Mockery::spy() 方法创建间谍:

$spy = \Mockery::spy('MyClass');

与存根或模拟一样,我们可以告诉 Mockery 将间谍基于任何具体类或抽象类,或者实现任意数量的接口:

$spy = \Mockery::spy('MyClass, MyInterface, OtherInterface');

这个间谍现在将是 MyClass 类型并实现 MyInterfaceOtherInterface 接口。

\Mockery::spy() 方法实际上是 \Mockery::mock()->shouldIgnoreMissing() 的简写。shouldIgnoreMissing 方法是一个 “行为修改器”。我们将在后面讨论它们。

模拟与间谍

下面示例说明 模拟对象间谍对象 的区别:

$mock = \Mockery::mock('MyClass');
$spy = \Mockery::spy('MyClass');

$mock->shouldReceive('foo')->andReturn(42);

$mockResult = $mock->foo();
$spyResult = $spy->foo();

$spy->shouldHaveReceived()->foo();

var_dump($mockResult); // int(42)
var_dump($spyResult); // null

如示例所示,模拟对象是在调用前设置期望,并返回预期结果。而间谍对象是在调用发生后验证调用,方法调用的返回结果始终为 null

我们也有专门章节介绍 间谍

部分测试替身

部分测试替身在我们希望对类的某些方法进行 存根、设置期望或 间谍,同时运行其他方法的实际代码时很有用。

我们将部分测试替身分为三类:

  • 运行时部分测试替身(runtime partial test doubles)
  • 生成部分测试替身(generated partial test doubles)
  • 代理部分测试替身(proxied partial test doubles)

运行时部分测试替身

所谓 运行时部分替身,涉及创建一个测试替身,然后告诉它变为部分替身。任何未被允许或未设置期望的方法调用,将像普通实例一样执行。

class Foo {
    function foo() { return 123; }
    function bar() { return $this->foo(); }
}

$foo = mock(Foo::class)->makePartial();
$foo->foo(); // int(123);

然后我们可以像对待其他 Mockery 替身一样告诉测试替身允许或期望的调用:

$foo->shouldReceive('foo')->andReturn(456);
$foo->bar(); // int(456)

关于运行时部分测试替身的示例,请参阅 大父类 的 cookbook 条目。

生成部分测试替身

第二种部分替身是 生成部分替身。生成部分替身时,我们明确告诉 Mockery 哪些方法可以允许或设置期望调用。所有其他方法将直接运行实际代码,因此对这些方法的存根和期望无效。

class Foo {
    function foo() { return 123; }
    function bar() { return $this->foo(); }
}

$foo = mock("Foo[foo]");

$foo->foo(); // 错误,没有设置期望

$foo->shouldReceive('foo')->andReturn(456);
$foo->foo(); // int(456)

// 为此方法设置期望无效
$foo->shouldReceive('bar')->andReturn(999);
$foo->bar(); // int(456)

也可以显式指定使用 !method 语法直接运行哪些方法:

class Foo {
    function foo() { return 123; }
    function bar() { return $this->foo(); }
}

$foo = mock("Foo[!foo]");

$foo->foo(); // int(123)
$foo->bar(); // 错误,没有设置期望
即便支持生成部分测试替身,我们也不推荐使用它们。原因之一是生成部分会调用被模拟类的原始构造函数,这可能在测试应用代码时产生副作用。 更多详情请参阅 不调用原始构造函数

代理部分测试替身

代理部分模拟是最后的手段。我们可能遇到无法模拟的类,因为它被标记为 final,或类中的方法被标记为 final。在这种情况下,我们不能简单继承类并重写方法来模拟——需要更有创意的方法。

$mock = \Mockery::mock(new MyClass);

新的 mock 是一个代理(Proxy)。它拦截调用,并将未设置期望的方法调用重新路由到代理对象(我们构造并传入的对象)。间接地,这允许我们模拟标记为 final 的方法。代价是代理部分无法通过类型提示检查,因为它不能继承被模拟的类。

别名

在未加载的有效类名前加前缀 “alias:”,将生成一个 “别名 mock”。别名 mock 会创建一个类别名,将给定类名映射到 stdClass,通常用于模拟公共静态方法。对新 mock 设置的期望,将用于该类的所有 静态调用

$mock = \Mockery::mock('alias:MyClass');
即便支持别名类,我们也不推荐使用它。

重载

在未加载的有效类名前加前缀 “overload:”,将生成别名 mock(如 “alias:”),但新创建的该类实例会继承原 mock 的期望。原 mock 不会被验证,因为它用于存储新实例的期望。为此,我们称之为 “实例 mock”,以区别于简单的 “别名 mock”。

换句话说,实例 mock 会在创建被模拟类的新实例时 “拦截”,并使用 mock 替代。对于模拟硬依赖特别有用,稍后会讨论。

$mock = \Mockery::mock('overload:MyClass');
跨多个测试使用 alias/instance mock 会产生致命错误,因为不能有两个同名类。为避免此问题,应在单独的 PHP 进程中运行此类测试(PHPUnit 和 PHPT 都原生支持)。

命名模拟

namedMock() 方法将生成一个由第一个参数命名的类。例如 MyClassName。其他参数处理方式与 mock 方法相同:

$mock = \Mockery::namedMock('MyClassName', 'DateTime');

该示例将创建一个名为 MyClassName 的类,继承 DateTime

命名模拟属于边缘用例,但在代码依赖 __CLASS__ 魔术常量,或需要两个不同派生抽象类时非常有用。

参见 cookbook 中 类常量 条目,了解命名模拟的示例用法。

命名模拟只能创建一次,任何后续使用不同参数的 namedMock 调用可能会引发异常。

构造函数参数

有时被模拟类有必需的构造函数参数。我们可以将这些参数作为索引数组传递给 Mockery 作为第二个参数:

$mock = \Mockery::mock('MyClass', [$constructorArg1, $constructorArg2]);

如果还需要让 MyClass 实现接口,则作为第三个参数:

$mock = \Mockery::mock('MyClass', 'MyInterface', [$constructorArg1, $constructorArg2]);

Mockery 会将 $constructorArg1$constructorArg2 传入构造函数。

行为修改器

创建模拟对象时,我们可能希望使用一些常用行为,这些行为不是 Mockery 的默认行为。

使用 shouldIgnoreMissing() 行为修改器,会将模拟对象标记为被动模拟(Passive Mock):

\Mockery::mock('MyClass')->shouldIgnoreMissing();

在这种模拟对象中,未被期望覆盖的方法调用将返回 null,而不是报错提示没有匹配调用的期望。

在 PHP >= 7.0.0 上,缺失期望且有返回类型的方法,将返回对象的模拟(如果返回类型是类)或 “假值” 原始类型,如空字符串、空数组、整数或浮点数的 0、布尔值 false、空闭包等。

在 PHP >= 7.1.0 上,缺失期望且返回类型可空的方法,将返回 null

我们可以选择返回 \Mockery\Undefined 类型对象(即 null 对象,0.7.2 行为):

\Mockery::mock('MyClass')->shouldIgnoreMissing()->asUndefined();

返回的对象只是占位符,如果意外使用,可能不会通过逻辑检查。

我们之前遇到过 makePartial() 方法,用于创建运行时部分测试替身:

\Mockery::mock('MyClass')->makePartial();

这种形式的模拟对象会将未受期望约束的方法委托给 mock 的父类,即 MyClass。而前述 shouldIgnoreMissing() 返回 null,这种行为则调用父类的匹配方法。

Mockery 中 stubs、mocks、spies 的典型使用场景详解

Stub(桩对象)使用场景

核心目标:提供预设返回值,不关心调用行为。

👉 适用于:

  • 被测代码依赖外部服务/模块,但我们只关心结果,不关心调用过程。
  • 测试需要确定性数据,避免依赖真实 API、数据库、文件系统。
  • 输入 → 输出 类型测试中最常用。

✅ 示例场景:

  • 模拟一个 配置读取器:
$stub = \Mockery::mock('Config');
$stub->shouldReceive('get')->andReturn('mock_value');

$service = new Service($stub);
$result = $service->doSomething();

$this->assertEquals('expected', $result);

不关心 get() 被调用几次,只要返回 mock_value 即可。

Mock(模拟对象)使用场景

核心目标:验证行为是否符合预期。

👉 适用于:

  • 需要验证 “是否调用了某方法、调用次数、参数是否正确”。
  • 测试强调 交互行为 而不仅仅是结果。
  • 常见于 命令型方法(执行动作,而不是返回数据)。

✅ 示例场景:

  • 模拟一个 仓储类,要求业务逻辑必须调用 save() 保存数据:
$mock = \Mockery::mock('Repository');
$mock->shouldReceive('save')->once()->with('order')->andReturn(true);

$service = new OrderService($mock);
$service->createOrder('order'); // 如果没调用 save('order') 就会测试失败

这里强调 “必须调用 save('order') 一次”,否则测试失败。

Spy(间谍对象)使用场景

核心目标:不预设期望,事后检查调用情况。

👉 适用于:

  • 不想写死期望,只想确认某个调用确实发生过。
  • 需要根据测试运行的结果,检查调用记录。
  • 调用参数多变、复杂,事前难以完全设定期望。
  • 常见于 事件监听、回调、日志调用 等场景。

✅ 示例场景:

  • 模拟一个 日志类,验证日志确实被调用:
$spy = \Mockery::spy('Logger');

$service = new UserService($spy);
$service->register('new_user');

$spy->shouldHaveReceived('info')->with('User new_user registered');

不需要在 shouldReceive() 中写死期望,测试运行完之后再验证。

总结:如何选择?

类型关注点使用时机
Stub结果(返回值)需要隔离外部依赖,只要假数据即可,不关心调用行为
Mock行为(调用过程 + 参数)需要验证交互逻辑(调用了几次、参数对不对),适合验证“契约”
Spy事后验证调用事前不好设定期望,运行后检查调用情况,适合日志、事件、回调