使用Mockery/Prophecy进行依赖隔离:实现单元测试中的模拟对象与桩(Stub)

使用 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 是否以正确的金额和支付信息调用了 PaymentGatewayprocessPayment() 方法。

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);
    }
}

在这个测试中,我们:

  • 创建了 UserRepositoryEmailService 的模拟对象。
  • 定义了 UserRepositorycreateUser() 方法应该接收 testuser[email protected] 作为参数,并返回一个User mock对象。
  • 定义了User mock 对象的getId方法返回123
  • 定义了 EmailServicesendWelcomeEmail() 方法应该被调用一次,并接收模拟的 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);
    }
}

在这个测试中,我们:

  • 创建了 UserRepositoryEmailService 的 prophecy (模拟对象)。
  • 定义了 UserRepositorycreateUser() 方法应该接收 testuser[email protected] 作为参数,并返回一个User mock对象。
  • 定义了User mock 对象的getId方法返回123
  • 预言了 EmailServicesendWelcomeEmail() 方法应该被调用,并接收模拟的 User 对象作为参数。
  • 创建了 UserController 对象,并将模拟对象注入到构造函数中。
  • 调用 registerUser() 方法,并断言返回的用户 ID 是否正确。

10. 最佳实践

  • 只模拟你拥有的代码: 避免模拟第三方库或框架的代码。
  • 保持模拟对象简单: 只模拟测试所需的最小行为。
  • 清晰的命名: 使用清晰的命名来描述模拟对象的行为。
  • 避免过度模拟: 不要模拟所有依赖项,只模拟那些难以控制或测试的依赖项。
  • 使用依赖注入: 使用依赖注入来方便地替换真实依赖项为模拟对象。
  • 测试驱动开发 (TDD): 在编写代码之前先编写测试,可以更好地指导设计,并确保代码的可测试性。

最后, 掌握 Mockery 和 Prophecy 只是开始。重要的是理解依赖隔离的原则,并将其应用到你的单元测试中。通过编写良好的单元测试,你可以提高代码质量、减少 bug,并提高软件的可维护性。

使用模拟对象和桩进行依赖隔离是单元测试的关键。 我们可以使用Mockery或者Prophecy来实现。选择Mockery还是Prophecy取决于个人喜好。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注