Mock与Stub的区别与应用:使用Mockery在PHPUnit中隔离外部依赖

Mock与Stub的区别与应用:使用Mockery在PHPUnit中隔离外部依赖

大家好,今天我们来聊聊单元测试中一个非常重要的概念:Mocking 和 Stubbing。它们是帮助我们隔离外部依赖,编写可维护、可靠的单元测试的关键技术。我会深入探讨 Mock 和 Stub 的区别,并结合 PHPUnit 和 Mockery 框架,通过丰富的代码示例,讲解如何在实际项目中应用它们。

1. 什么是单元测试?为什么要隔离外部依赖?

在深入 Mock 和 Stub 之前,我们先简单回顾一下单元测试。单元测试旨在验证软件中最小的可测试单元(通常是一个函数或方法)的行为是否符合预期。理想情况下,每个单元测试都应该独立运行,不受其他单元或外部因素的影响。

为什么要隔离外部依赖呢?原因如下:

  • 提高测试速度: 访问数据库、文件系统或网络服务等外部资源通常很慢,会显著降低测试速度。
  • 增强测试可靠性: 外部依赖可能不稳定,例如网络连接中断或数据库服务器故障,导致测试失败,即使被测单元本身没有问题。
  • 简化测试设置: 模拟复杂的外部环境比设置真实的外部环境要容易得多。
  • 关注被测单元: 隔离外部依赖可以让我们专注于测试被测单元的逻辑,避免被外部因素干扰。
  • 测试边界情况和错误处理: 通过模拟外部依赖的行为,我们可以轻松地测试各种边界情况和错误处理逻辑,例如网络超时或数据库连接失败。

2. Mock 与 Stub:概念与区别

Mock 和 Stub 都是用来替代真实依赖项的测试替身,但它们有着不同的目的和行为。

  • Stub: 提供预设的返回值,用于模拟外部依赖的状态。它主要用于控制被测单元的输入,让被测单元在可预测的环境中运行。Stub 通常只关注返回值,而不关心被测单元如何使用它。
  • Mock: 验证被测单元与外部依赖的交互是否符合预期。它不仅提供预设的返回值,还记录被测单元与它的交互,例如调用次数、传递的参数等。Mock 主要用于验证被测单元的行为。

可以用一个形象的比喻来理解 Mock 和 Stub:

  • Stub: 就像一个餐厅的菜单。它告诉你有哪些菜品(返回值)可以选择,但你如何点菜(与依赖交互)并不重要。
  • Mock: 就像一个餐厅的服务员。他会记录你点了哪些菜(方法调用)、点了多少次(调用次数)、对菜品有什么特殊要求(参数),然后验证你的点菜行为是否符合预期。

下面用一个简单的例子来说明 Mock 和 Stub 的区别。假设我们有一个 OrderService 类,它依赖于 PaymentGateway 类来处理支付:

<?php

class OrderService
{
    private $paymentGateway;

    public function __construct(PaymentGateway $paymentGateway)
    {
        $this->paymentGateway = $paymentGateway;
    }

    public function processOrder(float $amount): bool
    {
        try {
            $this->paymentGateway->charge($amount);
            return true;
        } catch (PaymentException $e) {
            return false;
        }
    }
}

class PaymentGateway
{
    public function charge(float $amount): void
    {
        // Real payment processing logic here
        // This would involve external API calls, etc.
        throw new Exception("Real payment processing not implemented");
    }
}

class PaymentException extends Exception {}

如果我们想测试 OrderService::processOrder() 方法,我们可以使用 Stub 来模拟 PaymentGateway 的返回值,或者使用 Mock 来验证 OrderService 是否正确地调用了 PaymentGateway::charge() 方法。

Stub 的例子:

<?php

use PHPUnitFrameworkTestCase;

class OrderServiceStubTest extends TestCase
{
    public function testProcessOrder_PaymentSuccessful_ReturnsTrue()
    {
        // Create a stub for the PaymentGateway class.
        $paymentGatewayStub = $this->createStub(PaymentGateway::class);

        // Configure the stub.
        $paymentGatewayStub->method('charge')
                         ->willReturn(null); // Or void, as there's no return value

        // Create the OrderService with the stub.
        $orderService = new OrderService($paymentGatewayStub);

        // Call the method under test.
        $result = $orderService->processOrder(100.00);

        // Assert that the method returns true.
        $this->assertTrue($result);
    }

    public function testProcessOrder_PaymentFails_ReturnsFalse()
    {
        // Create a stub for the PaymentGateway class.
        $paymentGatewayStub = $this->createStub(PaymentGateway::class);

        // Configure the stub.
        $paymentGatewayStub->method('charge')
                         ->willThrowException(new PaymentException('Payment failed'));

        // Create the OrderService with the stub.
        $orderService = new OrderService($paymentGatewayStub);

        // Call the method under test.
        $result = $orderService->processOrder(100.00);

        // Assert that the method returns false.
        $this->assertFalse($result);
    }
}

