
说实话我干了十几年 PHP早年也是先写完功能再说那一派的。线上改一行代码、手一抖把写成结果整站订单金额全错这种事摊上一次就够你记一辈子。后来我认真把 TDDTest-Driven Development测试驱动开发在 PHP 项目里完整走了一遍从环境搭建到 CI 集成都摸了一遍才意识到PHP 这种动态弱类型语言恰恰是最需要 TDD 兜底的。这篇文章就把我在 PHP 里跑通 TDD 完整流程的整套方案写出来包括工具选型、红绿重构循环、数据库和队列怎么测、以及我踩过的那些坑。不管你是刚接触测试的新手还是团队想推测试文化的老兵这套流程都能直接抄作业。1. 为什么 PHP 项目尤其需要 TDD从一次线上事故说起1.1 PHP 项目的裸奔现状PHP 上手快、部署简单这是它的优点但也是它的诅咒。Java 有编译器帮你查类型错误Go 有严格的静态检查而 PHP 呢变量可以不声明直接用数组和对象的形态可以随手变一个函数传错类型很多时候不是报错而是静默地把null或者0传下去最后在离事故现场十万八千里的地方炸开。我做过的很多 PHP 项目尤其是用原生 PHP 或者轻量框架写的那种测试覆盖率常年是零。大家不写测试的理由也高度一致业务赶、老板催、测试写起来啰嗦。结果就是代码库越改越乱一个看似无关的小改动可能把三个月前正常的功能干趴。1.2 那次事故改动一行全站订单金额错误有一次客户提了个需求会员折扣从满 1000 减 50改成满 1000 打 9 折。我心想这不就改一行比较逻辑吗改完本地手点了几下看着没问题直接上了生产。结果当天晚上工单就爆了——有一批用户的订单金额算出来是负数。排查了一整夜最后发现根因是我改的那段折扣逻辑被另一个模块在某种特殊条件下二次调用而那个条件分支里$subtotal还没初始化传进来的是null。null * 0.9在 PHP 里不会报致命错误而是得到0再走一轮减价逻辑订单就变负数了。那一刻我特别想把 PHP 骂一顿但冷静下来想想根子不在语言在流程。如果我先写一个折扣计算必须正确处理空值的测试这个 bug 在写代码的那一刻就会被拦下来根本活不到生产环境。也是从那以后我开始认真研究 PHP 里的 TDD。1.3 TDD 与事后补测试的本质区别很多人觉得写测试和TDD是一回事其实差得很远。事后补测试是你先把代码写完、bug 已经埋进去了再用测试去证明代码没事。这时候你的潜意识会不自觉地把测试写成配合实现怎么都能过。而 TDD 的核心纪律是先写一个会因为需求未实现而失败的测试再写让它通过的最简代码。测试先行意味着你被迫在动手前想清楚到底要什么样的行为而不是写着写着把需求写歪。TDD 的循环就三个字红、绿、重构。红是你的新测试跑不过去因为功能还不存在绿是最小改动让测试通过重构是在保证全绿的前提下顺手优化设计。这三个字看着简单真正执行起来门道不少后面我用一个订单折扣的例子完整演示一遍。提示TDD 不是银弹它管不了需求本身是错的这种问题。但它能保证你写出来的每一行代码都有明确的、可验证的行为契约。2. 工具链准备PHPUnit 为主Xdebug 作为覆盖率支撑2.1 用 Composer 初始化项目并安装 PHPUnit在 PHP 世界里做 TDD绕不开的工具就是 PHPUnit。它是事实上的标准测试框架生态最成熟文档最全踩坑资料也最多。第一步先把项目用 Composer 初始化composer init composer require --dev phpunit/phpunit:^11.0我用的是 PHPUnit 11搭配 PHP 8.2 以上。如果项目还在 PHP 7.4 或 8.0建议用 PHPUnit 9 或 10版本兼容性问题在 PHP 生态里非常常见别盲目追新。接下来安排目录结构。PHP 社区比较通用的做法是这样project/ ├── composer.json ├── phpunit.xml ├── src/ # 业务代码 └── tests/ ├── Unit/ # 纯逻辑单元测试 └── Feature/ # 涉及数据库、HTTP、队列的集成测试然后去composer.json里配上 PSR-4 自动加载{ autoload: { psr-4: { App\\: src/ } }, autoload-dev: { psr-4: { App\\Tests\\: tests/ } } }改完记得跑composer dump-autoload不然新加的命名空间映射不会生效。这个步骤我见过太多人漏掉然后一脸懵地报 Class not found。2.2 phpunit.xml 的关键配置项PHPUnit 运行时读的配置文件是phpunit.xml我建议从一开始就配齐不要裸跑命令行。这是我在实际项目里沉淀下来的一份基础配置?xml version1.0 encodingUTF-8? phpunit xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance bootstrapvendor/autoload.php colorstrue failOnWarningtrue failOnRiskytrue cacheDirectory.phpunit.cache testsuites testsuite nameunit directorytests/Unit/directory /testsuite testsuite namefeature directorytests/Feature/directory /testsuite /testsuites source include directorysrc/directory /include /source /phpunit有几个点值得展开说一下。failOnWarningtrue和failOnRiskytrue是我强烈建议打开的。PHPUnit 有时候会给你警告比如测试里执行了error_log、或者测试没有做任何断言。默认情况下这些只是警告不会让 CI 变红但恰恰是这些软性信号能暴露出测试写的烂。我打开这两个选项之后逼着自己把一批假测试全部修掉了。bootstrapvendor/autoload.php的作用是让测试环境能自动加载你的类和测试类。如果没有这个每个测试文件里都要手动require一堆文件那体验就太痛苦了。2.3 为什么主力选 PHPUnit而不是 Pest 或 Codeception经常有人问我Pest 写起来更简洁Codeception 能测浏览器行为为什么不用我把三者的特点整理成一张表方便你按场景选框架核心风格适合场景学习成本社区资料PHPUnit类 方法断言式绝大多数 PHP 项目的单元/集成测试CI 标配中极多Pest闭包 链式断言喜欢 RSpec/JS 风格、追求表达简洁的小团队低快速增长中Codeception场景化 DSL 可测浏览器需要端到端验收测试、想一套框架全包的项目高中等我的建议是第一个项目老老实实用 PHPUnit。不是因为 Pest 不好而是 PHPUnit 的报错信息、调试方式、和各类框架的整合资料最全。等你把 TDD 的红绿重构循环练熟了再去看 Pest 完全不迟。至于 Codeception我更愿意把它定位成验收测试工具而不是日常 TDD 的主力。2.4 Xdebug 与覆盖率统计覆盖率统计需要 Xdebug 或者 PCOV 扩展。我日常用 Xdebug因为除了覆盖率之外调试也靠它。现在的 Xdebug 3 配置比早期简单多了只需要在php.ini里写xdebug.modecoverage如果你还要用断点调试那就写成xdebug.modedebug,coverage。跑覆盖率的时候用vendor/bin/phpunit --coverage-text这个命令会输出一个简单的文本报告标明每个文件的行覆盖率是多少。我通常不追求 100%后面会讲我怎么设质量门槛这里只提醒一句覆盖率数字只是参考别把它当 KPI 供起来。3. TDD 核心循环实战红-绿-重构跑通一个订单折扣案例3.1 第一步红先写一个注定失败的测试理论讲再多不如撸一个真实例子。假设我们要实现一个订单折扣功能VIP 会员下单总额打 9 折。按照 TDD 的规矩我先写测试而且写的当下Order类根本不存在。?php declare(strict_types1); namespace App\Tests\Unit; use App\Order; use App\Customer; use PHPUnit\Framework\TestCase; final class OrderTest extends TestCase { public function testVipCustomerGetsTenPercentDiscount(): void { $customer new Customer(type: vip); $order new Order(subtotal: 1000.0, customer: $customer); $this-assertSame(900.0, $order-total()); } }跑一下测试vendor/bin/phpunit --filter testVipCustomerGetsTenPercentDiscount结果毫无疑问是红的报错要么是 Class App\Order not found要么是找不到方法。这一步的失败是预期的红的测试越明确待会儿绿的信心就越足。认真看一眼报错信息确认它失败在类不存在而不是类存在但断言不对这也是一种基本功。3.2 第二步绿用最简代码让测试通过红色的测试已经定义了边界Order接收subtotal和customertotal()方法要返回打折后的金额。现在我用最少的代码让测试变绿?php declare(strict_types1); namespace App; final class Order { public function __construct( private readonly float $subtotal, private readonly Customer $customer ) { } public function total(): float { if ($this-customer-type vip) { return $this-subtotal * 0.9; } return $this-subtotal; } }对应的Customer类也建一个最简版本?php declare(strict_types1); namespace App; final class Customer { public function __construct( public readonly string $type ) { } }再跑测试绿的。这里有个 TDD 的关键纪律绿了之后立刻停下来不要顺手加功能。很多人的毛病就是走得太快测试刚通过就开始反正写着呢把普通会员的 95 折也加上吧。这会破坏 TDD 的小步快跑节奏下一轮需求万一变了你刚才的顺手全是返工成本。3.3 第三步重构优化设计再补边界测试第三个测试快速通过之后马上进入重构阶段。现在这个折扣逻辑直接写死在Order::total()里如果后续再加员工 85 折、节假日 8 折这个类很快会膨胀成一堆if。趁现在测试全绿我先做一轮小重构把折扣计算抽出去?php declare(strict_types1); namespace App; final class DiscountCalculator { public function apply(float $subtotal, Customer $customer): float { return match ($customer-type) { vip $subtotal * 0.9, staff $subtotal * 0.85, default $subtotal, }; } }再看Order变成这样?php declare(strict_types1); namespace App; final class Order { public function __construct( private readonly float $subtotal, private readonly Customer $customer, private readonly DiscountCalculator $discountCalculator new DiscountCalculator() ) { } public function total(): float { return $this-discountCalculator-apply($this-subtotal, $this-customer); } }重构完马上跑全量测试确认还是绿的。然后我接着用 TDD 的方式补员工折扣和普通用户的用例public function testStaffCustomerGetsFifteenPercentDiscount(): void { $order new Order(subtotal: 1000.0, new Customer(type: staff)); $this-assertSame(850.0, $order-total()); } public function testNormalCustomerPaysFullPrice(): void { $order new Order(subtotal: 1000.0, new Customer(type: normal)); $this-assertSame(1000.0, $order-total()); }这两个测试同样先红后绿——严格来说员工 85 折的行为已经在重构时实现了但测试还没写所以你写出来的那一刻跑一下理论上会是绿的。这不是犯规而是重构保护下的行为已经存在、契约还没固化的场景。补上之后这套订单折扣的契约就算彻底锁定了。3.4 这个案例里的三个关键认知走完一遍红绿重构有几个体会是纯看文档得不到的。第一步子要小。我见过太多人写测试一次写几百行红了之后蒙圈因为根本不知道是哪一行导致的。正确的姿势是一次只写一个断言一次只让它过一步。第二最简实现不等于最终实现。绿的阶段允许你写很笨的代码只要测试通过就行。真正让代码变漂亮的是重构阶段。很多人混淆了这两步结果就是测试没写好代码也没写稳。第三测试是设计工具不只是验证工具。当我写new Order(subtotal: 1000.0, customer: $customer)的时候我实际上是在设计Order的构造器参数。如果参数顺序容易记混如果某个依赖让测试很难构造那这个设计大概率有问题。TDD 会把这类坏味道提前暴露出来。4. 数据库、队列与外部接口PHP 项目里最难测的三种依赖4.1 数据库用 SQLite 内存库还是测试库很多 PHP 项目里业务逻辑和数据库查询是缠在一起的这让测试变得很麻烦。我的经验是分两层处理纯逻辑用单元测试涉及数据库的用 feature test。feature test 里最省事的方式是 SQLite 内存库。在phpunit.xml或者测试基类里把连接切到sqlite::memory:然后在每个测试方法开始前建表、结束后回滚。Laravel 项目里自带的RefreshDatabasetrait 就是这个思路的封装。但如果你用的是原生 PDO 或者自研的轻量框架可以自己写一个简单的测试基类?php declare(strict_types1); namespace App\Tests\Feature; use PDO; use PHPUnit\Framework\TestCase; abstract class DatabaseTestCase extends TestCase { protected PDO $pdo; protected function setUp(): void { $this-pdo new PDO(sqlite::memory:); $this-pdo-setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $this-pdo-exec(CREATE TABLE orders (id INTEGER PRIMARY KEY, amount REAL, customer_type TEXT)); } protected function tearDown(): void { $this-pdo null; } }用 SQLite 内存库的好处是快、隔离、不需要外部服务。坏处是它和 MySQL/PostgreSQL 的 SQL 方言有差异某些查询比如 JSON 字段操作、特定的聚合语法在 SQLite 里可能表现不一样。所以我会在团队里定一条规则单测和 CI 全绿只是底线发布前必须有人在真实数据库上跑一遍冒烟测试。4.2 外部 APIPHPUnit Mock 的正确姿势真实场景里订单结算要调支付网关、要调短信服务、要调物流查询。这些外部 API 不可能在测试环境里真实调用一来慢二来不可控三来可能产生真实扣款。处理方式就是 Mock——造一个替身对象定义好它该被怎么调用、返回什么结果。看一个支付网关的测试?php declare(strict_types1); namespace App\Tests\Unit; use App\PaymentGateway; use App\Order; use App\Customer; use PHPUnit\Framework\TestCase; final class CheckoutTest extends TestCase { public function testCheckoutChargesDiscountedAmount(): void { $gateway $this-createMock(PaymentGateway::class); $gateway-expects($this-once()) -method(charge) -with($this-equalTo(900.0)) -willReturn(true); $customer new Customer(type: vip); $order new Order(subtotal: 1000.0, customer: $customer); $checkout new Checkout($gateway); $result $checkout-run($order); $this-assertTrue($result); } }这里最核心的是expects($this-once())和with($this-equalTo(900.0))。它们不光验证调用成功返回 true还验证了调用次数和调用参数。如果下单逻辑里不小心传了原价 1000 而不是折后价 900测试会直接红。Mock 的一个常见误区是为了让测试过把 Mock 配得无限宽松。method(charge)-willReturn(true)这种写法虽然能让测试绿但它没有验证任何交互细节掩盖的问题和没有测试差不多。我现在的习惯是关键业务路径上的外部调用一定要用expects明确次数和参数只有无关紧要的辅助服务才用松散的method。4.3 Redis 队列、缓存场景的测试思路PHP 项目里越来越常见的一种架构是接口收到请求把任务丢进 Redis 队列后台 worker 消费处理。这种异步逻辑特别容易变成测试盲区因为默认的 PHPUnit 测试是一次性的不会自动等 worker 跑完。我的做法分两种情况。如果队列消费逻辑本身是核心业务比如订单超时关单我会把消费者写成纯 PHP 类输入是任务数据输出是处理结果然后对消费者类做单元测试。测试里 Mock 掉 Redis 客户端?php declare(strict_types1); namespace App\Tests\Unit; use App\OrderCloseConsumer; use PHPUnit\Framework\TestCase; use Redis; final class OrderCloseConsumerTest extends TestCase { public function testConsumerClosesExpiredOrder(): void { $redis $this-createMock(Redis::class); $redis-method(brpop) -willReturn([order_queue, json_encode([order_id 12345])]); $sut new OrderCloseConsumer($redis); $result $sut-processOne(); $this-assertTrue($result); } }如果只是想验证任务被正确投递到队列那直接 Mock 生产者断言调用了lpush且参数正确和 4.2 节的思路一样。另一种情况是团队里已经搭了测试用的 Redis 实例那我会跑一个小的集成测试真实地lpush一条数据再同步调用消费者处理验证数据库里的状态变化。这种测试跑得慢一点但覆盖链路更真实。我的原则是能 Mock 就 MockMock 不了的才上真实依赖永远不要依赖一个连 CI 环境都没有的外部服务。5. 踩坑实录测试全绿但上线还是炸了的五个原因5.1 坑一测试之间存在顺序依赖PHPUnit 默认按字母顺序跑测试但它不保证每个测试方法之间的状态完全隔离。我踩过最典型的一次tests/Unit/OrderTest.php里的某个测试往一个静态数组里塞了数据下一个测试读这个静态数组单跑没问题全量跑就报错。后来我把全量跑的顺序打乱--order-byrandom果然又红了一片说明测试之间确实有隐性的顺序依赖。从那以后我定了两条死规矩每个测试方法必须完全自给自足setUp 里重建所有依赖tearDown 里清干净所有残留。治好了这个毛病测试的可信度上了一个台阶。5.2 坑二时间函数、随机数没有注入业务代码里直接写time()和rand()是 TDD 的隐形杀手。比如订单超过 30 分钟未支付自动取消这种逻辑测试的时候你怎么构造超过 30 分钟的场景总不能真sleep(1800)吧。正确的解法是把时间变成一个依赖注入进去。最朴素的做法public function __construct( private readonly ?\DateTimeImmutable $now null ) { $this-now $now ?? new \DateTimeImmutable(); }测试里就能写$now new \DateTimeImmutable(2025-01-01 10:00:00); $order new Order(..., now: $now); $order-markPaidAt(new \DateTimeImmutable(2025-01-01 09:00:00));用固定的时间对象来构造已过期和未过期两种边界。随机数同理——如果逻辑依赖随机数就把随机数生成器抽成一个接口测试里注入固定序列。一句话总结所有不确定的东西都要变成你可以控制的参数。5.3 坑三静态方法和全局状态让 Mock 失效PHP 的静态方法很难 Mock这是 TDD 在 PHP 里最头疼的问题之一。假设你的代码里到处都是Logger::info(...)这样的静态调用测试的时候想验证有没有记录日志就非常被动。我的处理思路是分阶段治理。短期应急用 PHPUnit 的MockBuilder对静态方法做有限度的 stub但这玩意维护成本高能不用就不用。长期方案逐步把静态调用改成实例注入或者用一个可替换的单例容器。这个过程不用一步到位但每次改到相关代码时顺手推进一点代码的可测试性就会慢慢变好。另外提一句$GLOBALS、$_SESSION这类超级全局变量。只要你的业务代码里直接读写它们测试就必然受环境污染。我的铁律是所有外部状态都给我通过参数或构造器进来。5.4 坑四浮点数直接 assertEqualsPHP 的浮点数有精度问题0.1 0.2在 PHP 里打印出来是0.30000000000000004这种鬼样子。如果你在订单金额的测试里写assertEquals(0.3, $total)大概率会因精度误差而挂。处理浮点数断言有两个办法。一个是把金额换成整数分接口处接收 10.00 元内部存 1000 分计算全程用整数。这也是很多支付系统的标准做法。另一个是在 PHPUnit 里用带精度的断言$this-assertEqualsWithDelta(0.3, $subtotal * 0.9 / 100, 0.00001);我个人强烈推荐第一种——整数分方案。它不光让测试好写也让线上计算更稳你不会想在生产环境里排查为什么金额差了一分钱这种问题。5.5 坑五只测happy path漏掉异常分支新人写测试容易有一个倾向测的都是正常情况。传一个正常用户、正常金额、正常调用链一路绿灯。但线上炸掉的地方几乎都在边界和异常上。我后来养成了一个习惯每写一个功能测试至少再补两个异常场景。比如刚才的订单折扣public function testZeroAmountOrderIsAllowed(): void { // 边界0 元订单 } public function testNegativeAmountOrderThrowsException(): void { $this-expectException(\InvalidArgumentException::class); // 负数金额应该被拒绝 }给功能加测试的时候问自己三个问题输入异常怎么办依赖返回异常怎么办临界值两边的行为分别是什么把这三个问题答完测试才算是真正够用了。6. 把 TDD 固化到日常持续集成与覆盖率门槛6.1 本地一遍完整的工作流很多人在 IDE 里写代码、开个浏览器手点两下就算验证过了。但 TDD 的日常工作流应该是这样从需求里挑一个最小的行为点写一个失败的测试本地跑vendor/bin/phpunit --filter 测试名确认红写最简实现再跑确认绿重构再跑全量确认没有回归重复 1 到 4直到这个功能的所有行为点都被覆盖全量测试 静态分析比如phpstan analyse src tests都通过再提交。第 6 步里我加了 PHPStan它和 TDD 是互补关系测试管行为静态分析管类型隐患。两个都跑绿了才算这次改动真的稳。6.2 GitHub Actions 自动跑测试本地全绿只是第一步团队协作里更关键的是 CI。没有 CI 的 TDD相当于没人监督的作业迟早有人偷懒跳过测试。我用 GitHub Actions 做演示配置非常简洁name: php-tests on: push: branches: [ main ] pull_request: jobs: test: runs-on: ubuntu-latest strategy: matrix: php: [ 8.2, 8.3 ] steps: - uses: actions/checkoutv4 - uses: shivammathur/setup-phpv2 with: php-version: ${{ matrix.php }} coverage: xdebug - run: composer install --no-interaction --prefer-dist - run: vendor/bin/phpunit --coverage-clover build/logs/clover.xml - run: vendor/bin/phpstan analyse src tests --no-progress配置里用矩阵跑了两个 PHP 版本这样能提前发现在我机器上好好的在别的 PHP 版本上挂了这类兼容性问题。每次 PR 一提交CI 自动跑测试和静态分析任何没写测试或者测试红了的改动都会被挡在合并之前。6.3 覆盖率不是 KPI我如何设定质量门槛覆盖率到底定多少才合理见过不少团队硬性要求 100%结果测试全在测 getter/setter 和空对象毫无营养也见过完全不管覆盖率测试写了等于没写。我的做法是分层设门槛层级覆盖率目标说明核心业务模块订单、支付、折扣90% 以上涉及钱的逻辑必须锁死一般业务模块70%-80%主要行为点有测试即可外部适配层SDK 封装、配置文件50% 以下纯胶水代码测了收益不高执行上CI 里加一个最低门槛命令vendor/bin/phpunit --coverage-clover build/logs/clover.xml --coverage-min 80覆盖率一旦低于 80%CI 直接红。这个 80% 是全项目平均值SQLite 内存库跑出来的数字会虚高一点所以我还定期在真实数据库环境上跑一次差异对比防止测试里能过、生产上换种数据就挂的情况。6.4 让 TDD 在团队里活下来工具和流程都配好了最后一个问题往往是人的问题。我给想在团队里推 TDD 的朋友一个很实用的建议别一上来就要求所有功能必须 TDD。先选一个不那么紧急、边界清晰的模块比如优惠券计算、积分换算用这套流程跑通让团队看到收益再逐步扩大范围。强行一刀切推行只会让测试变成应付了事的仪式。我自己带团队的时候PR 里有一条硬性检查功能代码和测试代码要一起出现。只要看到只有功能没有测试的 PR直接打回。一开始大家叫苦连天但三个月之后线上 bug 数量肉眼可见地往下掉最直接的变化是再也没人半夜爬起来抢修订单计算的问题了。这大概就是 TDD 在 PHP 项目里最实在的回报——它不是让你多写代码而是让你少在深夜挨骂。