☰
PHPUnit单元测试实战:从安装到Mock替身,打造高质量PHP代码
2026/10/12 10:34:37 网站建设 项目流程

开头我就直说了:很多人觉得单元测试是“业务做完之后抽空补的功课”,但实际上它应该是在写业务代码之前就盘算好的东西。我在好几家公司带过团队,也看过太多线上事故是怎么发生的——绝大多数不是架构崩了,而是某个公共方法被改了一行,结果把另一个模块的边界情况炸穿了。如果没有一套能随时跑的单元测试兜底,这种问题往往要等到灰度环境才暴露,排查成本直接翻好几倍。PHPUnit 是目前 PHP 生态里最主流的单元测试框架,不管你是用 Laravel、ThinkPHP 还是原生 PHP 写接口,学会用 PHPUnit 把核心逻辑测起来,是投入产出比极高的一件事情。

这篇文章我不会给你堆概念,只讲我实际用 PHPUnit 这些年里最有价值的内容:从安装配置开始,到第一个测试用例怎么写,到数据提供器、mock/stub 这些进阶工具,再到底层原理和常见的翻车现场。虽然不是每一家公司都要求开发者写测试,但我可以负责任地说:如果你能把 PHPUnit 玩明白,你写的代码质量、你排查 bug 的速度、你重构时的底气,都会明显不一样。

1. 为什么我建议每个 PHP 项目都配一套单元测试

很多人对单元测试有一个误解,认为“单元测试就是把模块跑一遍,看有没有报错”。其实这完全是两码事。报错检查只能告诉你“这段代码在正常输入下能不能走通”,而单元测试的价值在于:它把代码拆成一个一个最小单元,验证每一个单元在不同输入下是否能产出符合预期的输出。

换句话说,单元测试更像是一份可执行的契约。你每写一个函数,就顺带用测试明确了这个函数的输入边界和输出约定。以后任何人改这个函数,只要一跑测试,立刻就能知道这次改动有没有破坏原有契约。这是一个非常强大的约束,能直接从源头减少“悄悄改坏别人代码”的情况。

1.1 你是在写测试,还是在写“安慰剂”

我在代码评审里见过最多的,就是所谓的“安慰剂测试”:

public function testAdd() { $calculator = new Calculator(); $result = $calculator->add(2, 3); $this->assertNotNull($result); }

这个测试跑起来永远是绿的,但它什么也没验证。你只是确认了 add 方法没有返回 null,却没有确认返回值是不是 5,没有确认参数类型异常时会不会抛错,也没有确认浮点数精度有没有问题。这种测试一旦多了,反而会给项目带来虚假的安全感——所有人看到绿勾都觉得“没毛病”,实际上核心逻辑早就坏掉了。

真正有效的单元测试,至少要回答三个问题:

  • 给定什么输入;
  • 期望什么输出;
  • 断言是否成立。

少了任何一环,都是在自欺欺人。

1.2 单元测试的真实价值:重构与回归

我个人的经验是:没有单元测试的代码库,本质上是一栋不能拆墙的房子。你想优化一下结构、抽个公共类、改个方法签名,根本不敢动手,因为不知道墙里有没有电线。

有了单元测试就不一样了。几年前我接过一个老系统,里面有一个非常核心的订单金额计算函数,那个文件已经有一年多没人敢动了。接到需求要新增一个会员折扣梯度,我第一件事不是改代码,而是先给现有函数把能 Mock 的数据全部 Mock 完,把已有的行为用测试“钉死”,然后再动工。改完后我把测试一跑,发现原来的满减逻辑被我无意中改坏了一个边界条件,马上修复。如果那个测试没写,我根本不知道已经引入了回归 bug,等业务上线才炸,那就不是几行代码能解决的事了。

所以,单元测试表面上是在测代码,实际上是在给未来的自己和其他开发者留一张安全网。这也是我强烈建议每个项目尽早铺测试的核心理由。

2. 从零搭建 PHPUnit:安装与配置那些容易踩的坑

说完了价值,我想直接讲实操。PHPUnit 的安装方式不算复杂,但如果你对 Composer 不熟,还是有一些细节值得注意。我个人推荐用 Composer 装,而不是直接下载一个 phpunit.phar。原因很简单:Composer 可以让 PHPUnit 的版本跟着项目走,之后升级、切分支都方便。

