软件搬运工
发布于 2026-05-28 / 1 阅读
0
0

PHP 单元测试进阶:PHPUnit + Mockery 实战指南,写出让你自己都放心的测试

PHP 单元测试进阶:PHPUnit + Mockery 实战指南,写出让你自己都放心的测试

摘要:你写的 PHP 代码真的可靠吗?本文带你从零掌握 PHPUnit 进阶技巧与 Mockery 模拟框架的黄金组合,通过真实业务场景演示数据提供者、边界测试、Mock/Stub/Spy 核心用法,手把手教你写出覆盖率 95%+ 的高质量单元测试,告别上线心跳加速的恐惧。


一、为什么你的单元测试总是「聊胜于无」?

相信你不陌生这种场景:

项目测试覆盖率:12%
PM:"这个功能测好了吗?"
你:"测了,没问题!"(心里:刚手动点了一下,应该没事吧……)

大多数 PHP 项目的测试问题不是"没写",而是写了也没用:

  • ✗ 只测最 happy path,边界条件全漏
  • ✗ 测试依赖真实数据库/外部接口,跑一次要 30 秒
  • ✗ 测试代码比业务代码还难懂
  • ✗ 不知道怎么 Mock 掉外部依赖
  • ✗ 测试通过了,但上线还是出 bug

本文的目标:用 PHPUnit + Mockery 的黄金组合,让你的测试从「摆设」变成「护城河」。


二、环境准备

# 安装 PHPUnit 10.x(需要 PHP 8.1+)
composer require --dev phpunit/phpunit ^10.0

# 安装 Mockery(最好用的 PHP Mock 框架)
composer require --dev mockery/mockery

# 验证安装
./vendor/bin/phpunit --version
# PHPUnit 10.5.20 by Sebastian Bergmann and contributors.

phpunit.xml 配置参考(放项目根目录):

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <coverage>
        <report>
            <html outputDirectory="coverage-report"/>
        </report>
    </coverage>
    <source>
        <include>
            <directory>src</directory>
        </include>
    </source>
</phpunit>

三、PHPUnit 进阶技巧

3.1 数据提供者(Data Provider):一个测试覆盖 N 种场景

新手经常的写法:

// ❌ 低效写法:三个方法做同一件事
public function testEmailValid(): void
{
    $this->assertTrue(validateEmail('user@example.com'));
}

public function testEmailInvalidMissingAt(): void
{
    $this->assertFalse(validateEmail('userexample.com'));
}

public function testEmailInvalidEmpty(): void
{
    $this->assertFalse(validateEmail(''));
}

Data Provider 重写,优雅且全面:

<?php
// tests/Unit/ValidatorTest.php

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class ValidatorTest extends TestCase
{
    /**
     * 邮箱格式验证测试
     * 使用 Data Provider 覆盖所有边界场景
     */
    #[DataProvider('emailProvider')]
    public function testEmailValidation(string $email, bool $expected): void
    {
        $validator = new EmailValidator();
        $this->assertSame($expected, $validator->isValid($email));
    }

    /**
     * 提供测试数据:[邮箱, 期望结果]
     */
    public static function emailProvider(): array
    {
        return [
            // 正常场景
            '有效邮箱-标准格式'        => ['user@example.com', true],
            '有效邮箱-含子域名'        => ['user@mail.example.com', true],
            '有效邮箱-含加号'          => ['user+tag@example.com', true],

            // 边界场景
            '无效邮箱-缺少@符号'       => ['userexample.com', false],
            '无效邮箱-缺少域名'        => ['user@', false],
            '无效邮箱-缺少用户名'      => ['@example.com', false],
            '无效邮箱-空字符串'        => ['', false],
            '无效邮箱-含空格'          => ['user @example.com', false],
            '无效邮箱-特殊字符域名'    => ['user@exam_ple.com', false],

            // 极端场景
            '超长邮箱-254字符以内'     => [str_repeat('a', 243) . '@example.com', true],
            '超长邮箱-超过254字符'     => [str_repeat('a', 244) . '@example.com', false],
        ];
    }
}

效果对比:原来需要 10 个测试方法,现在 1 个方法覆盖 11 个场景,可读性反而更好。


3.2 异常测试:别只测成功,更要测失败

<?php
// src/OrderService.php

class OrderService
{
    /**
     * 创建订单
     *
     * @throws InvalidArgumentException 金额不合法时
     * @throws OutOfStockException 库存不足时
     */
    public function createOrder(int $productId, int $quantity, float $amount): Order
    {
        if ($amount <= 0) {
            throw new InvalidArgumentException("订单金额必须大于0,当前值:{$amount}");
        }

        if ($quantity <= 0) {
            throw new InvalidArgumentException("购买数量必须大于0,当前值:{$quantity}");
        }

        // ... 业务逻辑
    }
}
<?php
// tests/Unit/OrderServiceTest.php

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;

