当前位置:首页>php>PHP 单元测试进阶:PHPUnit + Mockery 实战指南,写出让你自己都放心的测试

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

  • 2026-10-11 08:03:21
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"?>
<phpunitxmlns: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>
<testsuitename="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuitename="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<report>
<htmloutputDirectory="coverage-report"/>
</report>
</coverage>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

三、PHPUnit 进阶技巧

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

新手经常的写法:

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

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

publicfunctiontestEmailInvalidEmpty(): void
{
$this->assertFalse(validateEmail(''));
}

用 Data Provider 重写,优雅且全面:

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

usePHPUnit\Framework\TestCase;
usePHPUnit\Framework\Attributes\DataProvider;

classValidatorTestextendsTestCase
{
/**
     * 邮箱格式验证测试
     * 使用 Data Provider 覆盖所有边界场景
     */

#[DataProvider('emailProvider')]
publicfunctiontestEmailValidation(string$email, bool$expected): void
{
$validator = newEmailValidator();
$this->assertSame($expected, $validator->isValid($email));
    }

/**
     * 提供测试数据:[邮箱, 期望结果]
     */

publicstaticfunctionemailProvider(): 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

classOrderService
{
/**
     * 创建订单
     *
     * @throws InvalidArgumentException 金额不合法时
     * @throws OutOfStockException 库存不足时
     */

publicfunctioncreateOrder(int$productId, int$quantity, float$amount): Order
{
if ($amount <= 0) {
thrownewInvalidArgumentException("订单金额必须大于0,当前值:{$amount}");
        }

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

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

usePHPUnit\Framework\TestCase;
usePHPUnit\Framework\Attributes\DataProvider;

classOrderServiceTestextendsTestCase
{
private OrderService $service;

protectedfunctionsetUp(): void
{
// 每个测试方法执行前自动调用
$this->service = newOrderService();
    }

/**
     * 测试非法参数时抛出正确异常
     */

#[DataProvider('invalidOrderProvider')]
publicfunctiontestCreateOrderThrowsOnInvalidInput(
int$productId,
int$quantity,
float$amount,
string$expectedException,
string$expectedMessage
): void
{
$this->expectException($expectedException);
$this->expectExceptionMessageMatches($expectedMessage);

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

publicstaticfunctioninvalidOrderProvider(): 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

classDatabaseTestextendsTestCase
{
private PDO $pdo;

/**
     * 在整个测试类执行前调用一次(静态方法)
     * 适合:创建数据库表、初始化连接池
     */

publicstaticfunctionsetUpBeforeClass(): void
{
// 创建测试用内存数据库(不污染正式库)
self::$pdo = newPDO('sqlite::memory:');
self::$pdo->exec('CREATE TABLE users (id INT, name TEXT, email TEXT)');
    }

/**
     * 每个测试方法执行前调用
     * 适合:重置状态、插入基准测试数据
     */

protectedfunctionsetUp(): void
{
$this->pdo->exec("DELETE FROM users"); // 清空旧数据
$this->pdo->exec(
"INSERT INTO users VALUES (1, '张三', 'zhangsan@test.com')"
        );
    }

/**
     * 每个测试方法执行后调用
     * 适合:清理临时文件、回滚事务
     */

protectedfunctiontearDown(): void
{
$this->pdo->exec("DELETE FROM users WHERE id > 1"); // 只保留基准数据
    }

publicfunctiontestFindUserById(): void
{
$repo = newUserRepository($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

classUserRegistrationService
{
publicfunction__construct(
privatereadonly UserRepositoryInterface $userRepo,   // 数据库操作
privatereadonly EmailServiceInterface $emailService, // 发送邮件
privatereadonly CacheInterface $cache,               // Redis 缓存
privatereadonly EventDispatcher $events              // 事件系统
) 
{}

/**
     * 注册新用户
     *
     * @throws DuplicateEmailException 邮箱已被注册
     */

publicfunctionregister(string$email, string$password): User
{
// 1. 检查邮箱是否重复
if ($this->userRepo->existsByEmail($email)) {
thrownewDuplicateEmailException("邮箱 {$email} 已被注册");
        }

// 2. 创建用户
$user = newUser(
            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(newUserRegistered($user));

return$user;
    }
}

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

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

useMockery;
useMockery\MockInterface;
usePHPUnit\Framework\TestCase;

classUserRegistrationServiceTestextendsTestCase
{
private MockInterface $userRepo;
private MockInterface $emailService;
private MockInterface $cache;
private MockInterface $events;
private UserRegistrationService $service;

protectedfunctionsetUp(): 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 = newUserRegistrationService(
$this->userRepo,
$this->emailService,
$this->cache,
$this->events
        );
    }

/**
     * 测试场景1:正常注册流程
     * 验证:save/sendWelcome/cache.delete/events.dispatch 都被正确调用
     */

publicfunctiontestSuccessfulRegistration(): 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:邮箱已存在时抛出异常
     */

publicfunctiontestRegistrationFailsWhenEmailExists(): 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:发送邮件失败不影响注册成功
     * 演示:异常不向上传播的测试
     */

publicfunctiontestRegistrationSucceedsEvenIfEmailFails(): 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(必须!)
     */

protectedfunctiontearDown(): void
{
Mockery::close();
    }
}

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

<?php

classAdvancedMockeryTestextendsTestCase
{
/**
     * Spy:允许真实执行,事后验证是否调用过
     */

publicfunctiontestWithSpy(): void
{
$emailService = Mockery::spy(EmailServiceInterface::class);

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

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

/**
     * 部分 Mock(Partial Mock):真实类 + 部分方法替换
     * 适合:只想 Mock 某个有副作用的方法
     */

publicfunctiontestPartialMock(): 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'));
    }

/**
     * 自定义参数匹配器
     * 适合:复杂对象的参数验证
     */

publicfunctiontestCustomMatcher(): 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 = newOrderService($repo);
$service->placeOrder(userId: 42, amount: 199.0);
    }

/**
     * 模拟连续调用返回不同值
     * 适合:测试重试逻辑
     */

publicfunctiontestConsecutiveCalls(): 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 = newRetryableApiService($apiClient, maxRetries: 3);
$result = $retryService->callWithRetry('/api/endpoint');

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

protectedfunctiontearDown(): 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. 测试名称表达意图

// ❌ 不知所云
publicfunctiontest1(): void{}

// ✅ 一目了然(方法名即文档)
publicfunctiontestRegistrationFailsWhenEmailAlreadyExists(): void{}
publicfunctiontestEmailValidationAcceptsSubdomainAddresses(): void{}

✅ 5. 测试相互独立

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

✅ 6. 测试够快

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

七、总结

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

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

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

最新文章

随机文章