Mock 的例子 (使用 Mockery):

<?php

use PHPUnitFrameworkTestCase;
use Mockery;

class OrderServiceMockTest extends TestCase
{
    public function tearDown(): void
    {
        Mockery::close();
    }

    public function testProcessOrder_PaymentSuccessful_CallsChargeMethod()
    {
        // Create a mock for the PaymentGateway class.
        $paymentGatewayMock = Mockery::mock(PaymentGateway::class);

        // Set expectations on the mock.
        $paymentGatewayMock->shouldReceive('charge')
                         ->once()
                         ->with(100.00); // Expect charge to be called once with amount 100.00

        // Create the OrderService with the mock.
        $orderService = new OrderService($paymentGatewayMock);

        // Call the method under test.
        $orderService->processOrder(100.00);

        // Mockery will automatically verify that the expectations are met.
    }

    public function testProcessOrder_PaymentFails_CallsChargeMethodAndReturnsFalse()
    {
        // Create a mock for the PaymentGateway class.
        $paymentGatewayMock = Mockery::mock(PaymentGateway::class);

        // Set expectations on the mock.
        $paymentGatewayMock->shouldReceive('charge')
                         ->once()
                         ->with(100.00)
                         ->andThrow(new PaymentException('Payment failed'));

        // Create the OrderService with the mock.
        $orderService = new OrderService($paymentGatewayMock);

        // Call the method under test.
        $result = $orderService->processOrder(100.00);

        // Assert that the method returns false.
        $this->assertFalse($result);
    }
}

总结 Mock 和 Stub 的区别:

Feature Stub Mock
Purpose Control the input to the SUT Verify the interactions of the SUT
Focus State Behavior
Verification No verification of interactions Verifies interactions with the SUT
Return Values Provides pre-defined return values Can provide return values and expectations

3. Mockery:一个强大的 PHP Mocking 框架

Mockery 是一个流行的 PHP Mocking 框架,它提供了简洁、灵活的 API 来创建和管理 Mock 对象。相比 PHPUnit 内置的 createStub()createMock() 方法,Mockery 提供了更强大的功能,例如:

  • 方法链式调用: 可以使用链式调用来设置 Mock 对象的行为,使代码更易读。
  • 参数匹配器: 可以使用灵活的参数匹配器来验证方法调用的参数。
  • 部分 Mocking: 可以只 Mock 类中的部分方法,而保留其他方法的真实行为。
  • 静态方法 Mocking: 可以 Mock 类的静态方法。

4. 使用 Mockery 隔离外部依赖:实战案例

接下来,我们通过一个更复杂的案例来演示如何使用 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): bool
    {
        // Validate username and email
        if (empty($username) || empty($email)) {
            return false;
        }

        // Check if user already exists
        $existingUser = $this->userRepository->findByUsername($username);
        if ($existingUser) {
            return false;
        }

        // Create new user
        $user = new User($username, $email);
        $this->userRepository->save($user);

        // Send welcome email
        $this->emailService->sendWelcomeEmail($user);

        return true;
    }
}

class UserRepository
{
    public function findByUsername(string $username): ?User
    {
        // Real database query logic here
        throw new Exception("Real database query not implemented");
    }

    public function save(User $user): void
    {
        // Real database save logic here
        throw new Exception("Real database save not implemented");
    }
}

class EmailService
{
    public function sendWelcomeEmail(User $user): void
    {
        // Real email sending logic here
        throw new Exception("Real email sending not implemented");
    }
}

class User
{
    private $username;
    private $email;

    public function __construct(string $username, string $email)
    {
        $this->username = $username;
        $this->email = $email;
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function getEmail(): string
    {
        return $this->email;
    }
}

现在,我们使用 Mockery 来测试 UserController::registerUser() 方法:

<?php

use PHPUnitFrameworkTestCase;
use Mockery;

class UserControllerTest extends TestCase
{
    public function tearDown(): void
    {
        Mockery::close();
    }

    public function testRegisterUser_ValidUser_RegistersAndSendsEmail()
    {
        // Arrange
        $username = 'testuser';
        $email = '[email protected]';

        // Create mocks for UserRepository and EmailService
        $userRepositoryMock = Mockery::mock(UserRepository::class);
        $emailServiceMock = Mockery::mock(EmailService::class);

        // Set expectations on UserRepository mock
        $userRepositoryMock->shouldReceive('findByUsername')
                           ->with($username)
                           ->once()
                           ->andReturn(null); // User does not exist

        $userRepositoryMock->shouldReceive('save')
                           ->once()
                           ->with(Mockery::type(User::class)); // Expect a User object

        // Set expectations on EmailService mock
        $emailServiceMock->shouldReceive('sendWelcomeEmail')
                         ->once()
                         ->with(Mockery::type(User::class)); // Expect a User object

        // Create UserController with the mocks
        $userController = new UserController($userRepositoryMock, $emailServiceMock);

        // Act
        $result = $userController->registerUser($username, $email);

        // Assert
        $this->assertTrue($result);
    }