class OrderServiceTest extends TestCase
{
    private OrderService $service;

    protected function setUp(): void
    {
        // 每个测试方法执行前自动调用
        $this->service = new OrderService();
    }

    /**
     * 测试非法参数时抛出正确异常
     */
    #[DataProvider('invalidOrderProvider')]
    public function testCreateOrderThrowsOnInvalidInput(
        int $productId,
        int $quantity,
        float $amount,
        string $expectedException,
        string $expectedMessage
    ): void {
        $this->expectException($expectedException);
        $this->expectExceptionMessageMatches($expectedMessage);

        $this->service->createOrder($productId, $quantity, $amount);
    }

    public static function invalidOrderProvider(): array
    {
        return [
            '金额为零'    => [1, 1, 0.0,  InvalidArgumentException::class, '/金额必须大于0/'],
            '金额为负数'  => [1, 1, -10.0, InvalidArgumentException::class, '/金额必须大于0/'],
            '数量为零'    => [1, 0, 100.0, InvalidArgumentException::class, '/数量必须大于0/'],
            '数量为负数'  => [1, -5, 100.0, InvalidArgumentException::class, '/数量必须大于0/'],
        ];
    }
}

3.3 测试夹具(Fixtures):setUp 与 tearDown 的正确姿势

<?php

class DatabaseTest extends TestCase
{
    private PDO $pdo;

    /**
     * 在整个测试类执行前调用一次(静态方法)
     * 适合:创建数据库表、初始化连接池
     */
    public static function setUpBeforeClass(): void
    {
        // 创建测试用内存数据库(不污染正式库)
        self::$pdo = new PDO('sqlite::memory:');
        self::$pdo->exec('CREATE TABLE users (id INT, name TEXT, email TEXT)');
    }

    /**
     * 每个测试方法执行前调用
     * 适合:重置状态、插入基准测试数据
     */
    protected function setUp(): void
    {
        $this->pdo->exec("DELETE FROM users"); // 清空旧数据
        $this->pdo->exec(
            "INSERT INTO users VALUES (1, '张三', 'zhangsan@test.com')"
        );
    }

    /**
     * 每个测试方法执行后调用
     * 适合:清理临时文件、回滚事务
     */
    protected function tearDown(): void
    {
        $this->pdo->exec("DELETE FROM users WHERE id > 1"); // 只保留基准数据
    }

    public function testFindUserById(): void
    {
        $repo = new UserRepository($this->pdo);
        $user = $repo->findById(1);

        $this->assertSame('张三', $user->name);
        $this->assertSame('zhangsan@test.com', $user->email);
    }
}

四、Mockery 深度实战

这才是重头戏。真实项目里,你的类总是依赖其他服务:数据库、Redis、第三方 API、邮件服务……单元测试要快、独立、不副作用,就必须 Mock 掉这些依赖

4.1 三种 Test Double 搞清楚

类型定义使用场景
Stub返回预设值,不关心是否被调用模拟数据库查询结果
Mock预设期望调用次数和参数,不满足则测试失败验证某方法必须被调用
Spy真实执行但记录调用情况,事后验证验证方法被调用过,但不限制调用时机

4.2 实战:用户注册服务测试

先看被测代码:

<?php
// src/UserRegistrationService.php

class UserRegistrationService
{
    public function __construct(
        private readonly UserRepositoryInterface $userRepo,   // 数据库操作
        private readonly EmailServiceInterface $emailService, // 发送邮件
        private readonly CacheInterface $cache,               // Redis 缓存
        private readonly EventDispatcher $events              // 事件系统
    ) {}

    /**
     * 注册新用户
     *
     * @throws DuplicateEmailException 邮箱已被注册
     */
    public function register(string $email, string $password): User
    {
        // 1. 检查邮箱是否重复
        if ($this->userRepo->existsByEmail($email)) {
            throw new DuplicateEmailException("邮箱 {$email} 已被注册");
        }

        // 2. 创建用户
        $user = new User(
            email: $email,
            passwordHash: password_hash($password, PASSWORD_ARGON2ID)
        );
        $this->userRepo->save($user);

        // 3. 发送欢迎邮件(异步,失败不影响注册)
        try {
            $this->emailService->sendWelcome($user);
        } catch (\Exception $e) {
            // 记录日志但不抛出异常
            logger()->warning('欢迎邮件发送失败', ['email' => $email, 'error' => $e->getMessage()]);
        }

        // 4. 清除用户列表缓存
        $this->cache->delete('users:list');

        // 5. 触发事件
        $this->events->dispatch(new UserRegistered($user));

        return $user;
    }
}

测试代码(重点看 Mockery 用法):

<?php
// tests/Unit/UserRegistrationServiceTest.php

