PHP `PHPUnit` `Data Providers` 与 `Test Doubles` (`Mock`, `Stub`, `Spy`)

哈喽,各位观众老爷们,今天咱们来聊聊PHPUnit里几个好玩又实用的小伙伴:Data Providers和Test Doubles。别怕,虽然名字听起来有点高大上,但其实都是能帮你写出更健壮、更易于维护的测试代码的利器。

Data Providers:让你的测试像机关枪一样扫射各种数据

想象一下,你要测试一个函数,这个函数的功能是判断一个数是不是偶数。你可能会写出这样的测试:

<?php

use PHPUnitFrameworkTestCase;

class EvenNumberTest extends TestCase
{
    public function testIsEvenWithEvenNumber()
    {
        $this->assertTrue(isEven(2));
    }

    public function testIsEvenWithOddNumber()
    {
        $this->assertFalse(isEven(3));
    }

    public function testIsEvenWithZero()
    {
        $this->assertTrue(isEven(0));
    }
}

function isEven(int $number): bool
{
    return $number % 2 === 0;
}

这看起来没什么问题,但如果我们要测试更多的偶数和奇数呢?难道要复制粘贴一堆类似的测试用例?太Low了!这时候,Data Providers就派上用场了。

Data Providers允许你提供一组数据,然后PHPUnit会用这组数据多次运行同一个测试方法。就像机关枪一样,哒哒哒哒地扫射各种数据,确保你的函数在各种情况下都能正常工作。

让我们用Data Provider重写上面的测试:

<?php

use PHPUnitFrameworkTestCase;

class EvenNumberTest extends TestCase
{
    /**
     * @dataProvider evenNumberProvider
     */
    public function testIsEven(int $number, bool $expected)
    {
        $this->assertEquals($expected, isEven($number));
    }

    public function evenNumberProvider(): array
    {
        return [
            [2, true],
            [3, false],
            [0, true],
            [10, true],
            [11, false],
            [-2, true],
            [-3, false],
        ];
    }
}

function isEven(int $number): bool
{
    return $number % 2 === 0;
}

在这个例子中,evenNumberProvider 方法就是一个Data Provider。它返回一个二维数组,每一行代表一组测试数据。第一列是输入数据 $number,第二列是期望的输出 $expected

@dataProvider evenNumberProvider 注解告诉PHPUnit,testIsEven 方法要使用 evenNumberProvider 提供的数据。

现在,PHPUnit会运行 testIsEven 方法七次,每次使用 evenNumberProvider 返回的一组数据。这样我们就用更少的代码,测试了更多的场景。

Data Provider 的一些小技巧:

  • Data Provider 方法必须是 public 的。
  • Data Provider 方法必须返回一个数组,这个数组可以是一维的,也可以是二维的。如果是二维的,每一行代表一组测试数据。
  • 可以在一个测试类中使用多个 Data Provider。只需要在 @dataProvider 注解中指定不同的 Data Provider 方法名即可。
  • Data Provider 可以返回 Generator 对象,这在处理大量数据时非常有用。

Test Doubles:模拟依赖,隔离测试

在单元测试中,我们希望只测试被测单元本身的功能,而不要受到它所依赖的其他组件的影响。如果被测单元依赖于一个外部服务,例如数据库或者第三方API,那么测试就会变得很复杂,而且可能会受到外部环境的影响。

Test Doubles就是用来解决这个问题的。Test Doubles 是一种模拟对象,它可以模拟被测单元所依赖的其他组件的行为,从而隔离测试环境,让我们可以专注于测试被测单元本身的功能。

PHPUnit提供了四种类型的Test Doubles:

  • Dummy: 传递给被测单元,但并不真正使用。
  • Stub: 提供预定义的返回值,模拟被测单元所依赖的组件的行为。
  • Mock: 用于验证被测单元是否以正确的方式调用了它所依赖的组件。
  • Spy: 记录被测单元对它所依赖的组件的调用,用于事后分析。

接下来,我们来详细介绍这四种类型的Test Doubles。

1. Dummy:假装存在的占位符

Dummy 对象就像一个假人,它只是用来占位的,并不会真正被使用。通常用于满足被测单元的接口要求,但我们并不关心它实际的行为。

例如,假设我们有一个 UserController 类,它的构造函数需要一个 LoggerInterface 对象,但我们在测试 UserController 的其他功能时,并不需要用到 LoggerInterface。这时,我们可以使用 Dummy 对象来满足 UserController 的构造函数的要求。

<?php

