一、为什么自建站需要单元测试
草根站长写代码的典型状态是:改一个函数,把整个网站手动点一遍。首页、列表页、详情页、后台登录、发布文章、上传图片……半小时过去了,只验证了三行改动。等站点有几十个文件、几百个页面,这种「人工回归」成本高到可怕,于是很多人干脆不测,改完直接上线,出问题再回滚。
单元测试解决的就是这件事:把「我改的这几行没有破坏其它地方」这件事交给机器去验证,几秒钟跑完,人只需要看最后的绿色或红色。
但要先说清楚边界。单元测试不适合测「页面上那个按钮点下去有没有跳转」,那是浏览器测试的事;它适合测纯逻辑:格式化函数、权限判断、参数校验、金额换算、slug 生成、分页计算、日期处理。这些函数属于「输入确定、输出确定、不依赖网络和数据库」的那一类,最容易写也最有价值。
判断一个函数值不值得写测试,看三个条件:被多处调用、分支多、曾经出过 bug。三条占两条就写。
二、环境准备:composer 与 PHPUnit 的最小组合
PHP 项目用 PHPUnit 起步最省事。不要全局安装,装进项目里,让版本跟着代码走:
mkdir -p tests
composer require --dev phpunit/phpunit ^9.6
# phpunit.xmlPHP 5.6 的老项目要注意版本天花板。PHPUnit 9 需要 PHP 7.3,PHPUnit 8 需要 7.2,PHP 5.6 只能停在 PHPUnit 5.7,功能有限但仍然可用。如果站点的 PHP 版本较老,先评估升级,否则后面所有工具链都会卡住。
配置文件放在项目根目录,写明自动加载和测试目录:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
</phpunit>目录按 layers 拆开,后面加集成测试时不会混在一起:tests/Unit 放纯逻辑,tests/Feature 放需要数据库或 HTTP 的用例。
三、第一个测试:从最容易出错的函数开始写
别从核心业务开始,从那个你最不放心的工具函数开始。假设站点有个生成 slug 的函数,把中文标题转成英文或拼音,历史上出过「带斜杠导致路由 404」的事故:
public static function slugify(string $title): string
{
$s = strtolower(trim($title));
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-');
}对应的测试就是把这个函数的每个「坑」都固化成一条用例:
class SlugifyTest extends TestCase
{
public function testBasic(): void
{
$this->assertSame('hello-world', Slug::slugify('Hello World'));
}
public function testEmptyInput(): void
{
$this->assertSame('', Slug::slugify(' '));
}
public function testNoLeadingDash(): void
{
$this->assertSame('a-b', Slug::slugify('--a b--'));
}
public function testChineseFallsBack(): void
{
$this->assertSame('', Slug::slugify('中文标题'));
}
}最后一条在这版实现下会通过(返回空串),但这个空串恰恰是问题:中文标题生成空 slug,会导致多篇文章抢同一个路由。测试在这里的作用不是「证明代码对」,而是「把错误行为写下来,让下次修改时必须面对它」。发现问题后修实现,测试跟着改成期望拼音输出——这才是测试驱动的小步改进。
四、测试写得像样:命名、数据与断言
初学者最容易把测试写成一大坨:一个方法里塞十几个断言,失败时只告诉你第 42 行挂了。三个习惯能显著提高可读性。
第一,一个测试只测一件事,方法名直接说结论。用 testReturnsEmptyWhenInputIsBlank 而不是 testSlug。失败时看到名字就知道死了什么需求。
第二,固定输入用数据提供器批量展开,不要复制粘贴:
/**
* @dataProvider slugProvider
*/
public function testSlugCases(string $in, string $out): void
{
$this->assertSame($out, Slug::slugify($in));
}
public function slugProvider(): array
{
return [
['Hello World', 'hello-world'],
[' A/B ', 'a-b'],
['Case_123', 'case-123'],
];
}第三,断言要具体。能写 assertSame 就不要写 assertTrue($x == $y),因为前者会告诉你「期望 abc 实际 abd」,后者只说 false。凡是要断数组、对象,用 assertEqualsCanonicalizing 处理顺序不稳定的情况。
五、测试替身:不碰数据库也能测业务
业务函数里最常见的污染源是数据库和外部请求。写单测时用测试替身把这两样替换掉,速度能快两个数量级。
如果代码里直接 new PDO() 或调全局函数,那几乎无法测。改造方向是依赖注入:把「读数据」抽成一个接口,实现类去碰数据库,测试里传一个内存实现。
interface PostRepository
{
public function find(int $id): ?array;
}
class Paginator
{
private $repo;
public function __construct(PostRepository $repo) { $this->repo = $repo; }
public function page(int $page, int $size): array
{
if ($page < 1 || $size < 1) {
throw new InvalidArgumentException('bad args');
}
return $this->repo->list(($page - 1) * $size, $size);
}
}测试里写一个 class FakeRepo implements PostRepository,返回几条假数据即可,不需要任何数据库。这条改造叫「为了可测性而解耦」,顺手还能让代码结构变好,是单测最被低估的收益。
对于必须在测试里用到数据库的场景,把它放进 tests/Feature,用事务回滚保证不污染数据:每个用例开始 beginTransaction,结束 rollBack。速度慢,但是真实的。
六、常见的新手坑
一是测试依赖执行顺序。有共享静态变量的用例,单独跑通过、一起跑失败。规矩是每个用例自己准备数据、自己清理,不依赖别人留下的状态。
二是断言了实现细节。断言「这个函数调用了 3 次 log」属于过度绑定,重构一次就崩。只断言外部可观察的行为。
三是把测试当文档却从不更新。测试红了就删掉,等于把安全网拆了。红了先看是实现错了还是需求变了,需求变了就改测试,而且要在 commit 里说明。
四是追求覆盖率数字。覆盖率 30% 的核心函数,价值远高于 90% 的 getter。不要为了让报告好看去测无逻辑的代码。
五是在测试里使用真实时间。time() 和 date() 会让用例在月末、时区边界、闰年时诡异地失败。把当前时间作为参数传进函数,测试里传固定值。
七、把测试接进日常流程
写完测试只是开始,关键是让它「一定会被执行」。三条低成本接入点:
一,本地提交前跑一遍。用 git 的 pre-commit 钩子,只跑与改动文件相关的测试,控制在十秒内,超过十秒人就会习惯性跳过。
二,推送到远程后跑全量。GitHub Actions 或自建 Gitea 都行,一个最小的 workflow 就是「装依赖、跑 phpunit」,失败就拦下合并。
name: tests
on: [push, pull_request]
jobs:
phpunit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: {php-version: '8.2'}
- run: composer install --no-interaction
- run: vendor/bin/phpunit --colors=never三,线上出 bug 之后,第一件事是补一条能复现它的测试,然后再修。这样同一个 bug 不会回来两次,这也是测试最实际的回报来源。
八、测试跑得慢怎么办:三个立竿见影的优化
用例从十条涨到一百条之后,最常出现的抱怨是「跑一遍要两分钟,我懒得等了」。测试一旦慢到让人不愿意跑,它的价值就开始归零,所以速度要当需求来维护。
第一,把碰数据库的用例挪出 unit 目录。单元测试的目标是「几十毫秒一个」,凡是需要连数据库、需要发 HTTP 请求的用例,一律归到 Feature 套件,日常只跑 Unit。这一刀切下去通常能砍掉一半以上时间。
第二,避免在 setUp 里做重活。常见写法是每个用例都在 setUp 里加载一遍配置文件、建一遍目录、初始化一遍容器,十个用例就重复十次。改成静态缓存或者延迟初始化,只在真正用到时才执行。
第三,善用过滤参数。开发时只跑改动相关的测试,不要每次全量:vendor/bin/phpunit --filter=Slug 只跑名字里带 Slug 的用例,--testsuite unit 只跑纯逻辑那一组。全量留给提交前和 CI。
还有一个容易被忽略的耗时来源:断言失败信息里打印超大数组。比较两个上万行的数组时,PHPUnit 会把整个数组 dump 出来,一个失败用例就能让输出卡住几秒。用 assertCount 加 assertSame($expected[0], $actual[0]) 分层断言,比一次性比较全量更利于定位。
九、小结:先写十条,再谈体系
很多站长被「测试体系」这个词吓住,觉得要重构架构、要买工具、要学一堆概念。实际上第一步只需要:挑三个最不放心的函数,写十条断言,跑通 vendor/bin/phpunit。这一步做完,你就已经比 90% 的草根站多了一层保护。
之后每次改动都顺手加一条,两年后你会有一个几十条用例的回归集,那才是真正的资产。测试不是给大公司准备的仪式,它只是把「手动点一遍」换成机器点一遍,省下来的是你自己的时间。