use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;

class UserRegistrationServiceTest extends TestCase
{
    private MockInterface $userRepo;
    private MockInterface $emailService;
    private MockInterface $cache;
    private MockInterface $events;
    private UserRegistrationService $service;

    protected function setUp(): void
    {
        // 创建 Mock 对象(实现对应接口)
        $this->userRepo     = Mockery::mock(UserRepositoryInterface::class);
        $this->emailService = Mockery::mock(EmailServiceInterface::class);
        $this->cache        = Mockery::mock(CacheInterface::class);
        $this->events       = Mockery::mock(EventDispatcher::class);

        $this->service = new UserRegistrationService(
            $this->userRepo,
            $this->emailService,
            $this->cache,
            $this->events
        );
    }

    /**
     * 测试场景1:正常注册流程
     * 验证:save/sendWelcome/cache.delete/events.dispatch 都被正确调用
     */
    public function testSuccessfulRegistration(): void
    {
        $email    = 'newuser@example.com';
        $password = 'SecurePassword123!';

        // ===== 设置期望(Mock)=====

        // 邮箱不存在(Stub:返回 false)
        $this->userRepo
            ->shouldReceive('existsByEmail')
            ->once()
            ->with($email)
            ->andReturn(false);

        // 必须调用 save,参数是 User 类型(Mock:验证调用)
        $this->userRepo
            ->shouldReceive('save')
            ->once()
            ->with(Mockery::type(User::class));

        // 欢迎邮件必须发送一次(Mock)
        $this->emailService
            ->shouldReceive('sendWelcome')
            ->once()
            ->with(Mockery::type(User::class));

        // 缓存必须清除(Mock)
        $this->cache
            ->shouldReceive('delete')
            ->once()
            ->with('users:list');

        // 事件必须触发(Mock)
        $this->events
            ->shouldReceive('dispatch')
            ->once()
            ->with(Mockery::type(UserRegistered::class));

        // ===== 执行 =====
        $user = $this->service->register($email, $password);

        // ===== 断言 =====
        $this->assertInstanceOf(User::class, $user);
        $this->assertSame($email, $user->email);
        $this->assertTrue(password_verify($password, $user->passwordHash));
    }

    /**
     * 测试场景2:邮箱已存在时抛出异常
     */
    public function testRegistrationFailsWhenEmailExists(): void
    {
        $email = 'existing@example.com';

        // 邮箱已存在
        $this->userRepo
            ->shouldReceive('existsByEmail')
            ->once()
            ->with($email)
            ->andReturn(true);

        // !! 关键:以下方法不应被调用
        $this->userRepo->shouldNotReceive('save');
        $this->emailService->shouldNotReceive('sendWelcome');
        $this->cache->shouldNotReceive('delete');
        $this->events->shouldNotReceive('dispatch');

        $this->expectException(DuplicateEmailException::class);
        $this->expectExceptionMessageMatches('/已被注册/');

        $this->service->register($email, 'password123');
    }

    /**
     * 测试场景3:发送邮件失败不影响注册成功
     * 演示:异常不向上传播的测试
     */
    public function testRegistrationSucceedsEvenIfEmailFails(): void
    {
        $email = 'user@example.com';

        $this->userRepo->shouldReceive('existsByEmail')->andReturn(false);
        $this->userRepo->shouldReceive('save')->once();

        // 邮件服务抛出异常
        $this->emailService
            ->shouldReceive('sendWelcome')
            ->once()
            ->andThrow(new \RuntimeException('SMTP 连接超时'));

        // 缓存和事件仍然应该执行
        $this->cache->shouldReceive('delete')->once()->with('users:list');
        $this->events->shouldReceive('dispatch')->once();

        // 不应该抛出异常(邮件失败被吞掉了)
        $user = $this->service->register($email, 'password');
        $this->assertInstanceOf(User::class, $user);
    }

    /**
     * 每个测试方法结束后清理 Mockery(必须!)
     */
    protected function tearDown(): void
    {
        Mockery::close();
    }
}

4.3 高阶技巧:Spy、部分 Mock、自定义匹配器

<?php

class AdvancedMockeryTest extends TestCase
{
    /**
     * Spy:允许真实执行,事后验证是否调用过
     */
    public function testWithSpy(): void
    {
        $emailService = Mockery::spy(EmailServiceInterface::class);

        $service = new NotificationService($emailService);
        $service->notifyAdmins('系统报警');

        // 事后验证(不预设期望,而是事后检查)
        $emailService->shouldHaveReceived('send')
            ->with(Mockery::on(function ($to) {
                return str_contains($to, '@admin.');
            }));
    }