use PHPUnitFrameworkTestCase;
use PsrLogLoggerInterface;
use AppUserController;

class UserControllerTest extends TestCase
{
    public function testIndex()
    {
        // 创建一个 Dummy Logger 对象
        $logger = $this->createMock(LoggerInterface::class);

        // 创建 UserController 对象,传入 Dummy Logger
        $userController = new UserController($logger);

        // 调用 index 方法
        $response = $userController->index();

        // 断言 response 是否正确
        $this->assertEquals('Index page', $response);
    }
}

namespace App;

use PsrLogLoggerInterface;

class UserController
{
    private $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function index(): string
    {
        return 'Index page';
    }
}

在这个例子中,我们使用 createMock(LoggerInterface::class) 创建了一个 Dummy Logger 对象。这个对象实现了 LoggerInterface 接口,但它的所有方法都是空的,不会做任何事情。

2. Stub:提供预定义行为的演员

Stub 对象就像一个演员,它可以按照我们的剧本,提供预定义的返回值,模拟被测单元所依赖的组件的行为。

例如,假设我们有一个 OrderService 类,它依赖于一个 PaymentGatewayInterface 来处理支付。在测试 OrderServiceprocessOrder 方法时,我们可以使用 Stub 对象来模拟 PaymentGatewayInterface 的行为,例如模拟支付成功或者支付失败。

<?php

use PHPUnitFrameworkTestCase;
use AppOrderService;
use AppPaymentGatewayInterface;

class OrderServiceTest extends TestCase
{
    public function testProcessOrderSuccess()
    {
        // 创建一个 PaymentGateway Stub 对象
        $paymentGateway = $this->createStub(PaymentGatewayInterface::class);

        // 设置 Stub 对象的行为:processPayment 方法总是返回 true (支付成功)
        $paymentGateway->method('processPayment')
            ->willReturn(true);

        // 创建 OrderService 对象,传入 PaymentGateway Stub
        $orderService = new OrderService($paymentGateway);

        // 调用 processOrder 方法
        $result = $orderService->processOrder(100);

        // 断言 result 是否为 true (订单处理成功)
        $this->assertTrue($result);
    }

    public function testProcessOrderFailure()
    {
        // 创建一个 PaymentGateway Stub 对象
        $paymentGateway = $this->createStub(PaymentGatewayInterface::class);

        // 设置 Stub 对象的行为:processPayment 方法总是返回 false (支付失败)
        $paymentGateway->method('processPayment')
            ->willReturn(false);

        // 创建 OrderService 对象,传入 PaymentGateway Stub
        $orderService = new OrderService($paymentGateway);

        // 调用 processOrder 方法
        $result = $orderService->processOrder(100);

        // 断言 result 是否为 false (订单处理失败)
        $this->assertFalse($result);
    }
}

namespace App;

interface PaymentGatewayInterface
{
    public function processPayment(int $amount): bool;
}

class OrderService
{
    private $paymentGateway;

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

    public function processOrder(int $amount): bool
    {
        return $this->paymentGateway->processPayment($amount);
    }
}

在这个例子中,我们使用 createStub(PaymentGatewayInterface::class) 创建了一个 PaymentGateway Stub 对象。然后,我们使用 method('processPayment')->willReturn(true) 设置了 Stub 对象的行为:当 processPayment 方法被调用时,总是返回 true

3. Mock:验证交互的监视器

Mock 对象就像一个监视器,它可以验证被测单元是否以正确的方式调用了它所依赖的组件。Mock 对象可以验证方法是否被调用、被调用了多少次、以及传递的参数是否正确。

例如,假设我们有一个 ArticleService 类,它依赖于一个 NotificationService 来发送文章发布的通知。在测试 ArticleServicepublishArticle 方法时,我们可以使用 Mock 对象来验证 NotificationServicesendNotification 方法是否被调用,以及传递的参数是否正确。

<?php

use PHPUnitFrameworkTestCase;
use AppArticleService;
use AppNotificationService;

class ArticleServiceTest extends TestCase
{
    public function testPublishArticle()
    {
        // 创建一个 NotificationService Mock 对象
        $notificationService = $this->createMock(NotificationService::class);

        // 设置 Mock 对象的期望:sendNotification 方法应该被调用一次,并且传递的参数为 'Article published'
        $notificationService->expects($this->once())
            ->method('sendNotification')
            ->with($this->equalTo('Article published'));

        // 创建 ArticleService 对象,传入 NotificationService Mock
        $articleService = new ArticleService($notificationService);

        // 调用 publishArticle 方法
        $articleService->publishArticle('My Article');
    }
}

