PHP单元测试实战指南:从入门到CI集成
目录导读
为什么需要单元测试
许多PHP开发者认为测试只是锦上添花,但实际上,没有测试的代码库就像没有安全网的高空杂技,单元测试通过验证函数、类和方法的行为,确保每次代码变更都不会破坏已有功能,在电商、金融等场景中,一次未捕获的bug可能导致直接经济损失。

核心收益:
- 快速定位回归错误
- 降低重构风险
- 形成可执行的文档
- 提升代码质量
环境搭建与工具选择
推荐工具栈
- PHPUnit(官方标准,支持数据提供者、mock对象)
- Mockery(灵活mock框架,适合复杂依赖)
- Codeception(兼容验收测试,适合全栈测试)
安装步骤(Composer)
composer require --dev phpunit/phpunit mockery/mockery
目录结构建议
project/
├── tests/
│ ├── Unit/
│ │ └── UserServiceTest.php
│ └── bootstrap.php
├── src/
│ └── Services/
│ └── UserService.php
├── phpunit.xml
└── composer.json
配置文件(phpunit.xml)
<phpunit bootstrap="tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>
编写第一个测试用例
目标:测试UserService::createUser方法
被测试类:
// src/Services/UserService.php
class UserService {
public function __construct(private Mailer $mailer) {}
public function createUser(array $data): User {
if (empty($data['email'])) {
throw new \InvalidArgumentException('Email required');
}
$user = new User($data);
$this->mailer->sendWelcome($user);
return $user;
}
}
测试类:
// tests/Unit/UserServiceTest.php
use PHPUnit\Framework\TestCase;
use Mockery as m;
class UserServiceTest extends TestCase {
protected function tearDown(): void {
m::close();
}
public function testCreateUserWithValidDataReturnsUser() {
$mailerMock = m::mock(Mailer::class);
$mailerMock->shouldReceive('sendWelcome')->once();
$service = new UserService($mailerMock);
$data = ['email' => 'test@example.com', 'name' => '张三'];
$result = $service->createUser($data);
$this->assertInstanceOf(User::class, $result);
$this->assertEquals('test@example.com', $result->email);
}
public function testCreateUserWithoutEmailThrowsException() {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Email required');
$mailerMock = m::mock(Mailer::class);
$service = new UserService($mailerMock);
$service->createUser([]);
}
}
运行测试:
./vendor/bin/phpunit
进阶测试模式
数据提供者(重复测试不同输入)
/** @dataProvider userProvider */
public function testCreateUserWithDifferentNames($name, $expected) {
// ...
}
public function userProvider(): array {
return [
['张三', 3], // 中文名长度?
['John', 4],
['', 0],
];
}
测试依赖(顺序执行)
public function testEmptyArray() {
$this->assertEmpty([]);
return [];
}
/** @depends testEmptyArray */
public function testPush(array $array) {
array_push($array, 'item');
$this->assertCount(1, $array);
return $array;
}
Mock外部HTTP请求
$httpClient = m::mock(GuzzleHttp\Client::class);
$httpClient->shouldReceive('get')
->with('https://api.example.com/users')
->andReturn(new Response(200, [], json_encode(['id' => 1])));
常见问题问答
Q1:单元测试应该覆盖哪些方法?
A:优先覆盖核心业务逻辑(如订单计算、权限验证)、边界条件(空值、最大长度)、异常路径,基础getter/setter可以不测,除非包含业务逻辑。
Q2:测试数据库调用怎么办?
A:避免真实数据库,使用内存SQLite或测试容器(如Testcontainers for PHP),方法包括:
- 事务回滚(测试后自动回滚)
- Mock数据库连接
- 使用Repository模式并mock Repository
Q3:测试私有或保护方法吗?
A:通常不直接测试,两种变通方案:
- 通过公有方法间接测试(更推荐)
- 使用反射工具(不推荐,破坏封装)
Q4:测试覆盖率多少才算合格?
A:建议核心模块达到80%以上,但不追求100%(如配置加载、简单委托方法),使用phpunit --coverage-html生成可视化报告。
Q5:测试运行太慢怎么办?
A:- 将慢速测试(如集成测试)标记为@group slow并使用--exclude-group跳过
- 使用并行测试工具(Paratest)
- 优先优化频繁执行的单元测试
持续集成中的测试集成
GitHub Actions配置示例
name: PHP Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: phpunit
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run tests
run: phpunit --coverage-text --coverage-clover=coverage.xml
测试金字塔实践
- 单元测试:数量最多,速度快,占总测试的70%
- 集成测试:测试模块间协作,20%
- E2E测试:关键业务流程,10%
通过构建完善的单元测试体系,你的PHP项目将获得更高的稳定性与可维护性,测试不是额外负担,而是对代码未来的投资,从今天开始,为你的核心类编写第一个通过/失败的测试吧!