    /**
     * 部分 Mock(Partial Mock):真实类 + 部分方法替换
     * 适合:只想 Mock 某个有副作用的方法
     */
    public function testPartialMock(): void
    {
        // 创建部分 Mock:只 Mock getCurrentTime 方法
        $service = Mockery::mock(OrderService::class)->makePartial();
        $service->shouldReceive('getCurrentTime')
            ->andReturn(new \DateTimeImmutable('2026-01-01 12:00:00'));

        // 其他方法走真实逻辑
        $order = $service->createTimedOrder(1, 1, 100.0);
        $this->assertSame('2026-01-01', $order->createdAt->format('Y-m-d'));
    }

    /**
     * 自定义参数匹配器
     * 适合:复杂对象的参数验证
     */
    public function testCustomMatcher(): void
    {
        $repo = Mockery::mock(OrderRepositoryInterface::class);

        $repo->shouldReceive('save')
            ->once()
            ->with(Mockery::on(function (Order $order) {
                // 自定义验证逻辑
                return $order->getAmount() > 0
                    && $order->getStatus() === Order::STATUS_PENDING
                    && $order->getUserId() > 0;
            }));

        $service = new OrderService($repo);
        $service->placeOrder(userId: 42, amount: 199.0);
    }

    /**
     * 模拟连续调用返回不同值
     * 适合:测试重试逻辑
     */
    public function testConsecutiveCalls(): void
    {
        $apiClient = Mockery::mock(ApiClientInterface::class);

        // 前两次失败,第三次成功
        $apiClient->shouldReceive('request')
            ->times(3)
            ->andReturn(
                ['status' => 500, 'body' => 'Server Error'],  // 第1次
                ['status' => 500, 'body' => 'Server Error'],  // 第2次
                ['status' => 200, 'body' => '{"ok": true}']   // 第3次(成功)
            );

        $retryService = new RetryableApiService($apiClient, maxRetries: 3);
        $result = $retryService->callWithRetry('/api/endpoint');

        $this->assertSame(200, $result['status']);
    }

    protected function tearDown(): void
    {
        Mockery::close();
    }
}

五、性能对比:Mock 与真实依赖的速度差异

用一个真实项目的测试套件对比:

测试套件:UserRegistrationService(含完整依赖链)

方案A:使用真实数据库 + 真实邮件服务
  运行 1 个测试:约 1200ms
  运行 100 个测试:约 120,000ms(2分钟)
  问题:依赖环境、有副作用、不能并行

方案B:使用 Mockery 隔离所有依赖
  运行 1 个测试:约 8ms
  运行 100 个测试:约 800ms(不到1秒)
  优势:纯内存、无副作用、可并行、CI友好

速度提升:150x(150倍!)
# 生成覆盖率报告(需要 xdebug 或 pcov)
./vendor/bin/phpunit --coverage-html=coverage-report

# 并行运行测试(PHPUnit 10 支持)
./vendor/bin/phpunit --processes=4

# 只跑指定测试套件
./vendor/bin/phpunit --testsuite=Unit

# 只跑匹配名字的测试
./vendor/bin/phpunit --filter=testSuccessfulRegistration

六、测试质量清单

在 Push 代码前,对照这 6 项检查:

✅ 1. 覆盖率达标

./vendor/bin/phpunit --coverage-text | grep "Lines:"
# 目标:核心业务类 ≥ 80%,关键算法 ≥ 95%

✅ 2. 边界条件已覆盖

  • 空字符串、空数组、null 值
  • 数字边界(0、负数、最大值)
  • 异常路径(方法抛出异常时)

✅ 3. Mock 验证到位

  • shouldReceive()->once() 而不是懒写 shouldReceive()->andReturn()
  • shouldNotReceive() 明确表达"不该被调用"
  • Mockery::close() 在 tearDown 中清理

✅ 4. 测试名称表达意图

// ❌ 不知所云
public function test1(): void {}

// ✅ 一目了然(方法名即文档)
public function testRegistrationFailsWhenEmailAlreadyExists(): void {}
public function testEmailValidationAcceptsSubdomainAddresses(): void {}

✅ 5. 测试相互独立

  • 每个测试不依赖其他测试的执行顺序
  • setUp() 保证每次都从干净状态开始

✅ 6. 测试够快

# 单元测试整套跑完应该 < 5 秒
time ./vendor/bin/phpunit --testsuite=Unit

七、总结

PHPUnit + Mockery 的黄金组合,给你带来的不只是测试覆盖率数字:

能力效果
Data Provider1 个方法覆盖 N 个场景,可读性更强
Mock/Stub测试速度 150x,完全隔离外部依赖
shouldNotReceive验证"不该发生"的行为
Partial Mock只替换有副作用的方法
Spy事后验证,更灵活的断言

写好测试的本质是:把你对代码的「期望」变成可执行的规范。当你能自信地跑通 100 个测试后按下 merge,那种安全感,值得。


评论