2.1 Composer 安装与版本选择

先确保你的项目已经用 Composer 管理依赖。然后在项目根目录执行:

composer require --dev phpunit/phpunit

这里有个非常关键的细节:一定要带--dev。PHPUnit 是开发环境工具,不是生产环境依赖。有些人图省事直接composer require phpunit/phpunit,结果把一堆测试工具也装到了线上依赖里,既拖慢部署,又白白增加 composer.lock 的变更范围。

另外要留意 PHPUnit 版本和 PHP 版本的兼容关系,这地方哪怕有多年经验的开发者也会翻车。以 PHPUnit 10 和 11 为例,它们要求 PHP 8.1 以上;PHPUnit 9 则支持 PHP 7.3 到 8.1。如果你还在维护老项目,PHP 版本停留在 7.2,那就只能使用 PHPUnit 8 或更早版本。装完必踩的坑是:你用了高版本 PHPUnit,结果 CI 环境跑测试时直接提示语法错误或者缺少 ext-mbstring。所以在 composer.json 里把 PHP 版本约束写清楚,再安装对应版本,是比较稳妥的做法。我常用这个命令手动指定版本:

composer require --dev phpunit/phpunit:^10.5

2.2 phpunit.xml 配置文件的正确姿势

PHPUnit 安装完成后,项目根目录通常要放一个phpunit.xml配置文件。这个文件的作用非常大,它决定了测试套件从哪里加载、bootstrap 文件是谁、覆盖率报告输出到哪里。我见过很多人忽略这个文件,直接裸跑phpunit,最后总是报“Test directory not found”,其实稍微配一下就解决了。

一个贴近实战的最小配置长这样:

<?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" colors="true" failOnWarning="true" failOnNotice="true" cacheDirectory=".phpunit.cache"> <testsuites> <testsuite name="Unit"> <directory>tests/Unit</directory> </testsuite> <testsuite name="Feature"> <directory>tests/Feature</directory> </testsuite> </testsuites> <source> <include> <directory>src</directory> </include> </source> </phpunit>

这里的bootstrap指向vendor/autoload.php,作用是让 PHPUnit 在测试启动时自动加载 Composer 的类映射,不需要每个测试文件都手动 require。testsuites用来区分单元测试和集成测试,这是一种约定俗成的分法:单元测试放在tests/Unit,涉及数据库、接口、外部服务的测试放在tests/Feature。colors设为true是为了终端输出好看些,failOnWarning和failOnNotice则是让代码里的警告和通知直接变成失败,逼迫开发者处理潜在问题。

2.3 验证安装是否成功

配置完后,运行:

vendor/bin/phpunit --version

如果能看到类似PHPUnit 10.5.x的输出,说明安装成功。此时你项目里还没有任何测试类,直接跑vendor/bin/phpunit会提示没有测试执行,这是正常的。接下来就该写第一个真正有意义的测试了。

3. 核心断言与第一个测试用例:从运行机制到常见误区

写测试之前,你要先理解 PHPUnit 的运行机制。它本质上是一个调度器:扫描配置里的测试目录,找到所有测试类,实例化它们,逐个执行测试方法,收集断言结果,最后给出一个汇总报告。测试方法之间是隔离的——每个方法跑完,PHPUnit 会销毁对象,尽量减少相互影响。

3.1 一个完整测试类的基本骨架

假设我们有一个计算器类:

<?php namespace App; class Calculator { public function add(float $a, float $b): float { return $a + $b; } public function divide(float $a, float $b): float { if ($b == 0) { throw new \InvalidArgumentException('除数不能为0'); } return $a / $b; } }

对应的测试类:

<?php namespace Tests\Unit; use App\Calculator; use PHPUnit\Framework\TestCase; class CalculatorTest extends TestCase { public function testAddReturnsCorrectSum(): void { $calculator = new Calculator(); $result = $calculator->add(2, 3); $this->assertSame(5.0, $result); } public function testDivideByZeroThrowsException(): void { $this->expectException(\InvalidArgumentException::class); $calculator = new Calculator(); $calculator->divide(10, 0); } }

这个例子里已经涵盖了 PHPUnit 最核心的两类断言逻辑:值断言和异常断言。

3.2 assertSame 与 assertEquals 的区别,别再搞混了