namespace App;

class NotificationService
{
    public function sendNotification(string $message): void
    {
        // Send notification logic
    }
}

class ArticleService
{
    private $notificationService;

    public function __construct(NotificationService $notificationService)
    {
        $this->notificationService = $notificationService;
    }

    public function publishArticle(string $title): void
    {
        // Publish article logic

        $this->notificationService->sendNotification('Article published');
    }
}

在这个例子中,我们使用 createMock(NotificationService::class) 创建了一个 NotificationService Mock 对象。然后,我们使用 expects($this->once())->method('sendNotification')->with($this->equalTo('Article published')) 设置了 Mock 对象的期望:sendNotification 方法应该被调用一次,并且传递的参数为 'Article published'

如果 ArticleServicepublishArticle 方法没有调用 NotificationServicesendNotification 方法,或者调用了但传递的参数不正确,那么测试就会失败。

4. Spy:记录行为的间谍

Spy 对象就像一个间谍,它可以记录被测单元对它所依赖的组件的调用,用于事后分析。Spy 对象可以记录方法是否被调用、被调用了多少次、以及传递的参数。

Spy 和 Mock 的区别在于,Mock 用于验证行为,而 Spy 用于记录行为。Mock 会在测试过程中强制执行期望,而 Spy 只是记录行为,不会强制执行任何期望。

例如,假设我们有一个 UserService 类,它依赖于一个 UserRepository 来保存用户信息。在测试 UserServicecreateUser 方法时,我们可以使用 Spy 对象来记录 UserRepositorysave 方法是否被调用,以及传递的参数。

<?php

use PHPUnitFrameworkTestCase;
use AppUserService;
use AppUserRepository;
use AppUser;

class UserServiceTest extends TestCase
{
    public function testCreateUser()
    {
        // 创建一个 UserRepository Spy 对象
        $userRepository = $this->createMock(UserRepository::class);

        // 设置 Spy 对象的行为:当 save 方法被调用时,记录调用信息
        $userRepository->expects($this->once())
            ->method('save')
            ->with($this->isInstanceOf(User::class));

        // 创建 UserService 对象,传入 UserRepository Spy
        $userService = new UserService($userRepository);

        // 调用 createUser 方法
        $userService->createUser('John Doe', '[email protected]');
    }
}

namespace App;

class UserRepository
{
    public function save(User $user): void
    {
        // Save user to database
    }
}

class UserService
{
    private $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function createUser(string $name, string $email): void
    {
        $user = new User($name, $email);
        $this->userRepository->save($user);
    }
}

class User
{
    private $name;
    private $email;

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

在这个例子中,我们使用 createMock(UserRepository::class) 创建了一个 UserRepository Spy 对象。然后,我们使用 expects($this->once())->method('save')->with($this->isInstanceOf(User::class)) 设置了 Spy 对象的期望:save 方法应该被调用一次,并且传递的参数是 User 类的一个实例。

Test Doubles 选择指南:

Test Double Type 目的 使用场景
Dummy 满足接口要求,占位 当被测单元需要依赖对象,但不需要使用该对象的功能时。例如,构造函数需要一个 LoggerInterface 对象,但测试过程中不需要用到 Logger。
Stub 提供预定义的返回值 当需要模拟被测单元所依赖的组件的行为,例如模拟支付成功或失败,模拟数据库查询结果。通常用于隔离外部依赖,使测试更加可控。
Mock 验证交互,强制执行期望 当需要验证被测单元是否以正确的方式调用了它所依赖的组件,例如验证方法是否被调用、被调用了多少次、以及传递的参数是否正确。通常用于测试单元之间的协作,确保它们按照预期的方式进行交互。
Spy 记录行为,事后分析 当需要记录被测单元对它所依赖的组件的调用,例如记录方法是否被调用、被调用了多少次、以及传递的参数。通常用于事后分析,例如分析性能瓶颈,或者调试复杂的交互过程。与 Mock 不同,Spy 不会强制执行任何期望,只是记录行为。

总结:

Data Providers 和 Test Doubles 都是PHPUnit中非常有用的工具。Data Providers可以让你用更少的代码,测试更多的场景。Test Doubles可以让你隔离测试环境,专注于测试被测单元本身的功能。

熟练掌握这些工具,可以帮助你写出更健壮、更易于维护的测试代码,提高软件质量。

好了,今天的讲座就到这里。希望大家有所收获,下次再见!

发表回复

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