资讯动态

PHP TDD完整流程实战:从失败测试到安全重构

发布时间:2026/9/8 6:12:09 来源:尧图企业网站定制
在 PHP 圈子里聊 TDD测试驱动开发争议从来没有停过。有人说 PHP 写测试是给自己找麻烦也有人觉得写业务都来不及哪还有时间写测试。但我做了这么多年 PHP 项目真正把 TDD 的完整流程跑通之后最大的感受是测试不是写给老板看的 KPI而是给你自己留的退路。这篇文章我会把这套“php 方案 TDD 完整流程”从头到尾讲清楚包括环境怎么搭、第一个失败测试怎么设计、怎么用最小代码把测试变绿、重构时怎么保证不出错以及我在真实项目里踩过的坑。无论你是刚开始碰测试的新手还是写了几年 PHP 但一直没系统用过 TDD 的老手这篇文章应该都能给你一些可以直接抄作业的东西。1. 先搞清楚 TDD 在 PHP 里到底解决什么问题1.1 为什么 PHP 项目特别需要测试驱动开发PHP 是动态弱类型语言天然灵活但这份灵活也是隐患。你没写类型约束的地方一个变量可能今天是字符串明天就变成了数组某个函数内部改了公共状态调用方可能在完全不知情的情况下拿到了脏数据。在这种语言环境里改一处代码影响一大片是常态而且很多时候是线上报错了才发现。TDD 的核心逻辑其实很简单先写一个必然会失败的测试用这个失败来定义需求然后写恰好能让测试通过的代码最后在测试的保护下安全重构。这个顺序看起来“多了一步”但它逼着你在写业务逻辑之前先把“这段代码到底要输出什么”想清楚。我见过太多项目代码写了两三年测试覆盖率是 0每次改需求都像拆地雷。而 TDD 给你的不是“不犯错”是“犯错时能立刻知道错在哪”。当你面前有几百个测试用例改完代码后跑一下 phpunit绿色一片那种安全感比什么都值钱。尤其是 PHP 项目里常见的订单计算、库存扣减、优惠券分摊这类核心逻辑一旦算错就是真金白银的损失用 TDD 把这类逻辑锁死非常划算。1.2 TDD 并不是万能的边界要画清楚但我也得说句公道话TDD 不是所有场景都适合。如果你在写一个一次性数据迁移脚本脚本跑完就删那给它写测试就是纯浪费时间。如果你在做纯粹的视图模板渲染测试成本高、收益低也不建议硬上。TDD 真正适合的是业务规则复杂、后续会持续迭代、出错了代价很高的代码比如支付对账、订单状态流转、折扣计算、库存扣减、接口参数校验这类逻辑。另外TDD 不能取代代码审查也不能取代集成测试。它更像是给开发过程装了一个“即时反馈器”让问题尽早暴露。我个人的经验是核心业务模块用 TDD 严格走红绿重构外部接口联调用集成测试兜底UI 层和脚本逻辑靠人工验证。把边界划清楚团队才不至于为了测试而测试。2. 项目初始化先把 PHPUnit 这套工具链跑起来2.1 Composer 安装 PHPUnit 和基础配置要在 PHP 项目里做 TDD第一步是把 PHPUnit 装好。原则上 PHPUnit 应该作为开发依赖安装这样才能保证线上部署不会拖一堆测试工具进去。在项目根目录执行composer require --dev phpunit/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这里bootstrap指定了自动加载文件PHPUnit 运行前会先执行它这样测试类里就能直接use项目里的类。failOnWarning和failOnRisky是我后来才加上的它们会把一些隐患直接当成测试失败处理严格模式能倒逼你写更干净的测试代码。2.2 PHPUnit 还是 Pest怎么选PHPUnit 是 PHP 生态里最经典、最稳的测试框架几乎所有 CI 教程和开源项目都在用它遇到问题也很好搜解决方案。Pest 是后来出现的更高层封装它底层还是基于 PHPUnit只是语法更接近自然语言写起来更“顺口”。同一个断言PHPUnit 是这样写的$this-assertSame(475.0, $calculator-calculate(500.0));Pest 是这样写的expect($calculator-calculate(500.0))-toBe(475.0);两者没有本质区别选型主要看团队习惯。如果团队里都是经验丰富的老 PHP 开发PHPUnit 最保守不易错如果是新团队、想降低写测试的心理门槛Pest 的上手体验确实更好。我的建议是不管选哪个关键是统一不要混着用。混用会导致代码风格割裂后面维护成本很高。2.3 自动加载和测试目录的规划测试类的自动加载要单独配置。composer.json里通常会有这样的配置{ autoload: { psr-4: { App\\: src/ } }, autoload-dev: { psr-4: { Tests\\: tests/ } } }配置完之后一定要重新生成自动加载文件composer dump-autoload这一步很多人会漏掉结果写好了测试类一运行就报Class Tests\\Unit\\FooTest not found。目录规划上我习惯把tests/Unit和tests/Feature分开单元测试只测单个类不碰数据库和外部服务功能测试才走完整链路。分开之后每次跑测试也可以按需选择比如只跑单元测试时用--testsuite Unit速度会快很多。3. 完整跑一遍红绿重构折扣计算器实战3.1 先写一个必然失败的测试讲再多理论不如动手跑一遍。我以一个订单折扣计算器为例。需求很简单订单金额满 1000 打 9 折满 500 打 95 折不满 500 不折扣。用 TDD 的思路第一步不是去写OrderDiscountCalculator类而是先写测试定义这个类的行为。在tests/Unit/OrderDiscountCalculatorTest.php里写上?php declare(strict_types1); use PHPUnit\Framework\TestCase; final class OrderDiscountCalculatorTest extends TestCase { public function testOrderAmountBelow500GetsNoDiscount(): void { $calculator new OrderDiscountCalculator(); $this-assertSame(400.0, $calculator-calculate(400.0)); } }这时候运行 PHPUnit./vendor/bin/phpunit --filter OrderDiscountCalculatorTest你大概率会看到一个错误Error: Class OrderDiscountCalculator not found。这个错误就是标准的“红”。看着它失败很重要因为它证明了你的测试确实在验证一个还不存在的功能而不是测试本身写错了。3.2 用最小实现让测试变绿看到红色之后现在去写让测试通过的最小代码。注意“最小”这个词很关键不要在一开始就想着把折扣规则全部实现也不要提前引入策略模式、工厂模式。TDD 的红绿循环要求你每次只做能让你当前测试变绿的最少事情。先创建src/OrderDiscountCalculator.php?php declare(strict_types1); final class OrderDiscountCalculator { public function calculate(float $amount): float { return $amount; } }这个时候跑测试会变绿。虽然这个实现看起来“啥也没干”但第一个测试通过了。接下来写第二个测试public function testOrderAmountAtLeast500GetsFivePercentOff(): void { $calculator new OrderDiscountCalculator(); $this-assertSame(475.0, $calculator-calculate(500.0)); }再次运行测试是红的因为 500 返回的是 500 而不是 475。于是修改实现public function calculate(float $amount): float { if ($amount 500 $amount 1000) { return $amount * 0.95; } return $amount; }继续添加满 1000 打 9 折的测试重复“红绿”过程直到所有规则都实现。这个过程看起来琐碎但它让你每一步都知道自己改了什么、为什么改。3.3 重构阶段用数据提供器干掉重复所有规则都通过后代码看起来已经算正常了但你可能会发现测试代码里有大量重复的赋值和断言。这时进入 TDD 的第三个阶段重构。重构的目标是改善代码结构但不改变行为。既然现有测试已经覆盖了行为你就可以放心动手。折扣计算器测试可以用 PHPUnit 的数据提供器把多个场景合并成一个测试方法?php declare(strict_types1); use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class OrderDiscountCalculatorTest extends TestCase { #[DataProvider(discountProvider)] public function testDiscountByAmountBracket(float $amount, float $expected): void { $calculator new OrderDiscountCalculator(); $this-assertSame($expected, $calculator-calculate($amount)); } public static function discountProvider(): array { return [ below 500 [400.0, 400.0], exactly 500 [500.0, 475.0], between 500 and 1000 [800.0, 760.0], exactly 1000 [1000.0, 900.0], above 1000 [1500.0, 1350.0], ]; } }这里有个细节PHPUnit 10 以上要求数据提供器方法是static并且用#[DataProvider]属性指定。如果你项目里还在用老版本 PHPUnit也可以写成dataProvider discountProvider注释但新项目建议直接用属性更清晰。跑完测试五个场景全部绿色重构完成。4. 写好测试的细节命名、数据、替身与依赖隔离4.1 测试命名是另一种文档很多人忽略测试命名的重要性觉得测试只要“能过”就行。实际上测试方法名是代码最真实的文档。testCalculateWithAmount400ShouldReturnOriginalPrice这样的命名虽然啰嗦但别人一看就知道这个测试在验证什么行为。到了 PHPUnit 9你也可以用#[Test]属性把方法变成测试方法名可以不写test前缀更自由一些。我个人的习惯是测试方法名统一用英文描述“输入条件 期望结果”注释用中文补充业务上下文。这样代码仓库里中文和英文混排不会显得突兀也能让非技术人员大致看懂测试覆盖了哪些场景。4.2 边界值和数据提供器的正确用法写测试最容易犯的错是只测“正常的中间值”。折扣规则里 500 和 1000 是分界点如果只测 600、1200 这种值边界上的浮点数比较很可能出问题。所以我在上面的数据提供器里专门加了exactly 500和exactly 1000的用例。这个思路可以推广到任何业务起止时间、库存上下限、分页条数、手机号长度边界值永远是 bug 高发区。另外如果业务里有日期、浮点运算建议在测试里直接写死期望值而不是再算一遍。比如shouldBe(475.0)不要写shouldBe(500.0 * 0.95)否则你的测试只是在复制实现逻辑等于没测。4.3 Mock、Stub 和依赖注入不碰外部依赖的测试才是好测试真实项目里一个类通常会依赖数据库、缓存、邮件服务、第三方 API。如果测试时真的去连数据库、真的发邮件测试就会变得极慢且不稳定。解决办法是把依赖抽象成接口并通过构造函数注入测试时用替身代替真实实现。举个例子订单完成服务依赖订单仓储和邮件服务?php declare(strict_types1); final class OrderService { public function __construct( private OrderRepositoryInterface $repository, private MailerInterface $mailer ) { } public function complete(int $orderId): void { $order $this-repository-find($orderId); $order-markCompleted(); $this-repository-save($order); $this-mailer-send($order-customerEmail(), order_completed); } }测试时用 PHPUnit 的createMock创建替身$repository $this-createMock(OrderRepositoryInterface::class); $mailer $this-createMock(MailerInterface::class); $order new Order(1); $repository-method(find)-willReturn($order); $mailer-expects($this-once()) -method(send) -with(customerexample.com, order_completed); $service new OrderService($repository, $mailer); $service-complete(1);这里expects($this-once())是在断言邮件服务只被调用一次且参数正确。这种测试跑起来毫秒级完成不依赖任何外部环境可以随时在本地和 CI 里执行。4.4 数据库测试怎么设计才不坑纯 PHP 项目没有框架时最好的策略是让业务代码依赖仓储接口测试里用内存数组实现一个 FakeRepository 来替代真实数据库。这样做的好处是测试根本不会碰数据库速度极快。如果必须做真实 SQL 的集成测试可以给测试环境配置一个独立的 SQLite 内存库每次测试前自动建表、测试后清理数据。同一个项目里我习惯把“业务规则测试”和“真实 SQL 测试”分开目录方便单独跑。5. 实战中高频出现的测试场景异常、时间、覆盖率与遗留代码5.1 异常路径也要写测试很多开发者写测试只测正常流程异常路径完全忽略。但业务里最容易出问题的恰恰是异常场景。比如库存扣减库存不足时必须抛出异常。PHPUnit 里用expectException就能搞定public function testCompleteThrowsExceptionWhenStockNotEnough(): void { $this-expectException(InsufficientStockException::class); $this-expectExceptionMessage(stock not enough); $orderService new OrderService($repository, $mailer); $orderService-complete(1); }要注意一旦调用了expectException在测试方法里你不需要也不能再 catch 这个异常去断言消息PHPUnit 会帮你统一处理。如果你需要在异常发生前后做状态断言可以结合try...catch...finally来写但能用expectException的场合我建议优先用它代码最简洁。5.2 时间与随机数测试里最不稳定的两个因素业务代码里只要出现time()、date()、random_int()测试的不确定性就会直线上升。比如一个判断优惠券是否过期的逻辑如果测试代码里直接比较time()和过期时间测试结果可能因为毫秒级的差异忽绿忽红。我常用的解决方案是“把时间变成注入依赖”。在业务类构造函数里传入一个ClockInterface实现一个now(): DateTimeImmutable方法。测试时用固定时间比如new DateTimeImmutable(2025-01-01 10:00:00)。这样应用代码里不再直接调time()而是调$this-clock-now()时间就完全可控了。随机数测试同理要么用固定 seed要么把随机生成器抽象出来注入。5.3 覆盖率怎么测CI 怎么自动跑覆盖率是一个参考指标不是唯一目标。我见过有人为了覆盖率达标疯狂给__get、__set这种魔术方法写测试实际意义很小。更合理的做法是要求核心业务模块覆盖率保持在 80% 以上同时用 git diff 去检查新增代码行有没有被测试覆盖。本地生成覆盖率报告的命令php -d xdebug.modecoverage ./vendor/bin/phpunit --coverage-text这里要求你本地装了 Xdebug 并开启了 coverage 模式。没有 Xdebug 也可以使用 PCOV但原理类似。覆盖率数据最好在 CI 里自动跑起来比如 GitHub Actions 的配置可以简化成steps: - name: Setup PHP uses: shivammathur/setup-phpv2 with: php-version: 8.3 coverage: xdebug - name: Run Tests run: php -d xdebug.modecoverage ./vendor/bin/phpunit --coverage-text --coverage-clover build/logs/clover.xmlCI 里跑测试的好处是任何人都不能悄悄提交一个破坏测试的代码而不被发现。团队里只要有一次“我本地跑过了”和 CI 结果不一致的教训你就会明白 CI 自动跑测试有多重要。5.4 老项目怎么用 TDD 重构先写特征测试现实里很多项目接手时已经是一大坨代码根本没有测试。这种情况下直接说“我们开始 TDD 吧”是行不通的因为连一处稳定的行为都没有。我的经验是先给老功能写特征测试Characterization Test。思路很简单把当前代码的真实输出固化到断言里不管这个输出是否符合理想设计先当成事实记录下。比如一个既有订单汇总函数当前输出是ORDER-2024-001 total: 100.00那测试就先断言这个格式。之后你重构内部实现时只要测试保持绿色就说明对外行为没变。这就像给一段烂代码上了个保险之后每一步重构都有一张网托着。6. 常见问题与排查技巧实录6.1 排查问题速查表在 TDD 落地过程中有几个问题几乎是所有团队都会遇到的我整理成一张速查表方便你遇到时直接照着排查。现象可能原因排查与解决Class not found自动加载没刷新或命名空间错误执行composer dump-autoload检查composer.json的 autoload 配置测试互相影响单独跑绿、一起跑红共享静态变量、数据库缓存、全局状态在tearDown()里清理状态每个测试用独立的数据时间相关断言时灵时不灵代码里直接调了time()/date()用时钟接口注入或者用Carbon::setTestNow()固定时间覆盖率一直为 0没有启用 Xdebug coverage 模式确认xdebug.modecoverage或使用 PCOV浮点金额断言失败浮点精度问题不要直接比较浮点用assertEqualsWithDelta或把金额转成分为单位CI 才能复现的测试失败依赖版本不一致或环境变量缺失检查 composer.lock 是否提交在 CI 配置里显式设置测试环境变量failOnRisky开启后报 risky测试没有断言或依赖外部输入给测试补充断言隔离外部数据源PHPUnit 版本和 PHP 版本不兼容本地 PHP 过老或过新升级 PHP 或降级 PHPUnit 版本参考官方兼容矩阵6.2 几个值得警惕的隐藏坑第一个坑是测试里直接操作私有属性和私有方法。PHPUnit 确实提供了反射工具能测私有方法但我劝你尽量别用。私有方法是实现细节TDD 主张测行为不是测实现。如果某个私有方法重要到必须单独测往往说明这个类已经太复杂该把那个方法抽成一个独立的类了。第二个坑是“为了绿而绿”。有些人看到测试红色不是去写正确实现而是想办法改测试让测试通过比如把断言改成assertTrue(true)。这种自欺欺人的做法比不写测试更危险。TDD 里有一条铁律测试的红必须是因为被测代码缺少功能而红不是因为测试本身写错。一旦发现红色原因不对第一件事是回头检查测试而不是想办法绕过去。第三个坑是过度 mock。如果一个测试里 mock 了五个依赖那么它实际上并没有在测你的业务逻辑而是在测 mock 对象之间的交互。这种情况通常说明被测类职责过多拆分类才是正解。我通常用“一个核心业务类 最多两个外部依赖”作为健康度标准超过这个数就考虑重构。第四个坑是测试和业务代码耦合过深。业务代码内部换了变量名测试就挂了这种“脆测试”会拖慢整个团队的开发速度。好的测试应该只依赖公共 API、输入和输出而不依赖内部实现细节。重构一段代码时好的测试应该基本不动需要频繁改动的测试往往是在提醒你设计出了问题。6.3 团队落地 TDD 的心得TDD 能不能在一个团队里生根和氛围关系很大。如果你是自己一个人写项目那自由度很高想怎么跑都行。但如果是团队协作你需要先立规矩提交前必须跑测试、CI 红灯不能合入、测试命名要统一、数据提供器优先于重复断言。这些规则不需要一开始就铺开而是先用一个小模块做试点跑顺畅了再逐步覆盖核心业务。我自己的习惯是每天下班前把当天写的功能对应的测试跑一遍看到一片绿色再走。第二天上班先跑一次完整测试套件确保昨晚的改动没有破坏别的地方。这个习惯坚持几年下来最直接的收益是联调阶段的 bug 数量肉眼可见地减少了因为大部分问题在写代码的那一刻就被测试拦住了。最后说一个可能不太起眼但很实在的建议测试代码也是代码它会长期存在所以要像对待生产代码一样对待它。不要为了省事写那种只覆盖正常路径、没有任何断言的“伪测试”也不要害怕在测试代码里做重构。等你真正跑通一次完整的 TDD 循环并且靠着测试顺利做了一轮大重构之后你就明白这套流程为什么值得坚持了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价