这个问题我在面试别人时经常会问到,但回答准确的人其实不多。assertEquals用的是==,assertSame用的是===。很多人觉得无所谓,实际上这里藏着不少坑。

举个例子:

$this->assertEquals(5, '5'); // 通过 $this->assertSame(5, '5'); // 失败

你测试订单金额的时候,接口层返回的字段经常是字符串类型的金额,而业务层计算出来的是浮点数。如果你用assertEquals,即使精度差了那么一点也照样绿。等你把代码接到真实环境,发现金额怎么差了 0.01,再去排查,就会发现原来测试根本没有帮你挡住类型和精度问题。

所以我在项目里的约定通常是:比较值和类型都用assertSame;只有当比较对象是浮点数且涉及运算精度时,才用assertEqualsWithDelta来指定允许误差。这样可以确保测试足够严格,不会产生“静默漂移”。

3.3 测试方法命名的艺术

我见过大量类似testA、testB这样的测试命名。平心而论,测试跑过之后,这些名字毫无信息量。当你某天需要定位一个失败用例,看到testB失败了,你不点进去看代码根本不知道哪里出了问题。

好的命名应该像一句简短的英文句子,描述的是一种行为期望。比如:

  • testAddReturnsCorrectSum—— 加法返回正确之和
  • testDivideByZeroThrowsException—— 除零时抛出异常
  • testNegativePriceShouldRejectOrder—— 负数价格应该拒绝订单

这样当 CI 报错时,你瞄一眼测试名就能判断大概是哪个逻辑出问题了,根本不需要额外去翻回代码。

4. 进阶玩法:数据提供器、测试替身与依赖注入的合理姿势

跑通了第一个测试之后,很多人的下一步会遇到一个真实的困境:我的类需要查数据库、调用第三方 API、或者依赖别的服务,这种情况下怎么测?

这就要引入数据提供器和测试替身。它们是 PHPUnit 里用得最多、也最容易用错的两个特性。

4.1 用数据提供器消除重复测试代码

假如你要测一个校验邮箱格式的函数,你可能会写一堆几乎一样的测试方法:

public function testValidEmail(): void { ... } public function testInvalidEmailWithoutAt(): void { ... } public function testInvalidEmailWithoutDomain(): void { ... }

这太繁琐了。PHPUnit 提供了“数据提供器”,可以让你把测试输入和预期结果集中在一张表里,然后由框架自动帮你循环执行。

PHPUnit 10 之后推荐使用属性方式:

<?php namespace Tests\Unit; use App\Validator; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class ValidatorTest extends TestCase { #[DataProvider('provideEmailCases')] public function testValidateEmail(string $email, bool $expected): void { $validator = new Validator(); $this->assertSame($expected, $validator->validateEmail($email)); } public static function provideEmailCases(): array { return [ 'normal email' => ['user@example.com', true], 'missing at sign' => ['userexample.com', false], 'missing domain' => ['user@', false], 'contains spaces' => ['user name@example.com', false], ]; } }

数据提供器最有价值的地方在于:它把“输入-预期输出”的对应关系非常直观地呈现出来,任何人维护测试数据都像在维护一张业务规则表。而且当你新增边界条件时,只需要往里加一行数据,不需要复制粘贴整个方法。

有一个性能上的细节值得注意:数据提供器返回的数组元素比较多时,每个用例都会被 PHPUnit 当作一个独立的测试项来统计。这本身是好事,但如果你在一个数据提供器里塞了几百条用例,报表看起来会非常冗长。我通常的做法是:普通边界值 5-10 条,特殊情况单独拆成独立测试方法。

4.2 createMock 与 createStub:别再盲目 Mock 一切

“测试替身”这个概念听起来玄乎,其实就是给你的被测类找一个替身演员。PHPUnit 原生提供了两种创建替身的核心方法:

  • createStub(ClassName::class):只替换掉你想控制的那些方法,其余方法返回默认值;
  • createMock(ClassName::class):可以进一步校验被测类是否以特定参数、特定次数调用了替身的方法。

我见过很多新手把测试写成“为了 Mock 而 Mock”,连一个纯计算类的依赖也要 Mock,这是完全没必要的。如果你的类只依赖简单的值对象或者无状态服务,直接 new 真实的类来测就好。Mock 应该主要用于那些会带来外部副作用的依赖:数据库连接、HTTP Client、消息队列、文件系统等。

举个例子,假设有一个OrderService,它依赖PaymentGatewayInterface:

<?php namespace App\Service; use App\Payment\PaymentGatewayInterface; class OrderService { public function __construct( private PaymentGatewayInterface $paymentGateway ) {} public function payOrder(int $orderId, float $amount): bool { return $this->paymentGateway->charge($orderId, $amount); } }

测试里我们不想真的去调用支付网关,于是创建一个替身,并指定它的charge方法返回true:

<?php namespace Tests\Unit; use App\Payment\PaymentGatewayInterface; use App\Service\OrderService; use PHPUnit\Framework\TestCase; class OrderServiceTest extends TestCase { public function testPayOrderReturnsTrueWhenChargeSucceeds(): void { $paymentGateway = $this->createMock(PaymentGatewayInterface::class); $paymentGateway->method('charge') ->with(1001, 199.9) ->willReturn(true); $orderService = new OrderService($paymentGateway); $this->assertTrue($orderService->payOrder(1001, 199.9)); } }

这里的with(1001, 199.9)就是在断言:OrderService 必须把订单号和金额原封不动传给支付网关。一旦未来有人不小心把参数顺序写反了,测试会立刻失败,提醒你这个改动会破坏支付流程。

4.3 测试替身使用过度的副作用

Mock 用多了也有副作用。整个测试类里如果全是createMock,那这个测试实际上是在验证“你期望代码怎么做”,而不是“代码功能是否真的正确”。一旦你误解了业务需求,Mock 出来的测试仍然会绿,它并不能帮你拦住错误的理解。

所以我有一个经验法则:测试里尽量使用真实的、简单的对象;只有涉及外部 IO 的资源,才用测试替身。这也让整个测试更接近真实运行时的行为。

4.4 依赖注入为什么让事情变得简单

看刚才的OrderService例子,你会发现它之所以好测,是因为构造函数把PaymentGatewayInterface这个依赖显式传了进来。如果这段代码里直接new PaymentGateway(),那测试就没法替换掉了。

换句话说,可测试性是从源头上设计出来的。你在写类的时候,就要养成一个习惯:不要在一个方法里直接 new 依赖对象,而是通过构造函数或参数注入。这不仅让测试更轻松,也让代码的耦合度降低,是长期受益的设计习惯。

5. 测试失败不是坏事:如何从失败信息反向定位问题

我以前带过一个同学,写了几个测试后只要一见红色失败就发慌,觉得是自己的代码不行。我跟他说,测试失败是这套体系里最有价值的反馈。你要做的不是害怕,而是去读失败信息。

PHPUnit 的失败报告其实给得相当精准:

1) Tests\Unit\CalculatorTest::testDivideByZeroThrowsException Failed asserting that exception of type "RuntimeException" is thrown.

这行信息直接告诉你:测试想要的是InvalidArgumentException,但代码里实际抛出的可能是RuntimeException。你顺着这个信息去检查实现代码的异常类型,问题就清楚了。

5.1 失败排查的一次完整链路

我举一个真实发生过的情况。项目中有一个formatMoney()函数,负责把100格式化成"1.00"的字符串。测试长这样:

public function testFormatMoneyConvertsCentsToDollars(): void { $this->assertSame('1.00', $this->formatter->formatMoney(100)); }

跑完以后失败信息是:

Failed asserting that two strings are identical. --- Expected +++ Actual @@ @@ -'1.00' +'1.0'

看到这里,我就能快速判断:代码里可能用了number_format(100 / 100, 1),只保留了一位小数,所以输出是1.0。我把参数改成2,再结合数据提供器覆盖0、99、1001这些边界值,最终确认所有金额格式化逻辑都符合预期。

整个过程里,测试失败其实已经替我定位了 bug:不是 JSON 编码问题,不是浮点精度问题,而是number_format的小数位数写错了。如果没有测试,我可能要打开接口页面,传几个参数,再手动去对比返回值,效率完全不在一个量级。

5.2 通过--filter精准运行单个测试

当测试文件越来越多,全量跑一遍开始需要十几秒甚至更久时,你可以用--filter只跑关心的那一个方法:

vendor/bin/phpunit --filter testFormatMoneyConvertsCentsToDollars

这个命令非常适合你在开发阶段高频迭代。每次改完代码,只跑当前正在调整的用例,秒级反馈;提交之前再全量跑一遍作为收尾,是很顺手的节奏。

5.3 覆盖率报告怎么用才不算走火入魔

PHPUnit 可以生成覆盖率报告,让你知道每一行代码有没有被测试执行到。运行方式是:

vendor/bin/phpunit --coverage-text

覆盖率数据当然有价值,但它只是个参考。我见过一些团队把覆盖率当成 KPI,逼着大家把覆盖率冲到 95% 以上。结果大家开始写一堆只为了“踩到代码行”的无效断言——代码倒是都cover住了,但逻辑根本没验证到,整体产出变成了一场数字游戏。

正确的用法是:先看核心业务模块的覆盖率,比如订单、支付、库存这些,保证它们至少有个合理的覆盖;至于工具类、DTO 这些简单代码,覆盖率低完全没关系。

6. 让测试真正发挥作用:命名约定、目录结构与日常节奏

最后我想聊的是:测试写好了,怎么放进日常工作流里而不变成摆设。这是决定测试体系能否长期存在的关键。

6.1 常见的目录结构和命名规范

我在团队里长期推行的规则是:

  • tests/Unit放单元测试,对应src目录下的类,一个类对应一个测试类;
  • tests/Feature放有数据库、HTTP、队列等参与的集成测试;
  • 测试类名保持和被测类一致,比如OrderService的测试类就叫OrderServiceTest。
  • 测试方法名用英文行为句命名,不叫testA。

这样做的好处是,你看到一个测试类就能很快知道它在测谁,看到一个失败的测试名就能大致判断是哪个环节出了什么问题。没什么高深道理,但极其实用。

6.2 把测试跑进 CI,让回归无处可逃

本地运行测试只是第一步。如果没有 CI 在每次提交时自动跑测试,那么测试的约束力会大打折扣。在绝大多数 CI 工具中,执行测试的方式就是一条命令:

composer install --no-dev --ignore-platform-reqs # 或者 php vendor/bin/phpunit

千万不要在生产环境的构建流程里跑测试。测试依赖的扩展、环境变量和数据库服务和生产往往不一样,跑出来的结果不仅没有参考价值,还可能拖慢整个部署流程。正确的是在独立的 CI 流水线里跑,配置好测试环境的数据库,然后让phpunit失败时以非 0 退出码中断流水线,从而挡住代码合并。

6.3 说一个我踩了很久才改掉的习惯:把测试当成代码评审的输入,而不是输出

曾经我写完代码后再去补测试,写着写着发现要补的用例太多,就逐渐开始偷懒:挑几个不痛不痒的场景测一测,图个心理安慰。

后来我调整了顺序:写业务代码之前,先用测试把关键行为的期望描述出来。这个过程有点像“先写需求文档再写实现代码”。你会发现,当你真的花 10 分钟去思考“这个方法应该接受什么、拒绝什么、异常时做什么”时,代码设计本身就已经清晰了一大半,实现的时候反而顺手很多。

6.4 日常开发中我推荐的一套节奏

最终,我的日常测试节奏已经稳定成下面这样:

  • 动手写功能前,先把核心类的接口想清楚;
  • 边写实现边补测试,重点覆盖输入边界、异常分支、返回值的准确性;
  • 改公共方法后,立刻跑一次--filter定位受影响测试;
  • 提交代码前,全量跑一遍,确保没有回归;
  • 每周至少看一次覆盖率报告,把核心业务模块的空洞补一补,不追求虚高数字。

单元测试说到底不是一种额外负担,而是一种开发时的思考方式。它逼着你把函数写小、把依赖写清、把边界想透。这套习惯一养成,你写出的代码自然更稳当,你在团队里的可信度也会明显提升。

在我自己经手的项目里,凡是测试体系坚持了半年以上的,线上紧急修复的次数都肉眼可见地减少了。而那些没有测试的项目,每次发版前所有人都提心吊胆。所以如果你还在犹豫要不要学 PHPUnit,我的建议是:不要犹豫,先拿你手头最核心的一个类试水,给它写十几个用例,跑通一次全套流程,你一定会回来把其他类也补上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询