使用 Mockery/Prophecy 进行依赖隔离:实现单元测试中的模拟对象与桩
大家好,今天我们来探讨单元测试中依赖隔离的关键技术:模拟对象(Mock)和桩(Stub),并深入研究如何使用 Mockery 和 Prophecy 这两个强大的 PHP 模拟框架来实现它们。依赖隔离是编写健壮、可维护的单元测试的核心,它可以让我们专注于测试代码的特定单元,而不受外部依赖的影响。
1. 为什么需要依赖隔离?
在复杂的软件系统中,一个类通常依赖于其他类或服务。直接测试这些依赖会带来以下问题:
- 复杂性: 测试会变得复杂,需要搭建整个依赖链。
- 脆弱性: 外部依赖的变化会导致测试失败,即使被测试的单元本身没有问题。
- 速度慢: 访问数据库、网络服务等外部资源会显著降低测试速度。
- 不可控: 无法控制外部依赖的行为,难以模拟各种边界情况和异常。
依赖隔离通过使用模拟对象和桩来解决这些问题。
2. 模拟对象(Mock)与桩(Stub)
简单来说:
- 桩 (Stub): 提供预定义的返回值,用于模拟依赖项的简单行为。它主要用于提供测试所需的输入数据。关注的是“结果”。
- 模拟对象 (Mock): 用于验证依赖项是否被以期望的方式调用。它允许我们验证交互,例如方法被调用的次数,以及调用时使用的参数。关注的是“行为”。
| 特性 | 桩 (Stub) | 模拟对象 (Mock) |
|---|---|---|
| 主要目的 | 提供预定义的返回值模拟依赖项 | 验证依赖项的调用行为 |
| 关注点 | 返回值/输出 | 方法调用、参数、调用次数等 |
| 交互验证 | 无 | 有 |
| 复杂性 | 相对简单 | 更复杂,需要定义期望的行为 |
| 使用场景 | 只需要提供固定值的依赖项 | 需要验证依赖项是否被正确调用的情况 |
举例说明:
假设有一个 OrderService 类,它依赖于 PaymentGateway 类来处理支付。
- 桩 (Stub): 我们可以创建一个
PaymentGateway的桩,它始终返回true(支付成功) 或false(支付失败),以便测试OrderService在不同支付结果下的行为。 - 模拟对象 (Mock): 我们可以创建一个
PaymentGateway的模拟对象,并验证OrderService是否以正确的金额和支付信息调用了PaymentGateway的processPayment()方法。
3. Mockery 简介
Mockery 是一个简单但功能强大的 PHP 模拟对象框架,易于学习和使用。
安装 Mockery:
composer require mockery/mockery --dev
基本用法:
<?php
use Mockery;
use PHPUnitFrameworkTestCase;
class ExampleTest extends TestCase
{
public function tearDown(): void
{
Mockery::close();
}
public function testExample()
{
// 创建一个模拟对象
$mock = Mockery::mock('MyClass');
// 定义模拟对象的行为
$mock->shouldReceive('myMethod')
->with('someArgument')
->andReturn('someValue');
// 使用模拟对象进行测试
$result = $mock->myMethod('someArgument');
// 断言结果
$this->assertEquals('someValue', $result);
}
}
Mockery::mock('MyClass'):创建一个名为MyClass的类的模拟对象。可以传入接口名,类名或者直接创建一个stdClass的mock对象。shouldReceive('myMethod'):指定模拟对象应该接收myMethod方法的调用。with('someArgument'):指定myMethod方法应该接收someArgument作为参数。andReturn('someValue'):指定myMethod方法应该返回someValue。Mockery::close():在每个测试结束后,需要调用Mockery::close()来验证所有期望的交互都已发生,并清理模拟对象。
4. Mockery 的高级特性
-
方法调用次数验证:
$mock->shouldReceive('myMethod')->times(3); // 验证 myMethod 被调用 3 次 $mock->shouldReceive('myMethod')->once(); // 验证 myMethod 被调用 1 次 $mock->shouldReceive('myMethod')->never(); // 验证 myMethod 从未被调用 -
参数匹配器:
use Mockery; $mock->shouldReceive('myMethod')->with(Mockery::any()); // 匹配任何参数 $mock->shouldReceive('myMethod')->with(Mockery::type('int')); // 匹配整数类型的参数 $mock->shouldReceive('myMethod')->with(Mockery::on(function($arg) { return $arg > 10; // 自定义匹配器,参数大于 10 })); -
返回值策略:
$mock->shouldReceive('myMethod')->andReturn('value1', 'value2', 'value3'); // 每次调用返回不同的值 $mock->shouldReceive('myMethod')->andThrow(new Exception('Something went wrong')); // 抛出异常 $mock->shouldReceive('myMethod')->andReturnUsing(function($arg) { return 'Processed: ' . $arg; // 使用回调函数生成返回值 }); -
部分模拟 (Partial Mocking):
只模拟类中的部分方法,而保留其他方法的真实行为。
// 模拟现有对象 $realObject = new MyClass(); $mock = Mockery::mock($realObject); // 只模拟 myMethod 方法 $mock->shouldReceive('myMethod')->andReturn('mocked value'); // 其他方法仍然使用真实对象的行为 $result = $mock->anotherMethod(); // 调用真实对象的 anotherMethod 方法 -
静态方法模拟:
Mockery::mock('alias:MyStaticClass')->shouldReceive('myStaticMethod')->andReturn('static value'); $result = MyStaticClass::myStaticMethod(); // 调用静态方法 $this->assertEquals('static value', $result);注意: 模拟静态方法需要使用
alias:前缀。 -
模拟 final 类/方法:
Mockery 默认无法模拟
final类或方法。需要安装mockery/mockery-integration包,并在phpunit.xml中配置。
5. Prophecy 简介
Prophecy 是另一个流行的 PHP 模拟框架,它提供了一种更具声明性的方式来定义模拟对象的行为。 Prophecy的设计哲学是:先“预言”对象的行为,然后在代码中验证预言是否实现。
安装 Prophecy:
composer require phpspec/prophecy --dev
基本用法:
<?php
use PHPUnitFrameworkTestCase;
use ProphecyPhpUnitProphecyTrait;
class ExampleTest extends TestCase
{
use ProphecyTrait;
public function testExample()
{
// 创建一个 prophecy (模拟对象)
$prophecy = $this->prophesize(MyClass::class);
// 定义 prophecy 的行为 (预言)
$prophecy->myMethod('someArgument')->willReturn('someValue');
// 获取 prophecy 的对象
$mock = $prophecy->reveal();
// 使用模拟对象进行测试
$result = $mock->myMethod('someArgument');
// 断言结果
$this->assertEquals('someValue', $result);
// 不需要像 Mockery 那样手动调用 close(), ProphecyTrait 会自动处理
}
}
$this->prophesize(MyClass::class):创建一个MyClass类的 prophecy (模拟对象)。$prophecy->myMethod('someArgument')->willReturn('someValue'):定义myMethod方法接收someArgument作为参数时,返回someValue。$prophecy->reveal():将 prophecy 转化为一个可用的模拟对象。use ProphecyTrait: 在TestCase中使用ProphecyTrait,ProphecyTrait会自动处理prophecy对象的生命周期。
6. Prophecy 的高级特性
-
方法调用次数验证:
Prophecy 本身不直接提供
times()、once()、never()等方法来验证调用次数。但是,可以通过其他方式实现:-
使用
shouldHaveBeenCalled()和shouldNotHaveBeenCalled():$prophecy->myMethod('someArgument')->shouldHaveBeenCalled(); // 验证 myMethod 被调用至少一次 $prophecy->myMethod('someArgument')->shouldNotHaveBeenCalled(); // 验证 myMethod 从未被调用 -
自定义验证:
可以使用一个计数器来跟踪方法被调用的次数,并在断言中验证计数器的值。
-
-
参数匹配器:
Prophecy 提供了一些内置的参数匹配器,也可以使用自定义的匹配器。
use ProphecyArgument; $prophecy->myMethod(Argument::any())->willReturn('any value'); // 匹配任何参数 $prophecy->myMethod(Argument::type('int'))->willReturn('int value'); // 匹配整数类型的参数 $prophecy->myMethod(Argument::that(function($arg) { return $arg > 10; // 自定义匹配器,参数大于 10 }))->willReturn('custom value'); -
返回值策略:
$prophecy->myMethod()->willReturn('value1', 'value2', 'value3'); // 每次调用返回不同的值 $prophecy->myMethod()->willThrow(new Exception('Something went wrong')); // 抛出异常 $prophecy->myMethod(Argument::any())->will(function($args) { return 'Processed: ' . $args[0]; // 使用回调函数生成返回值 }); -
部分模拟 (Partial Mocking):
Prophecy 也支持部分模拟,但需要使用不同的方法。
// 模拟现有对象 $realObject = new MyClass(); $prophecy = $this->prophesize(MyClass::class); // 将真实对象的方法 "嫁接" 到 prophecy 对象 $prophecy->reveal()->injectObject($realObject); // 只模拟 myMethod 方法 $prophecy->myMethod()->willReturn('mocked value'); // 其他方法仍然使用真实对象的行为 $result = $prophecy->reveal()->anotherMethod(); // 调用真实对象的 anotherMethod 方法注意: 使用
injectObject()方法将真实对象的方法 "嫁接" 到 prophecy 对象。 -
模拟 final 类/方法:
Prophecy 默认无法模拟
final类或方法。需要安装mikey179/pimple-interop-prophecy包,并在phpunit.xml中配置。 还需要安装laminas/laminas-code包
7. Mockery vs. Prophecy:如何选择?
Mockery 和 Prophecy 都是优秀的 PHP 模拟框架,选择哪个取决于个人偏好和项目需求。
| 特性 | Mockery | Prophecy |
|---|---|---|
| 语法 | 命令式 | 声明式 |
| 学习曲线 | 相对简单 | 稍陡峭 |
| 调用次数验证 | 内置 times()、once()、never() 等方法 |
需要使用 shouldHaveBeenCalled() 或自定义验证 |
| 部分模拟 | 更直接,使用 mock($realObject) |
需要使用 injectObject() |
| 社区支持 | 广泛 | 良好 |
-
Mockery 适合:
- 喜欢命令式编程风格的开发者。
- 需要简单易用的模拟框架。
- 需要直接验证方法调用次数的场景。
-
Prophecy 适合:
- 喜欢声明式编程风格的开发者。
- 希望先定义期望的行为,然后再验证是否实现的场景。
- 更关注对象之间的交互,而不是具体的实现细节。
8. 实战案例:使用 Mockery 进行单元测试
假设我们有一个 UserController 类,它依赖于 UserRepository 类来获取用户信息,并依赖于 EmailService 类来发送欢迎邮件。
<?php
class UserController
{
private $userRepository;
private $emailService;
public function __construct(UserRepository $userRepository, EmailService $emailService)
{
$this->userRepository = $userRepository;
$this->emailService = $emailService;
}
public function registerUser(string $username, string $email)
{
// 1. 创建用户
$user = $this->userRepository->createUser($username, $email);
// 2. 发送欢迎邮件
$this->emailService->sendWelcomeEmail($user);
// 3. 返回用户ID
return $user->getId();
}
}
class UserRepository
{
public function createUser(string $username, string $email)
{
// 实际的创建用户逻辑,例如保存到数据库
$user = new User($username, $email);
//假设这里做了数据库操作,设置了id
$user->setId(123);
return $user;
}
}
class EmailService
{
public function sendWelcomeEmail(User $user)
{
// 实际的发送邮件逻辑
echo "Sending welcome email to {$user->getEmail()}...n";
}
}
class User {
private $id;
private $username;
private $email;
public function __construct(string $username, string $email) {
$this->username = $username;
$this->email = $email;
}
public function getId() {
return $this->id;
}
public function getUsername() {
return $this->username;
}
public function getEmail() {
return $this->email;
}
public function setId($id){
$this->id = $id;
}
}
使用 Mockery 进行单元测试:
<?php
use Mockery;
use PHPUnitFrameworkTestCase;
class UserControllerTest extends TestCase
{
public function tearDown(): void
{
Mockery::close();
}
public function testRegisterUser()
{
// 1. 创建模拟对象
$userRepositoryMock = Mockery::mock(UserRepository::class);
$emailServiceMock = Mockery::mock(EmailService::class);
$userMock = Mockery::mock(User::class);
// 2. 定义模拟对象的行为
$userRepositoryMock->shouldReceive('createUser')
->with('testuser', '[email protected]')
->andReturn($userMock);
$userMock->shouldReceive('getId')
->andReturn(123);
$emailServiceMock->shouldReceive('sendWelcomeEmail')
->with($userMock)
->once();
// 3. 创建被测试对象
$userController = new UserController($userRepositoryMock, $emailServiceMock);
// 4. 执行被测试方法
$userId = $userController->registerUser('testuser', '[email protected]');
// 5. 断言结果
$this->assertEquals(123, $userId);
}
}
在这个测试中,我们:
- 创建了
UserRepository和EmailService的模拟对象。 - 定义了
UserRepository的createUser()方法应该接收testuser和[email protected]作为参数,并返回一个Usermock对象。 - 定义了
Usermock 对象的getId方法返回123 - 定义了
EmailService的sendWelcomeEmail()方法应该被调用一次,并接收模拟的User对象作为参数。 - 创建了
UserController对象,并将模拟对象注入到构造函数中。 - 调用
registerUser()方法,并断言返回的用户 ID 是否正确。
9. 实战案例:使用 Prophecy 进行单元测试
<?php
use PHPUnitFrameworkTestCase;
use ProphecyPhpUnitProphecyTrait;
use ProphecyArgument;
class UserControllerTestProphecy extends TestCase
{
use ProphecyTrait;
public function testRegisterUser()
{
// 1. 创建 prophecy (模拟对象)
$userRepositoryProphecy = $this->prophesize(UserRepository::class);
$emailServiceProphecy = $this->prophesize(EmailService::class);
$userProphecy = $this->prophesize(User::class);
// 2. 定义 prophecy 的行为 (预言)
$userRepositoryProphecy->createUser('testuser', '[email protected]')->willReturn($userProphecy->reveal());
$userProphecy->getId()->willReturn(123);
$emailServiceProphecy->sendWelcomeEmail($userProphecy->reveal())->shouldBeCalled();
// 3. 获取 prophecy 的对象
$userRepositoryMock = $userRepositoryProphecy->reveal();
$emailServiceMock = $emailServiceProphecy->reveal();
$userMock = $userProphecy->reveal();
// 4. 创建被测试对象
$userController = new UserController($userRepositoryMock, $emailServiceMock);
// 5. 执行被测试方法
$userId = $userController->registerUser('testuser', '[email protected]');
// 6. 断言结果
$this->assertEquals(123, $userId);
}
}
在这个测试中,我们:
- 创建了
UserRepository和EmailService的 prophecy (模拟对象)。 - 定义了
UserRepository的createUser()方法应该接收testuser和[email protected]作为参数,并返回一个Usermock对象。 - 定义了
Usermock 对象的getId方法返回123 - 预言了
EmailService的sendWelcomeEmail()方法应该被调用,并接收模拟的User对象作为参数。 - 创建了
UserController对象,并将模拟对象注入到构造函数中。 - 调用
registerUser()方法,并断言返回的用户 ID 是否正确。
10. 最佳实践
- 只模拟你拥有的代码: 避免模拟第三方库或框架的代码。
- 保持模拟对象简单: 只模拟测试所需的最小行为。
- 清晰的命名: 使用清晰的命名来描述模拟对象的行为。
- 避免过度模拟: 不要模拟所有依赖项,只模拟那些难以控制或测试的依赖项。
- 使用依赖注入: 使用依赖注入来方便地替换真实依赖项为模拟对象。
- 测试驱动开发 (TDD): 在编写代码之前先编写测试,可以更好地指导设计,并确保代码的可测试性。
最后, 掌握 Mockery 和 Prophecy 只是开始。重要的是理解依赖隔离的原则,并将其应用到你的单元测试中。通过编写良好的单元测试,你可以提高代码质量、减少 bug,并提高软件的可维护性。
使用模拟对象和桩进行依赖隔离是单元测试的关键。 我们可以使用Mockery或者Prophecy来实现。选择Mockery还是Prophecy取决于个人喜好。