PHPUnit 单元测试
概述
PHPUnit 是 PHP 生态中最广泛使用的单元测试框架,由 Sebastian Bergmann 创建和维护。它提供了一套完整的测试基础设施,包括断言库、测试运行器、代码覆盖率和模拟对象生成等功能。PHPUnit 遵循 xUnit 架构模式,是保障 PHP 代码质量、实现持续集成不可或缺的工具。
PHP 版本要求
PHPUnit 10.x 支持 PHP 8.1+。PHPUnit 11.x 支持 PHP 8.3+。本文基于 PHPUnit 10.x 和 PHP 8.1+ 编写。
基础概念
什么是单元测试
单元测试是对软件中最小可测试单元(通常是函数或方法)进行验证的过程。每个测试用例(Test Case)验证一个特定的行为或功能是否符合预期。
测试的 AAA 模式
单元测试通常遵循 Arrange-Act-Assert(准备-执行-断言)模式:
- Arrange(准备):设置测试数据和环境
- Act(执行):调用被测试的方法
- Assert(断言):验证结果是否符合预期
安装与配置
安装 PHPUnit
bash
# 通过 Composer 安装(推荐)
composer require --dev phpunit/phpunit:^10.0
# 安装到全局
composer global require phpunit/phpunit:^10.0
# 验证
./vendor/bin/phpunit --versionphpunit.xml 配置
xml
<!-- 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.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
cacheDirectory=".phpunit.cache">
<!-- 测试源文件目录 -->
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<!-- 源代码目录(覆盖率统计用) -->
<source>
<include>
<directory>src</directory>
</include>
<exclude>
<directory>src/Console</directory>
<file>src/Kernel.php</file>
</exclude>
</source>
<!-- 代码覆盖率过滤 -->
<coverage>
<report>
<html outputDirectory="coverage/html"/>
<text outputFile="coverage/coverage.txt"/>
</report>
<include>
<directory suffix=".php">src</directory>
</include>
</coverage>
</phpunit>详细说明
TestCase 基础
基本测试结构
php
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
/**
* Calculator 单元测试
*/
class CalculatorTest extends TestCase
{
private Calculator $calculator;
/**
* 每个测试方法执行前调用
*/
protected function setUp(): void
{
$this->calculator = new Calculator();
}
/**
* 每个测试方法执行后调用
*/
protected function tearDown(): void
{
unset($this->calculator);
}
/**
* 测试加法
*/
public function testAdd(): void
{
// Arrange
$a = 5;
$b = 3;
// Act
$result = $this->calculator->add($a, $b);
// Assert
$this->assertSame(8, $result);
}
/**
* 测试除法
*/
public function testDivide(): void
{
$result = $this->calculator->divide(10, 2);
$this->assertSame(5.0, $result);
}
/**
* 测试除以零异常
*/
public function testDivideByZeroThrowsException(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('除数不能为零');
$this->calculator->divide(10, 0);
}
/**
* 测试浮点数精度
*/
public function testFloatPrecision(): void
{
$result = $this->calculator->divide(1, 3);
$this->assertEqualsWithDelta(0.333, $result, 0.001);
}
}断言方法大全
php
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class AssertionsDemoTest extends TestCase
{
// === 值比较断言 ===
public function testAssertSame(): void
{
$value = 42;
$this->assertSame(42, $value); // 严格比较(===)
}
public function testAssertEquals(): void
{
$value = 42;
$this->assertEquals('42', $value); // 宽松比较(==)
}
public function testAssertTrue(): void
{
$this->assertTrue(true);
}
public function testAssertFalse(): void
{
$this->assertFalse(false);
}
public function testAssertNull(): void
{
$this->assertNull(null);
}
public function testAssertNotNull(): void
{
$this->assertNotNull('value');
}
public function testAssertEmpty(): void
{
$this->assertEmpty([]);
$this->assertEmpty('');
$this->assertEmpty(0);
}
public function testAssertNotEmpty(): void
{
$this->assertNotEmpty([1, 2, 3]);
}
// === 类型断言 ===
public function testAssertIsInt(): void
{
$this->assertIsInt(42);
}
public function testAssertIsString(): void
{
$this->assertIsString('hello');
}
public function testAssertIsArray(): void
{
$this->assertIsArray([1, 2, 3]);
}
public function testAssertIsBool(): void
{
$this->assertIsBool(true);
}
public function testAssertIsFloat(): void
{
$this->assertIsFloat(3.14);
}
public function testAssertIsObject(): void
{
$this->assertIsObject(new \stdClass());
}
public function testAssertIsCallable(): void
{
$this->assertIsCallable('strlen');
}
// === 数组断言 ===
public function testAssertContains(): void
{
$this->assertContains(3, [1, 2, 3, 4]);
}
public function testAssertArrayHasKey(): void
{
$this->assertArrayHasKey('name', ['name' => 'Alice']);
}
public function testAssertCount(): void
{
$this->assertCount(3, [1, 2, 3]);
}
// === 字符串断言 ===
public function testAssertStringContainsString(): void
{
$this->assertStringContainsString('world', 'hello world');
}
public function testAssertStringStartsWith(): void
{
$this->assertStringStartsWith('hello', 'hello world');
}
public function testAssertStringEndsWith(): void
{
$this->assertStringEndsWith('world', 'hello world');
}
public function testAssertMatchesRegularExpression(): void
{
$this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', '2024-01-15');
}
public function testAssertStringEqualsFile(): void
{
$this->assertStringEqualsFile(__DIR__ . '/fixtures/expected.txt', 'expected content');
}
// === 异常断言 ===
public function testExpectException(): void
{
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('操作失败');
$this->expectExceptionCode(1001);
throw new \RuntimeException('操作失败', 1001);
}
// === 文件系统断言 ===
public function testAssertFileExists(): void
{
$this->assertFileExists(__FILE__);
}
public function testAssertFileIsReadable(): void
{
$this->assertFileIsReadable(__FILE__);
}
public function testAssertDirectoryExists(): void
{
$this->assertDirectoryExists(__DIR__);
}
// === 数值比较 ===
public function testAssertGreaterThan(): void
{
$this->assertGreaterThan(10, 20);
}
public function testAssertLessThan(): void
{
$this->assertLessThan(20, 10);
}
public function testAssertGreaterThanOrEqual(): void
{
$this->assertGreaterThanOrEqual(10, 10);
}
public function testAssertEqualsWithDelta(): void
{
// 浮点数比较
$this->assertEqualsWithDelta(3.14159, M_PI, 0.001);
}
}数据提供器(Data Providers)
php
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class DataProviderTest extends TestCase
{
/**
* 测试数据提供器:验证邮箱格式
*
* @dataProvider validEmailProvider
*/
public function testValidEmail(string $email): void
{
$validator = new EmailValidator();
$this->assertTrue($validator->isValid($email));
}
/**
* 有效邮箱数据
*/
public static function validEmailProvider(): array
{
return [
'basic' => ['user@example.com'],
'with dots' => ['first.last@example.com'],
'with plus' => ['user+tag@example.com'],
'with numbers' => ['user123@example.com'],
'subdomain' => ['user@mail.example.com'],
];
}
/**
* 测试数据提供器:验证无效邮箱
*
* @dataProvider invalidEmailProvider
*/
public function testInvalidEmail(string $email): void
{
$validator = new EmailValidator();
$this->assertFalse($validator->isValid($email));
}
/**
* 无效邮箱数据
*/
public static function invalidEmailProvider(): array
{
return [
'missing @' => ['userexample.com'],
'missing domain' => ['user@'],
'double @' => ['user@@example.com'],
'space' => ['user @example.com'],
'empty' => [''],
];
}
/**
* 数学运算数据提供器
*
* @dataProvider additionProvider
*/
public function testAdd(int $a, int $b, int $expected): void
{
$calc = new Calculator();
$this->assertSame($expected, $calc->add($a, $b));
}
public static function additionProvider(): array
{
return [
'positive numbers' => [2, 3, 5],
'with zero' => [0, 5, 5],
'negative numbers' => [-2, -3, -5],
'mixed signs' => [-2, 5, 3],
'large numbers' => [1000000, 2000000, 3000000],
];
}
}测试生命周期
php
<?php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
class LifecycleTest extends TestCase
{
/**
* 类级别:在整个测试类之前执行一次
*/
public static function setUpBeforeClass(): void
{
echo "setUpBeforeClass: 初始化共享资源" . PHP_EOL;
// 例如:创建测试数据库连接
}
/**
* 类级别:在整个测试类之后执行一次
*/
public static function tearDownAfterClass(): void
{
echo "tearDownAfterClass: 清理共享资源" . PHP_EOL;
// 例如:关闭数据库连接
}
/**
* 方法级别:每个测试方法之前执行
*/
protected function setUp(): void
{
parent::setUp();
echo "setUp: 准备测试环境" . PHP_EOL;
// 例如:创建被测试对象
}
/**
* 方法级别:每个测试方法之后执行
*/
protected function tearDown(): void
{
echo "tearDown: 清理测试环境" . PHP_EOL;
parent::tearDown();
// 例如:释放资源
}
/**
* 在每个断言之前执行(PHPUnit 10+)
*/
protected function assertPreConditions(): void
{
echo "assertPreConditions: 验证前置条件" . PHP_EOL;
// 例如:确保环境变量已设置
}
/**
* 在每个断言之后执行(PHPUnit 10+)
*/
protected function assertPostConditions(): void
{
echo "assertPostConditions: 验证后置条件" . PHP_EOL;
}
public function testOne(): void
{
$this->assertTrue(true);
}
public function testTwo(): void
{
$this->assertSame(42, 42);
}
}组织测试
测试目录结构
tests/
├── Unit/ # 单元测试
│ ├── Services/
│ │ ├── UserServiceTest.php
│ │ └── PaymentServiceTest.php
│ ├── Models/
│ │ └── UserTest.php
│ └── ValueObjects/
│ └── EmailTest.php
├── Integration/ # 集成测试
│ ├── Repository/
│ │ └── UserRepositoryTest.php
│ └── Http/
│ └── ApiEndpointTest.php
├── Feature/ # 功能测试
│ └── UserRegistrationTest.php
├── Fixtures/ # 测试夹具
│ ├── data/
│ │ └── users.csv
│ └── expected/
│ └── output.json
├── bootstrap.php # 测试引导文件
└── TestCase.php # 自定义基类自定义基类 TestCase
php
<?php
declare(strict_types=1);
namespace Tests;
use PHPUnit\Framework\TestCase;
abstract class TestCase extends TestCase
{
/**
* 创建一个模拟的 PDO 连接
*/
protected function createMockPdo(): \PDO
{
return $this->createMock(\PDO::class);
}
/**
* 设置私有属性
*/
protected function setPrivateProperty(
object $object,
string $property,
mixed $value
): void {
$reflection = new \ReflectionProperty($object, $property);
$reflection->setAccessible(true);
$reflection->setValue($object, $value);
}
/**
* 获取私有属性
*/
protected function getPrivateProperty(
object $object,
string $property
): mixed {
$reflection = new \ReflectionProperty($object, $property);
$reflection->setAccessible(true);
return $reflection->getValue($object);
}
/**
* 调用私有方法
*/
protected function invokePrivateMethod(
object $object,
string $method,
array $args = []
): mixed {
$reflection = new \ReflectionMethod($object, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($object, $args);
}
}实战示例
被测类:UserService
php
<?php
declare(strict_types=1);
namespace App\Services;
class UserService
{
public function __construct(
private UserRepositoryInterface $repository,
private EmailValidatorInterface $emailValidator,
) {
}
public function createUser(string $name, string $email): User
{
if (empty($name) || strlen($name) < 2) {
throw new \InvalidArgumentException('用户名至少2个字符');
}
if (!$this->emailValidator->isValid($email)) {
throw new \InvalidArgumentException('无效的邮箱地址');
}
$existing = $this->repository->findByEmail($email);
if ($existing !== null) {
throw new \RuntimeException('邮箱已被注册');
}
$user = new User($name, $email);
$this->repository->save($user);
return $user;
}
public function getUser(int $id): ?User
{
return $this->repository->find($id);
}
public function deactivateUser(int $id): void
{
$user = $this->repository->find($id);
if ($user === null) {
throw new \RuntimeException('用户不存在');
}
$user->deactivate();
$this->repository->save($user);
}
}测试类
php
<?php
declare(strict_types=1);
namespace Tests\Unit\Services;
use App\Services\UserService;
use App\Models\User;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class UserServiceTest extends TestCase
{
private UserService $service;
private MockObject $repository;
private MockObject $emailValidator;
protected function setUp(): void
{
$this->repository = $this->createMock(
\App\Repositories\UserRepositoryInterface::class
);
$this->emailValidator = $this->createMock(
\App\Validators\EmailValidatorInterface::class
);
$this->service = new UserService(
$this->repository,
$this->emailValidator
);
}
public function testCreateUserSuccess(): void
{
// Arrange
$this->emailValidator->method('isValid')
->with('alice@example.com')
->willReturn(true);
$this->repository->method('findByEmail')
->with('alice@example.com')
->willReturn(null);
$this->repository->expects($this->once())
->method('save')
->with($this->isInstanceOf(User::class));
// Act
$user = $this->service->createUser('Alice', 'alice@example.com');
// Assert
$this->assertInstanceOf(User::class, $user);
$this->assertSame('Alice', $user->getName());
$this->assertSame('alice@example.com', $user->getEmail());
}
public function testCreateUserWithInvalidEmail(): void
{
$this->emailValidator->method('isValid')
->with('invalid-email')
->willReturn(false);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('无效的邮箱地址');
$this->service->createUser('Alice', 'invalid-email');
}
public function testCreateUserWithDuplicateEmail(): void
{
$this->emailValidator->method('isValid')
->willReturn(true);
$existingUser = new User('Bob', 'bob@example.com');
$this->repository->method('findByEmail')
->willReturn($existingUser);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('邮箱已被注册');
$this->service->createUser('Alice', 'bob@example.com');
}
public function testGetUserNotFound(): void
{
$this->repository->method('find')
->with(999)
->willReturn(null);
$result = $this->service->getUser(999);
$this->assertNull($result);
}
}命令行用法
bash
# 运行所有测试
./vendor/bin/phpunit
# 运行指定目录
./vendor/bin/phpunit tests/Unit/
# 运行指定文件
./vendor/bin/phpunit tests/Unit/Services/UserServiceTest.php
# 运行指定方法
./vendor/bin/phpunit --filter testCreateUserSuccess
# 使用过滤器(方法名模式)
./vendor/bin/phpunit --filter 'UserService'
# 运行指定测试套件
./vendor/bin/phpunit --testsuite unit
# 输出到文件
./vendor/bin/phpunit --log-junit test-results.xml
# 递归测试
./vendor/bin/phpunit --recursive tests/
# 停在第一个失败
./vendor/bin/phpunit --stop-on-failure
# 详细输出
./vendor/bin/phpunit --verbose
# 显示未覆盖的文件
./vendor/bin/phpunit --show-uncovered注意事项
测试命名规范
方法名格式: test + 功能描述
例如:
- testCreateUserSuccess
- testCreateUserWithInvalidEmail
- testDivideByZeroThrowsException
- testArraySortPreservesKeys
PHP 8.0+ 可使用 attributes:
#[Test]
public function createUserSuccess(): void { ... }测试隔离
php
<?php
declare(strict_types=1);
// 错误:测试之间有依赖
class BadTest extends TestCase
{
private static int $counter = 0;
public function testIncrement(): void
{
self::$counter++;
$this->assertSame(1, self::$counter); // 依赖执行顺序
}
}
// 正确:每个测试独立
class GoodTest extends TestCase
{
public function testIncrement(): void
{
$counter = 0;
$counter++;
$this->assertSame(1, $counter);
}
}最佳实践
1. FIRST 原则
- Fast(快速):测试应该在毫秒级完成
- Independent(独立):测试之间不应相互依赖
- Repeatable(可重复):每次运行结果应一致
- Self-validating(自验证):测试结果应自动判断(通过/失败)
- Timely(及时):测试应在代码变更后及时编写/更新
2. 测试覆盖率目标
- 关键业务逻辑:80-100%
- 工具类/值对象:100%
- 控制器/中间件:可以较低
- 覆盖率不是唯一目标,测试质量更重要
下一节
继续学习:Mock 对象