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 了 UserRepository 和 EmailService 类,并设置了相应的期望。例如,我们期望 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 等工具,能够帮助开发者编写更完善、更可靠的单元测试,最终提升软件质量。