
PHP程序员入门单元测试:用 PHPUnit 给旧代码加上保障
接手 PHP 旧项目时,最让人犹豫的往往不是“怎么重构”,而是“改完会不会把别的功能弄坏”。代码可能没有测试,业务规则散落在控制器、全局函数和数据库操作里,文档也未必跟得上。这时,单元测试的首要价值不是追求覆盖率,而是把已经依赖的行为记录下来:先验证现状,再小步修改,减少回归风险。
本文以 PHPUnit 为例,说明哪些场景适合写单元测试、如何在项目中安装和运行测试,以及如何处理旧代码的依赖与边界。示例要求 PHP 8.1 或更高版本,并使用 PHPUnit 10.5;如果项目的 PHP 版本更旧,应选择与项目 PHP 版本兼容的 PHPUnit 版本,不能直接照搬这里的 Composer 约束。
一、单元测试适合解决什么问题
单元测试通常针对一个较小的代码单元,给定输入后检查输出或可观察行为。比如验证折扣金额是否被正确限制、日期范围是否按业务规则计算、某种状态转换是否正确。测试运行时尽量不访问真实数据库、网络、文件系统或外部服务,因而通常运行较快,也更容易定位失败原因。
单元测试特别适合以下情况:
- 规则明确且容易重复验证:例如价格计算、输入归一化、权限判断中的纯业务规则。
- 代码改动频繁:为常被修改的模块建立测试,可以更早发现意外改变。
- 旧行为有调用方依赖:即使行为看起来不够理想,也可以先用测试记录,再决定是否单独调整。
- 故障难以手工复现:将边界条件固定在测试中,便于后续重复检查。
但不是所有问题都能靠单元测试解决。单元测试不能证明整个应用在真实服务器、数据库和代理配置下都工作正常;它也不能代替集成测试、端到端测试、代码审查或安全测试。一个方法的模拟测试通过,不代表数据库事务、SQL 约束、HTTP 配置或第三方接口就一定正确。实际项目通常需要不同层次的测试共同覆盖。
二、先选一块边界清楚的代码
为旧项目补测试,最好从输入输出清晰、外部依赖少的代码开始,而不是一上来就测试整个控制器或应用启动流程。下面定义一个订单金额计算器:金额以“分”为单位用整数表示;折扣不能为负,也不能超过小计。这里的规则只是示例,真实项目必须以自身业务约定为准。
项目结构可以先保持简单:
project/
├── composer.json
├── phpunit.xml
├── src/
│ └── OrderTotal.php
└── tests/
└── OrderTotalTest.php
在 composer.json 中声明自动加载和开发依赖:
{
"require": {
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
}
}
在项目根目录运行 composer install 安装依赖。首次加入自动加载配置后,再执行:
composer dump-autoload
依赖安装失败时,先检查当前 PHP 版本和已启用的扩展是否满足所选 PHPUnit 版本的要求。不要为了让测试框架安装成功,就未经评估地升级线上 PHP 或修改项目运行环境。
接下来添加实现文件 src/OrderTotal.php:
<?php
declare(strict_types=1);
namespace App;
final class OrderTotal
{
public function calculate(int $subtotalCents, int $discountCents): int
{
if ($subtotalCents < 0) {
throw new \InvalidArgumentException('Subtotal cannot be negative.');
}
if ($discountCents < 0) {
throw new \InvalidArgumentException('Discount cannot be negative.');
}
if ($discountCents > $subtotalCents) {
throw new \InvalidArgumentException('Discount cannot exceed subtotal.');
}
return $subtotalCents - $discountCents;
}
}
这里用整数分数避免了示例中的浮点金额运算;这并不表示所有货币场景只要用整数就足够。涉及汇率、税率、不同货币精度或分摊规则时,仍需根据业务定义选用明确的精度和舍入规则。
三、写出第一组 PHPUnit 测试
添加 tests/OrderTotalTest.php:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\OrderTotal;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
final class OrderTotalTest extends TestCase
{
public function testCalculatesTotalAfterDiscount(): void
{
$calculator = new OrderTotal();
self::assertSame(850, $calculator->calculate(1000, 150));
}
public function testAllowsZeroDiscount(): void
{
$calculator = new OrderTotal();
self::assertSame(1000, $calculator->calculate(1000, 0));
}
public function testAllowsDiscountEqualToSubtotal(): void
{
$calculator = new OrderTotal();
self::assertSame(0, $calculator->calculate(1000, 1000));
}
public function testRejectsNegativeSubtotal(): void
{
$calculator = new OrderTotal();
$this->expectException(InvalidArgumentException::class);
$calculator->calculate(-1, 0);
}
public function testRejectsDiscountGreaterThanSubtotal(): void
{
$calculator = new OrderTotal();
$this->expectException(InvalidArgumentException::class);
$calculator->calculate(1000, 1001);
}
}
测试类继承 PHPUnit\Framework\TestCase。方法名以 test 开头时,PHPUnit 会将其识别为测试。assertSame() 会同时检查值和类型,所以整数 850 与字符串 "850" 不会被当成相同结果。这种严格检查有助于及时发现类型变化,但断言应符合待测代码的实际契约。
为 PHPUnit 指定测试目录,创建 phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Application tests">
<directory>tests</directory>
</testsuite>
</testsuites>
</phpunit>
运行测试:
vendor/bin/phpunit
也可以在 Composer 中加一个脚本,之后通过 composer test 执行:
{
"scripts": {
"test": "phpunit"
}
}
修改 composer.json 后,重新执行 composer dump-autoload,再运行 composer test。如果测试没有被发现,优先核对文件名、测试目录、命名空间和自动加载配置;如果运行时报类找不到,也要确认新加的类是否位于 PSR-4 映射对应的目录。
四、给旧代码加测试:先记录现状,再改变设计
真实旧代码往往不是上面的整洁类,可能是静态方法、全局变量和数据库查询混在一起。不要把“先重构得很漂亮”当成开始测试的前提。更稳妥的做法是先找一个可以观察的行为,编写能固定该行为的测试,然后再逐步拆分依赖。
这里有一个重要边界:测试旧代码时,测试不一定是在确认“设计理想”。它首先确认的是当前调用方能够观察到的行为。比如旧系统把空字符串当成默认值,虽然未必是好的接口,也可能已经被其他模块依赖。记录这种行为之后,可以在需求明确时修改,并同步更新测试;不要在不知情的情况下把“清理代码”和“改业务规则”混成一次变更。
如果旧代码有副作用,可以按风险由低到高推进:
- 先选不访问外部资源的分支,例如格式转换或规则判断。
- 把计算逻辑从控制器、命令行脚本或数据库回调中提取到独立方法或类。
- 将数据库、时钟、邮件发送器等依赖改为显式传入,或先加一层薄的适配接口。
- 在测试中替换外部依赖,验证调用参数与结果;再用少量集成测试验证真实适配器。
测试不是把旧代码里的每个私有方法都单独测一遍。通常应从公共方法或对调用方可见的行为入手。过度依赖私有方法的测试,会让内部实现稍一调整就大量失败,尽管用户能看到的行为并没有变化。
五、参数化测试与边界值
上面的测试分别覆盖正常输入、允许的边界和非法输入。类似情况还可以用数据提供器减少重复。PHPUnit 10 中可用 #[\PHPUnit\Framework\Attributes\DataProvider] 标注数据提供器。将下列方法加入测试类即可:
#[\PHPUnit\Framework\Attributes\DataProvider('validCases')]
public function testCalculatesExpectedTotal(
int $subtotalCents,
int $discountCents,
int $expectedCents
): void {
$calculator = new OrderTotal();
self::assertSame(
$expectedCents,
$calculator->calculate($subtotalCents, $discountCents)
);
}
public static function validCases(): array
{
return [
'no discount' => [1000, 0, 1000],
'partial discount' => [1000, 150, 850],
'full discount' => [1000, 1000, 0],
'zero subtotal' => [0, 0, 0],
];
}
写边界测试时,先从规则推导边界,而不是机械地凑测试数量。这个例子至少要考虑负小计、负折扣、折扣等于小计、折扣略大于小计,以及零值。其他项目常见的边界包括空数组、空字符串、最大分页大小、时区切换、重复请求和刚好过期的时间点。
若用数据提供器测试异常场景,也要确认异常是由待测代码按预期抛出的,而不是在测试准备阶段就提前发生。错误的断言位置、错误的类型或只断言“抛出任意异常”,都可能让无关错误被误认为业务规则符合预期。
六、如何处理数据库、时间和其他外部依赖
旧代码中常见的难点,是业务方法直接调用数据库、读取当前时间、发送邮件或使用全局配置。可以先识别哪些部分是决定规则的核心,哪些部分只是取得数据或执行副作用,再把边界拆开。
例如,“订单是否已过期”可以只接收订单过期时间和当前时间,作为纯计算测试;“当前时间怎么取得”则由应用层提供。这样测试无需真的等待时间流逝,也不依赖运行机器的时区。但在生产环境中,仍要明确数据库、服务器和业务使用的时区约定,单元测试不会自动替你统一这些设置。
对数据库交互,单元测试可以替换数据访问接口,检查代码是否传入正确条件,或者是否在失败时采取预期处理。不过,替身对象不能完整模拟真实数据库的 SQL 行为、事务隔离、唯一约束、字符集和索引。涉及这些行为时,应通过针对测试数据库的集成测试验证,并确保测试不会误连生产环境。
对邮件、支付、短信等外部服务,单元测试适合验证业务代码是否请求了正确操作,通常不应该在每次测试时发送真实邮件或发起真实扣款。服务适配层和外部系统之间的格式、认证与网络行为,需要在受控环境中单独验证。不要把一个模拟对象上“方法被调用”误解成外部服务已经成功执行。
七、常见错误与改进方法
- 只测“正常路径”:忽略空值、零值、上限、无效状态等,容易让真正的回归漏网。根据业务约束列出边界,并为重要失败行为写测试。
- 断言过于宽泛:只检查“没有抛异常”或“结果不是空”,可能掩盖错误结果。尽量断言具体值、类型或明确的可观察行为。
- 一个测试做太多事:同一测试同时创建数据、访问数据库、调用多个服务并验证许多结果,失败时难以判断原因。按行为拆分,让每个测试有清晰目的。
- 测试依赖执行顺序:测试之间共享可变状态,会导致单独运行通过、全量运行失败。每个测试应自行准备所需状态,并在结束时清理可能产生的资源。
- 用模拟对象模拟所有东西:模拟越多,测试越可能只验证模拟的配置,而不是实际业务。优先测试纯逻辑;只有在隔离外部边界时才使用替身,并用集成测试补上真实交互验证。
- 为了通过测试改掉测试本身:如果行为变化是有意的,应先确认需求,再更新断言;如果是不小心的回归,就修实现。不要只为消除红色状态而删除失败用例。
- 把覆盖率当成质量结论:覆盖率表示执行过哪些代码路径,不能说明断言是否有效,也不能证明需求完整。覆盖率可用于发现完全没测到的区域,但不应单独作为质量保证。
- 测试依赖真实环境且不可重复:当前时间、随机数、文件路径和网络状态都会让结果不稳定。能注入的依赖尽量显式注入,并让测试数据确定、可重复。
八、参数限制与安全用途的边界
测试代码能够检查程序是否按定义处理特定输入,但它本身不会替程序做输入校验。PHP 的类型声明可以约束部分调用方式,却不等于完整的业务验证;更不等于对用户输入进行了安全处理。请求参数仍需在应用边界按类型、范围、格式和业务规则校验,并以符合上下文的方式编码或绑定。
以整数金额为例,类型声明会限制传入值为整数,但它不会替你决定允许的最大金额,也不会验证用户是否有权创建该金额的订单。业务上限、货币精度、权限和并发约束必须由系统明确制定并在相应层实现。本文的金额计算类只展示一种规则,不应被当成支付、税务或财务系统的通用实现。
同样,单元测试不是安全证明。即使输入验证相关测试全部通过,也无法由此得出应用没有 SQL 注入、跨站脚本、越权访问、信息泄露或竞态问题。安全控制要落实在正确的实现位置,例如使用参数化查询、按上下文进行输出编码、执行服务端授权检查,并针对风险开展代码审查、依赖检查和安全测试。对重要控制可以写回归测试,但不能把“测试通过”表述为“绝对安全”。
测试覆盖哪些参数,也应由接口契约决定,而不是凭空设定所有输入的统一限制。像字符串长度、文件大小、分页上限和请求频率这类限制,需要根据业务需求、运行资源和接口设计明确规定;没有依据时,不应在测试或示例中宣称某个数字是行业标准或系统安全上限。
九、从少量测试形成稳定习惯
刚开始补测试,不必追求一次覆盖整个项目。每次修复缺陷时,可以先写一个能复现缺陷的测试,再修复实现;每次修改高风险规则时,补充对应边界案例。这样测试逐步积累,并与实际问题保持关联,比为了达到某个覆盖率数字而大量编写低价值断言更有用。
在持续集成中,可以让 PHPUnit 在每次提交或合并请求时运行。测试失败时先看失败用例、输入数据和错误信息,再判断是代码回归、测试环境问题还是测试本身不稳定。对于旧项目,第一阶段的成功标准可以很务实:关键规则有回归保护,测试能在开发机和 CI 中重复运行,失败原因足够清晰。
给旧代码加单元测试,核心不是让每个类都变成“完美可测”,而是建立一条可靠的修改反馈回路:识别调用方依赖的行为,隔离可控的业务逻辑,覆盖重要边界,并承认测试无法替代真实集成验证和安全审查。从一段最容易验证的逻辑开始,通常比一次性改造整套系统更安全,也更容易持续。



