关于 phpunit
面向 PHP 开发者的 PHPUnit 测试技能,所述规范以 PHPUnit 11+ 和 PHP 8.5+ 为目标。内容侧重测试行为,并涵盖夹具、数据提供器、断言、模拟对象、属性和测试组织。
主要功能
- 01以行为为中心的测试命名
- 02Arrange-Act-Assert 结构
- 03夹具生命周期指导
- 04数据提供器模式
- 05断言与异常测试
- 06Stub、Mock 与 PHP 属性
适用场景
- 01创建 PHPUnit 测试类
- 02测试异常和数据驱动行为
- 03为依赖选择 Stub 或 Mock
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ xobotyi/ cc-foundry --skill phpunit安装后在 Claude Code 中输入/phpunit确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
.dev/reference-inventory.jsonreferences/assertions.mdconfiguration.mddata-providers.mdmocking.md详细介绍
PHPUnit
测试行为,而不是实现。测试是可执行的文档——如果测试名称无法说明代码的行为,就重写它。
PHPUnit 是 PHP 的标准测试框架。它使用继承 TestCase 的测试用例类,通过 setUp() / tearDown() 设置测试夹具,并提供完整的断言 API。所有模式均适用于 PHP 8.5+ 上的 PHPUnit 11+。仅使用 PHP 8 属性——注解在 11 中已弃用,在 12 中已移除。
参考资料
- 断言目录、约束、异常预期 → [
${CLAUDE_SKILL_DIR}/references/assertions.md] — 按类别整理的完整断言 API、约束系统、自定义断言 - 测试替身 — 存根、模拟对象、MockBuilder → [
${CLAUDE_SKILL_DIR}/references/mocking.md] — createStub 与 createMock 的区别、返回值配置、调用匹配器、参数约束、MockBuilder - 数据提供器 — 静态、命名、生成器 → [
${CLAUDE_SKILL_DIR}/references/data-providers.md] — #[DataProvider]、#[TestWith]、命名数据集、生成器提供器、外部提供器 - phpunit.xml 结构、测试套件、源代码配置 → [
${CLAUDE_SKILL_DIR}/references/configuration.md] — XML 元素、严格设置、源代码元素、覆盖率报告、执行顺序
测试结构
测试发现与命名
- 文件: 已配置的测试目录中的
*Test.php。镜像源代码结构:src/Service/PaymentService.php→tests/Unit/Service/PaymentServiceTest.php。 - 类:
final class PaymentServiceTest extends TestCase。始终使用final。 - 方法: 使用
test前缀或#[Test]属性。描述行为:使用testReturnsEmptyCollectionWhenNoResults,而不是testSearch。 - 每个生产类对应一个测试类。 拆分到 Unit / Integration 目录中。
Arrange-Act-Assert
将每个测试分为三个阶段:
public function testUserCreationSetsDefaults(): void
{
// Arrange
$data = ['name' => 'Alice', 'email' => '[email protected]'];
// Act
$user = User::fromArray($data);
// Assert
$this->assertSame('Alice', $user->getName());
$this->assertTrue($user->isActive());
$this->assertSame([], $user->getRoles());
}
- 每个测试只执行一次操作。 如果需要执行多次操作,就编写多个测试。
- 当各阶段显而易见时,注释可选。如果测试较长、各阶段不易立即辨明,则添加注释。
测试粒度
- 每个测试聚焦一个概念。 如果多个断言验证的是同一行为,则可以使用。行为彼此独立时,应拆分测试。
- 默认保持快速。 单元测试应在几毫秒内运行。使用分组控制慢速测试:
#[Group('slow')]。 - 必须隔离。 测试不得依赖执行顺序或共享可变状态。每个测试都要自行建立测试环境。
测试夹具
setUp() / tearDown()
setUp()在每个测试方法运行前执行,每次都会使用一个新实例。在此处创建 SUT 及其存根。tearDown()在每个测试后执行。仅在需要清理外部资源(文件、套接字、数据库连接)时使用。无需清理普通对象。setUpBeforeClass()/tearDownAfterClass()在每个类中各执行一次。用于开销较大的共享资源(数据库连接)。将资源存储在static属性中。
final class PaymentServiceTest extends TestCase
{
private PaymentService $service;
private Gateway&Stub $gateway;
protected function setUp(): void
{
$this->gateway = $this->createStub(Gateway::class);
$this->service = new PaymentService($this->gateway);
}
}
测试夹具生命周期
-
setUpBeforeClass()— 类作用域;第一个测试前执行一次 -
setUp()— 方法作用域;每个测试前执行 -
assertPreConditions()— 方法作用域;setUp后、测试前执行 -
assertPostConditions()— 方法作用域;测试后、tearDown前执行 -
tearDown()— 方法作用域;每个测试后执行 -
tearDownAfterClass()— 类作用域;最后一个测试后执行一次 -
扩展抽象测试用例时,调用
parent::setUp()——否则会静默跳过父类的测试夹具设置。 -
需要多个设置方法时,使用
#[Before]/#[After]属性(避免脆弱的parent::setUp()调用链)。
数据提供器
基本用法
use PHPUnit\Framework\Attributes\DataProvider;
#[DataProvider('additionCases')]
public function testAdd(int $a, int $b, int $expected): void
{
$this->assertSame($expected, $a + $b);
}
public static function additionCases(): array
{
return [
'zeros' => [0, 0, 0],
'positive sum' => [1, 2, 3],
'negative' => [-1, 1, 0],
];
}
- 提供器必须是
public static。 PHPUnit 11 已移除非静态提供器。 - 始终使用命名数据集——字符串键会生成易读的失败输出。
- 使用
#[DataProvider]属性,不要使用@dataProvider注解。
内联数据
适用于规模较小、简单的数据集——无需提供器方法:
use PHPUnit\Framework\Attributes\TestWith;
#[TestWith([0, 0, 0])]
#[TestWith([1, 2, 3])]
#[TestWith([-1, 1, 0])]
public function testAdd(int $a, int $b, int $expected): void
{
$this->assertSame($expected, $a + $b);
}
生成器提供器
适用于规模较大或通过计算生成的数据集:
public static function boundaryCases(): Generator
{
yield 'min int' => [PHP_INT_MIN, 0, PHP_INT_MIN];
yield 'max int' => [PHP_INT_MAX, 0, PHP_INT_MAX];
}
数据提供器规则
- 数据必须是标量或不可变值——数据提供器中不得包含服务对象或复杂对象图。
- 数据提供器中不得使用模拟对象——执行数据提供器时,框架尚未初始化。
- PHPUnit 11 禁止使用空数据提供器——否则会抛出
InvalidDataProviderException。 - 一个测试方法可以叠加多个数据提供器——数据集会合并。
有关外部数据提供器、TestDox 集成和边缘情况,请参阅 ${CLAUDE_SKILL_DIR}/references/data-providers.md。
断言
核心断言
$this->assertSame($expected, $actual); // Strict === (preferred)
$this->assertEquals($expected, $actual); // Loose == (use sparingly)
$this->assertTrue($condition);
$this->assertFalse($condition);
$this->assertNull($value);
$this->assertInstanceOf(Expected::class, $obj);
$this->assertCount(3, $collection);
$this->assertEmpty($collection);
$this->assertArrayHasKey('key', $array);
$this->assertContains($needle, $haystack); // Strict comparison
- 优先使用
assertSame()而不是assertEquals()——严格的类型比较可以发现更多错误。 - 每个测试中可以包含多个断言,前提是它们验证的是同一行为。
字符串断言
$this->assertStringStartsWith('Error:', $message);
$this->assertStringEndsWith('.php', $filename);
$this->assertStringContainsString('needle', $haystack);
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}$/', $date);
浮点数比较
$this->assertEqualsWithDelta(3.14, $result, 0.01);
异常测试
public function testThrowsOnInvalidInput(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('must be positive');
$calculator->divide(1, 0);
}
- 在会抛出异常的代码之前调用
expectException()——它会设置预期。 - 异常类型较宽泛时,使用
expectExceptionMessage()——验证消息中包含指定子字符串。 - 使用
expectExceptionMessageMatches()进行正则表达式匹配。
弃用 / 错误预期
public function testTriggersDeprecation(): void
{
$this->expectUserDeprecationMessage('use newMethod() instead');
$service->oldMethod();
}
完整的断言目录、约束系统和格式字符串断言,请参阅 ${CLAUDE_SKILL_DIR}/references/assertions.md。
模拟
存根与模拟对象
- 存根(
createStub())——控制返回值,不验证调用。 - 模拟对象(
createMock())——验证交互(是否调用方法、参数是否匹配)。
默认使用存根。只有在需要验证副作用是否发生时才使用模拟对象。
创建存根
$repo = $this->createStub(UserRepository::class);
$repo->method('find')->willReturn(new User(name: 'Alice'));
$service = new UserService($repo);
$result = $service->getUser(1);
$this->assertSame('Alice', $result->name);
多个方法的简写:
$repo = $this->createConfiguredStub(UserRepository::class, [
'find' => new User(name: 'Alice'),
'exists' => true,
]);
创建模拟对象
$logger = $this->createMock(Logger::class);
$logger->expects($this->once())
->method('error')
->with($this->stringContains('payment failed'));
$service = new PaymentService($logger);
$service->process($invalidPayment);
返回值配置
$stub->method('fetch')->willReturn('value'); // Fixed value
$stub->method('fetch')->willReturn('a', 'b', 'c'); // Consecutive values
$stub->method('fetch')->willReturnArgument(0); // Return first arg
$stub->method('fetch')->willReturnSelf(); // Fluent interface
$stub->method('fetch')->willReturnCallback(fn ($id) => "item-{$id}");
$stub->method('fetch')->willThrowException(new RuntimeException('fail'));
$stub->method('fetch')->willReturnMap([
['key1', 'value1'],
['key2', 'value2'],
]);
模拟规则
- 在边界处使用模拟。 模拟外部服务、数据库、文件系统、时钟,不要模拟内部函数。
- 当有伪实现或内存实现可用时,不要模拟自己编写的代码。
- 优先使用依赖注入,而不是复杂的模拟设置。 将协作者作为构造函数参数传入,并在测试中为其设置存根。
- 绝不要模拟正在测试的对象。 如果需要模拟 SUT 的一部分,说明 SUT 承担了过多职责——应将其拆分。
- 测试替身优先使用接口而非类——限制更少,设计也更好。
- 不要对存根调用
expects()——该方法在 11 中已弃用,在 12 中会报错。
有关 MockBuilder、交叉类型、调用匹配器和 PHP 8.4 属性钩子的更多信息,请参阅 ${CLAUDE_SKILL_DIR}/references/mocking.md。
属性
PHPUnit 11 仅使用 PHP 8 属性。所有属性均位于 PHPUnit\Framework\Attributes 命名空间中。
测试元数据
#[Test]——将非test*方法标记为测试#[DataProvider('method')]——关联数据提供器#[DataProviderExternal(Class::class, 'method')]——关联外部数据提供器#[TestWith([args])]——内联数据提供器#[TestDox('description')]——自定义 TestDox 描述#[Depends('testMethod')]——声明测试依赖项#[Group('name')]——分配到指定组#[Ticket('PROJ-123')]——关联问题跟踪器中的问题
跳过 / 条件判断
#[RequiresPhp('>= 8.4')]——PHP 版本不匹配时跳过#[RequiresPhpExtension('pdo_pgsql')]——缺少扩展时跳过#[RequiresOperatingSystemFamily('Linux')]——在其他操作系统上跳过#[RequiresFunction('sodium_crypto_sign')]——缺少函数时跳过#[RequiresMethod(PDO::class, 'sqliteCreateFunction')]——缺少方法时跳过
覆盖率
#[CoversClass(ClassName::class)]——测试覆盖此类#[CoversFunction('functionName')]——测试覆盖此函数#[CoversMethod(ClassName::class, 'method')]——测试覆盖此方法#[CoversNothing]——测试不计入覆盖率(集成测试)#[UsesClass(ClassName::class)]——允许使用但不计入覆盖率的依赖项#[UsesFunction('functionName')]——允许使用但不计入覆盖率的函数
Fixture
#[Before]— 在每项测试前运行方法(setUp的替代方案)#[After]— 在每项测试后运行方法(tearDown的替代方案)#[BeforeClass]— 在首项测试前运行静态方法#[AfterClass]— 在最后一项测试后运行静态方法#[BackupGlobals(true)]— 为此测试备份/恢复全局变量#[BackupStaticProperties(true)]— 备份/恢复静态属性
测试行为
#[DoesNotPerformAssertions]— 抑制有风险测试警告#[RunInSeparateProcess]— 在独立的 PHP 进程中隔离运行#[RunTestsInSeparateProcesses]— 类中的所有测试均在独立进程中隔离运行#[Small]/#[Medium]/#[Large]— 执行时间限制(1s/10s/60s)
测试组织
目录结构
tests/
├── Unit/ # Fast, isolated, no I/O
│ ├── Service/
│ │ └── PaymentServiceTest.php
│ └── Model/
│ └── UserTest.php
├── Integration/ # Real dependencies, slower
│ └── Repository/
│ └── UserRepositoryTest.php
└── bootstrap.php # Autoloader for tests
- 在
tests/Unit/和tests/Integration/下镜像源目录结构。 - 单元测试 — 不使用数据库、文件系统或网络。模拟所有边界。
- 集成测试 — 使用真实依赖项。使用
#[CoversNothing]标记,避免污染覆盖率指标。
测试套件
在 phpunit.xml 中定义,以便选择性执行:
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
运行子集:phpunit --testsuite unit、phpunit --group slow。
配置
推荐的 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/11.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,random"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
failOnWarning="true"
failOnRisky="true"
failOnDeprecation="true"
failOnNotice="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source restrictDeprecations="true"
restrictNotices="true"
restrictWarnings="true">
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>
关键配置选项
executionOrder="depends,random"— 随机调整测试顺序以发现隐藏依赖,同时遵循显式的#[Depends]。beStrictAboutTestsThatDoNotTestAnything="true"— 将没有断言的测试标记为有风险。failOnDeprecation="true"— 尽早发现代码中的弃用问题。<source>与restrictDeprecations— 仅显示代码中的问题,而非供应商依赖项中的问题。cacheDirectory— 将.phpunit.cache添加到.gitignore。
代码覆盖率
需要 PCOV 或 Xdebug 扩展:
phpunit --coverage-html build/coverage --coverage-clover build/clover.xml
使用 #[CoversClass] 和 #[UsesClass] 属性精确指定覆盖目标。启用
beStrictAboutCoverageMetadata="true" 后,不带覆盖率属性的测试会被视为有风险。
完整的 XML 参考、覆盖率报告类型和执行顺序选项,请参阅 ${CLAUDE_SKILL_DIR}/references/configuration.md。
应用
编写测试时:默默应用所有约定,不要逐条讲述遵循了哪些规则。遵循项目现有的测试风格。如果现有代码库与某项约定冲突,请遵循代码库,并指出一次差异。
审查测试时:指出具体问题,并直接展示修复方式。不要说教——说明问题所在以及如何修复。
Bad: "According to PHPUnit best practices, you should use createStub
instead of createMock when you don't need expectations..."
Good: "createMock → createStub (no expects() call, stub is sufficient)"
集成
php 技能决定语言选择;本技能决定 PHPUnit 测试相关决策。coding 技能决定工作流(探索、规划、验证)。
测试行为,而非实现。拿不准时,少用模拟。