    public function testRegisterUser_ExistingUser_ReturnsFalse()
    {
        // Arrange
        $username = 'testuser';
        $email = '[email protected]';

        // Create mocks for UserRepository and EmailService
        $userRepositoryMock = Mockery::mock(UserRepository::class);
        $emailServiceMock = Mockery::mock(EmailService::class);

        // Set expectations on UserRepository mock
        $existingUser = new User($username, $email);
        $userRepositoryMock->shouldReceive('findByUsername')
                           ->with($username)
                           ->once()
                           ->andReturn($existingUser); // User already exists

        // Create UserController with the mocks
        $userController = new UserController($userRepositoryMock, $emailServiceMock);

        // Act
        $result = $userController->registerUser($username, $email);

        // Assert
        $this->assertFalse($result);

        // Ensure that save and sendWelcomeEmail are not called
        $userRepositoryMock->shouldNotReceive('save');
        $emailServiceMock->shouldNotReceive('sendWelcomeEmail');

    }

    public function testRegisterUser_InvalidUsername_ReturnsFalse()
    {
        // Arrange
        $username = '';
        $email = '[email protected]';

        // Create mocks for UserRepository and EmailService
        $userRepositoryMock = Mockery::mock(UserRepository::class);
        $emailServiceMock = Mockery::mock(EmailService::class);

        // Create UserController with the mocks
        $userController = new UserController($userRepositoryMock, $emailServiceMock);

        // Act
        $result = $userController->registerUser($username, $email);

        // Assert
        $this->assertFalse($result);

        // Ensure that findByUsername, save and sendWelcomeEmail are not called
        $userRepositoryMock->shouldNotReceive('findByUsername');
        $userRepositoryMock->shouldNotReceive('save');
        $emailServiceMock->shouldNotReceive('sendWelcomeEmail');
    }
}

在这个例子中,我们 Mock 了 UserRepositoryEmailService 类,并设置了相应的期望。例如,我们期望 UserRepository::findByUsername() 方法被调用一次,并且参数为 $username。我们还使用了 Mockery::type(User::class) 参数匹配器来验证 UserRepository::save()EmailService::sendWelcomeEmail() 方法接收到的参数是 User 类的实例。

5. 何时使用 Mock,何时使用 Stub?

选择使用 Mock 还是 Stub 取决于你的测试目标。

  • 使用 Stub: 当你需要控制被测单元的输入,并验证其基于输入的状态转换是否正确时。
  • 使用 Mock: 当你需要验证被测单元与外部依赖的交互是否符合预期,例如验证方法调用次数、传递的参数等。

一般来说,如果你的测试目标是验证被测单元的状态,那么使用 Stub 更合适。如果你的测试目标是验证被测单元的行为,那么使用 Mock 更合适。

6. 最佳实践

  • 只 Mock 你拥有的代码: 避免 Mock 第三方库或框架的代码。你应该专注于测试你的代码如何使用这些库和框架,而不是测试它们本身。
  • 避免过度 Mock: 过度 Mock 会导致测试代码过于复杂,难以维护,并且可能掩盖真实的问题。只 Mock 那些真正需要隔离的依赖项。
  • 保持 Mock 对象简单: Mock 对象应该尽可能简单,只提供必要的返回值和行为。
  • 编写可读性强的测试代码: 使用清晰、简洁的 Mocking API,使测试代码易于理解和维护。
  • 使用参数匹配器: 使用参数匹配器来验证方法调用的参数,提高测试的准确性。
  • 使用 Mockery 的 tearDown() 方法: 在每个测试方法结束后调用 Mockery::close() 方法,以确保所有的 Mock 对象都被清理,避免测试之间的相互影响。
  • 考虑使用 Test Doubles (Mock, Stub, Spy, Fake): 了解不同的 Test Doubles 类型,例如 Spy 和 Fake,并在适当的情况下使用它们。 Spy 可以让你在调用真实方法的同时,记录方法的调用信息。 Fake 提供一个简化的、可控的实现,用于替代复杂的依赖项。

7. 总结:隔离依赖,提升质量

Mocking 和 Stubbing 是单元测试中不可或缺的技术。通过隔离外部依赖,我们可以编写更快速、更可靠、更易于维护的单元测试。Mockery 是一个强大的 PHP Mocking 框架,它提供了简洁、灵活的 API 来创建和管理 Mock 对象。合理使用 Mock 和 Stub,并遵循最佳实践,可以帮助你编写更高质量的软件。

选择合适的工具,编写更好的测试

Mock 和 Stub 的选择取决于测试的目的,以及想要验证的是状态还是行为。理解它们的区别,并善用 Mockery 等工具,能够帮助开发者编写更完善、更可靠的单元测试,最终提升软件质量。

发表回复

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