PHP项目TDD落地实战:从测试环境到红绿重构全流程
发布时间:2026/9/9 1:33:11 锦皓数字建站

最近和几个做PHP的朋友聊天发现一个挺普遍的现象大家不是不知道TDD测试驱动开发Test-Driven Development这个词也不是没看过几篇介绍文章但真要在自己的项目里跑起来总觉得无从下手。项目工期紧、老板不催测试、PHP又不像某些语言那样有强制的测试文化于是“测试驱动开发”就变成了收藏夹里的概念偶尔翻出来看看关上继续写Bug。这篇东西我想换个角度聊不讲虚的直接说清楚一件事在一个普通PHP项目里TDD的完整流程到底是什么样。从环境搭建、测试框架选型到真实的“红-绿-重构”三阶段怎么走再到数据库、外部接口、老代码这些绕不开的麻烦怎么处理我会用我实际踩过的坑和总结出来的套路把整个过程掰开揉碎给你看。如果你是PHP开发者被TDD的“正确姿势”绕晕过或者想给团队引入一套能落地的测试流程那这篇文章应该能帮上忙。1. 先想清楚为什么在PHP里做TDD身边却没几个人在做1.1 TDD不是“写测试”是“开发顺序反过来”很多初学者对TDD的理解是“写代码的时候顺便写点测试”这是错的。TDD不是测试技术而是一种开发顺序先写一个会失败的测试再去写能让它通过的实现代码然后重构。整个过程像一个三步循环红灯写一个描述期望行为的测试运行它确认它是失败的因为对应的功能还不存在。绿灯写最少的实现代码让这个测试通过不追求优雅只追求正确。重构在测试保护下优化实现代码的结构消除重复、提升可读性每改一步都跑一遍测试确认没破坏行为。我当年第一次接触这个流程时最大的困惑是功能还没写我怎么知道测试怎么写后来才理解这正是TDD最核心的思维转变——你先把“完成”的定义写清楚再倒推实现。没有测试约束的实现很容易把“能跑”当“完成”而TDD逼着你先把验收标准定出来。1.2 PHP项目落地TDD的三座大山说句公道话PHP项目做TDD确实比其他语言辛苦原因不外乎三点。动态类型是第一个阻力。PHP变量没有固定类型函数传个数组进来还是字符串进来写的时候全靠自觉。这导致写测试时经常要处理类型边界问题比如一个方法接收参数你以为是int结果线上传入字符串“100”PHP的弱类型转换会静默地帮你转成数字测试环境却可能因为strict_types声明的差异直接报错。这类问题不是TDD本身能解决的但TDD可以让你更早暴露它们。历史技术债是第二座山。绝大多数PHP项目都是从老代码演进过来的没有测试甚至没有清晰的目录结构。在这样的代码上直接写测试光初始化环境就够折腾人。我见过很多团队一腔热血引入PHPUnit结果发现某个老类直接操作数据库连构造函数都绕不开DB连接测试根本没法实例化。这种时候先把TDD放一放补集成测试或者更基础的特性测试才是靠谱路线。工具链分散是第三个问题。PHP测试生态不算差PHPUnit是事实标准但加上Mockery、数据库迁移工具、容器化环境、CI/CD配置组合起来的学习成本并不低。很多人被卡在了“工具装不好”还没体验到TDD的好处就放弃了。1.3 为什么折腾这么多仍然值得既然这么麻烦为什么还要做我的亲身体会是TDD带来最大的收益不是“测试数量多”而是“改代码不慌”。PHP项目最容易翻车的就是改老模块数据库字段加一个、接口返回格式调一下、鉴权逻辑改个判断……手一抖线上就挂。有了测试等于给代码加了安全网改完跑一遍测试就知道哪些地方受影响。另一个好处是设计感。为了写测试你必须把“输入-输出”理清楚代码自然会往低耦合方向走。长期下来代码可维护性会肉眼可见地提升这不是凭空说的是TDD作为设计工具的一面。2. 准备环节搭建一个能跑TDD的PHP测试环境2.1 版本、镜像、Composer环境别凑合工欲善其事必先利其器。做TDD之前先把基础环境理顺。建议使用Docker镜像跑PHP不用在宿主机装一堆扩展污染环境。一个简单的PHP开发镜像可以用php:8.2-cli作为基础装一下sqlite、zip等常用扩展这样本地开发和CI环境保持一致避免“我机器上能跑”的尴尬。PHP 8.0以上是必须的。8.0引入了大量语法改进和性能提升8.2更稳定PHPUnit 10/11对PHP版本也有要求。如果项目还在PHP 5.6TDD可以先放放先升级版本比测试更紧急。Composer是现代PHP项目的命根子装依赖全靠它。TDD环境下PHPUnit应该作为require-dev依赖安装这样生产环境不会冗余安装。命令很简单composer require --dev phpunit/phpunit:^11.0安装完成后验证下vendor/bin/phpunit --version如果看到版本号说明安装成功。没有就检查Composer是否正常、PHP版本是否满足要求。2.2 目录结构测试代码别和业务代码混在一起PHP项目习惯根据框架来组织目录Laravel有app/、routes/、resources/ThinkPHP有app/。不管用哪种框架测试代码建议单独放在tests/目录下业务代码放在src/或app/下。一个典型结构项目根目录/ ├── app/ │ └── Services/ │ └── OrderService.php ├── src/ │ └── Discount/ │ └── DiscountCalculator.php ├── tests/ │ ├── Unit/ │ │ └── DiscountCalculatorTest.php │ └── Feature/ │ └── OrderFlowTest.php ├── vendor/ ├── composer.json └── phpunit.xml注意不是所有代码都要放tests/里做单元测试。像路由、中间件、模板渲染这类接近框架底层的部分更适合用特性测试或框架自带测试工具。真正把TDD做到位的是核心业务逻辑的计算类、服务类、工具类它们纯函数化程度高输入输出明确测试成本低收益高。2.3 phpunit.xml核心配置别忽略PHPUnit不靠命令行参数硬写一堆路径而是通过phpunit.xml来定义测试套件的加载方式、bootstrap文件、代码覆盖率报告等。一个精简配置如下?xml version1.0 encodingUTF-8? phpunit xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance bootstrapvendor/autoload.php colorstrue failOnRiskytrue failOnWarningtrue cacheDirectory.phpunit.cache xsi:noNamespaceSchemaLocationhttps://schema.phpunit.de/11.0/phpunit.xsd testsuites testsuite nameUnit directorytests/Unit/directory /testsuite testsuite nameFeature directorytests/Feature/directory /testsuite /testsuites source include directorysrc/directory /include /source /phpunitbootstrap指定vendor/autoload.php这样测试时composer的自动加载就生效不需要手动require每个类。配置是为了代码覆盖率统计只统计src目录。colors和failOnRisky这些属于细节优化让输出更友好、避免不严格的测试“假绿”。3. 完整TDD流程实操一个订单折扣模块从零到能上线3.1 项目背景与第一个需求假设我们要给一个商城系统开发订单折扣功能需求很简单订单满500元打9折满1000元打8折不满500原价计算。这是我用来演示TDD流程最经典的场景因为它足够清晰又隐含了边界条件。传统开发流程下我会先写一个DiscountCalculator类直接算出结果然后再写测试验证。TDD流程完全反着来先写测试。3.2 红灯第一个测试必须失败在tests/Unit目录下创建DiscountCalculatorTest.php?php declare(strict_types1); namespace Tests\Unit; use PHPUnit\Framework\TestCase; use App\Services\DiscountCalculator; class DiscountCalculatorTest extends TestCase { public function test_calculate_when_amount_less_than_500_returns_original(): void { $calculator new DiscountCalculator(); $this-assertSame(400.0, $calculator-calculate(400.0)); } }运行测试vendor/bin/phpunit --filter test_calculate_when_amount_less_than_500_returns_original tests/Unit/DiscountCalculatorTest.php预期结果是Class App\Services\DiscountCalculator not found红灯。这是TDD规定动作测试失败不是因为断言有问题而是因为实现类不存在说明我们确实是从“需求还没写代码”的状态开始。3.3 绿灯用最烂的实现让测试通过TDD要求实现代码越简单越好能过就行。为了让测试通过我只需要创建一个类和方法返回一个固定值即可?php declare(strict_types1); namespace App\Services; class DiscountCalculator { public function calculate(float $amount): float { return $amount; } }再运行测试结果变绿。这一步看起来很简单甚至有点“作弊”但这是TDD有意为之先确认测试是有效的能测出类缺失/方法缺失再用最小实现满足需求。如果一开始就写一堆复杂代码万一测试失败无法判断是测试问题还是实现问题。TDD这种“小步快跑”的节奏就是为了把“出错的变量”控制到最小。3.4 增加第二个需求边界与真实逻辑现在给测试文件追加一个用例满500打9折。public function test_calculate_when_amount_equals_500_applies_nine_discount(): void { $calculator new DiscountCalculator(); $this-assertSame(450.0, $calculator-calculate(500.0)); }运行红灯。此时实现代码需要加入真实逻辑public function calculate(float $amount): float { if ($amount 500 $amount 1000) { return $amount * 0.9; } return $amount; }试运行绿灯。接着再追加满1000打8折的测试再改实现public function calculate(float $amount): float { if ($amount 1000) { return $amount * 0.8; } if ($amount 500) { return $amount * 0.9; } return $amount; }一步步来每个测试都是先红灯再绿灯。这段过程看起来平淡但这就是TDD的本意——每一步都小到不会出错积累起来却能形成足够完整的行为保障。3.5 数据提供器不要用复制粘贴写测试需求还有一个隐含条件边界值。400元保持原价499.99也算原价500整打9折999.99打9折1000整打8折。每写一个边界值就单独写一个test方法太啰嗦了PHPUnit支持Data Provider数据提供器可以把测试数据和期望结果集中定义?php declare(strict_types1); namespace Tests\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use App\Services\DiscountCalculator; class DiscountCalculatorTest extends TestCase { #[DataProvider(discountProvider)] public function test_calculate(float $amount, float $expected): void { $calculator new DiscountCalculator(); $this-assertSame($expected, $calculator-calculate($amount)); } public static function discountProvider(): array { return [ 金额小于500不变 [400.0, 400.0], 金额为500打9折 [500.0, 450.0], 金额为999.99打9折 [999.99, 899.991], 金额为1000打8折 [1000.0, 800.0], 金额为1500打8折 [1500.0, 1200.0], ]; } }注意测试名用了中文键名输出时更容易定位失败场景。assertSame不仅比较值还比较类型400被传成float返回的也必须是float在declare(strict_types1)的加持下弱类型陷阱少很多。3.6 重构阶段测试保护下优化代码现在所有测试都通过了可以停下来审视实现。目前看起来还过得去但设想如果折扣档位越来越多满2000打7折、满5000打6折、还有一种VIP用户额外95折……嵌套if会越来越难看。重构阶段可以引入一个配置数组public function calculate(float $amount): float { $discounts [ 1000 0.8, 500 0.9, ]; foreach ($discounts as $threshold $discount) { if ($amount $threshold) { return $amount * $discount; } } return $amount; }每改一次跑一遍PHPUnit所有用例继续通过说明重构没有破坏行为。这个“安全网”体验就是TDD最让人上瘾的地方。没有测试你根本不敢这样重构有了测试改起来心里有底。这也回答了很多人“重构到底怎么才敢动手”的疑问——不是靠勇气是靠测试。3.7 集成到框架路由和依赖注入也别裸写真实项目里DiscountCalculator不会在单元测试里new一下这么简单它会注册到容器被Controller调用。Laravel里通常这样绑定$this-app-bind(DiscountCalculator::class, function ($app) { return new DiscountCalculator(); });然后Controller通过构造函数注入调用。这一步更多属于框架使用范畴但TDD的原则是相通的先从核心逻辑的纯函数测试做起再向HTTP层、接口层、数据层一层层扩散。我在实际项目中通常先保证核心服务类的测试这类测试稳定、快、不依赖外部环境能给重构兜底。HTTP层测试更脆一旦路由变化就大面积失败不作为TDD初期重点。4. 绕不开的现实问题外部依赖、数据库、老代码4.1 外部API和数据库怎么测Mock与依赖注入业务里很少有纯函数大多会调用API、查数据库、读缓存。这就是TDD被很多人放弃的干扰项这些外部资源本地没有测试跑不起来。解决办法不是不去测而是用“替身”。PHPUnit自带Mock功能比如OrderService依赖一个远程优惠券接口在测试中不真的发HTTP请求而是建一个“假接口”?php declare(strict_types1); namespace Tests\Unit; use PHPUnit\Framework\TestCase; use App\Services\OrderService; use App\Contracts\CouponApiInterface; class OrderServiceTest extends TestCase { public function test_apply_coupon_uses_api(): void { $api $this-createMock(CouponApiInterface::class); $api-method(getCouponValue) -willReturn(50.0); $service new OrderService($api); $this-assertSame(50.0, $service-applyCoupon(CODE123)); } }createMock会创建一个实现CouponApiInterface的替身getCouponValue方法返回固定值。这样测试OrderService时不需要真实的HTTP网络和KEY也不会因为网络波动导致测试变红。但要注意Mock的作用是隔离依赖而不是用来验证远端接口本身接口本身的请求/返回逻辑要单独做集成测试不能靠Mock覆盖。4.2 数据库相关代码怎么测事务回滚技巧数据库是PHP测试里最头疼的部分。我的工程实践是区分两类测试涉及SQL语句正确性的测试放Feature测试里跑真数据库只涉及业务逻辑的用Repository的Mock足够。真数据库测试最实用的一招是“事务包裹回滚”。在测试基类的setUp里开启事务tearDown里回滚这样每条测试数据写完就消失不会污染数据库?php declare(strict_types1); namespace 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:); // 创建表结构、加载fixture $this-pdo-beginTransaction(); } protected function tearDown(): void { $this-pdo-rollBack(); parent::tearDown(); } }这套思路在MySQL、PostgreSQL下也适用只需换成对应PDO连接串。SQLite内存库跑得快但有些SQL语法差异要留意反映不了线上真实情况所以关键SQL还是要连真库测一遍。另一个常用技巧是用Fixture在测试前插入一份固定的初始数据测试过程中只读不写或写后回滚。这样用例之间互不干扰。4.3 老代码没有测试怎么办先织一张“临时安全网”给一个没有测试的遗留PHP项目补TDD难度最大因为原有代码结构设计时根本就没考虑过可测试性。我的做法是三步走第一步找“活点”。优先选择改动频率高、业务逻辑复杂、出过线上问题的方法比如订单优惠计算、库存扣减、状态流转这些。第二步记录现有行为。通过日志或直接读代码搞清楚“当前实际输出是什么”把这些输出写成特征测试。哪怕当前结果本身有Bug也先拿测试锁住保证重构过程中行为不变等确认后再改期望结果。第三步在测试保护下小块重构。老类一般是一把梭的God Object构造函数里new一堆依赖测试时根本无法实例化。这时候要么用Reflection强行注入更推荐的是逐步把依赖改成构造参数传入一次拆一个。这个过程要克制一天最多拆十几个方法每拆一个就全量跑一遍测试。4.4 弱类型与精度陷阱assertSame和浮点数比较PHPUnit的断言里assertEquals和assertSame差别很大。assertEquals比较宽松1和“1”会判断相等assertSame要求值和类型都一致更严格。我建议单元测试能上assertSame就不要用assertEquals尤其在declare(strict_types1)下能有效暴露类型混乱问题。浮点数比较还有一个经典坑。比如0.1 0.2 ! 0.3直接assertSame(0.3, $result)大概率失败因为浮点数精度问题。折扣计算涉及金钱时推荐用整数分做单位或者用bccomp做比较断言时也注意$this-assertEqualsWithDelta(899.991, $result, 0.001);assertEqualsWithDelta可以指定误差范围适合浮点数结果。真实支付场景则强烈建议用金额转“分”为整数避免一切精度问题。4.5 常见运行错误速查别让配置把节奏带崩我在带团队时遇到最频繁的报错整理成一张速查表报错信息原因与解决办法Class XXX not found没有add file到composer autoload或类名与路径不匹配运行composer dump-autoloadTarget [XXX] is not instantiable依赖注入时接口没有绑定实现检查ServiceProviderThere was 1 risky test测试没执行断言可能是测试写漏了用failOnRiskytrue把它当错误暴露出来Too many connections每个测试都新建数据库连接连接池被耗尽用事务回滚模式避免反复连接Deprecated: Using static setUp is deprecatedPHPUnit 10以上版本有些基类方法改成了非静态检查方法签名Failed asserting that 0 matches expected 0类型不匹配字符串“0”和整型0检查strict_types是否声明、参数返回值是否带类型这些问题大多数不是TDD本身的问题而是“测试环境不够隔离”和“配置不规范”导致的。环境问题可以靠Docker解决配置问题建议统一使用phpunit.xml不要手动在命令行灌一堆参数。5. 什么时候该上TDD什么时候别硬上5.1 最适合TDD的PHP项目类型根据经验下面几类项目最值得引入TDD业务规则复杂的系统比如电商的订单状态机、优惠计算、库存库存同步。这些逻辑一个小数点就能让公司亏钱测试覆盖的价值极大。第三方SDK适配层。对接微信支付、短信服务、对象存储等外部接口不靠谱很容易因为接口字段调整导致业务挂掉。用Mock把外部接口测住内部的字段映射和异常处理逻辑就能稳定校验。REST API的字段组装和鉴权逻辑。接口返回结构一变前端就崩。这类用特性测试保护可以防止“改一个底层字段把线上接口带崩”的灾难。5.2 哪些场景别硬上TDD一次性脚本、安装向导页面、纯模板渲染页面。这些代码写一次跑完就扔掉或者高度依赖框架运行时行为测试性价比低到负数。代码还没稳定、需求每周大变的探索型项目。TDD适合需求逐渐趋于稳定的场景如果业务本身还在薛定谔状态测试写一个改一个还不如先做原型验证。老掉牙的PHP 5.6项目且没有Composer、没有命名空间。这种项目想要TDD第一步不是写测试而是先引入Composer和namespace否则光autoload就劝退。5.3 团队推广TDD的三个务实建议不要一上来就要求100%覆盖率。先把核心业务逻辑盖住比覆盖率数字好看重要得多。不要指望每个人第一天就写漂亮的测试。TDD是个手艺活写一天测试和写一年测试的人写出来的用例质量差很远。可以考虑先让团队骨干在几个核心模块跑通完整流程沉淀一套标准示例其他人照着模板写。不要让CI变成摆设。既然有测试就配CIGitHub Actions、GitLab CI、Gitea Actions都可以每次push自动跑全量测试失败就直接拦截合并。不然测试跑与不跑没人关心质量防线很快形同虚设。5.4 我自己的一点收尾心得写了十几年PHP我和大多数同行一样最初对TDD是抵触的觉得“耽误时间”“不够灵活”。后来在几次线上事故之后才开始认真对待。现在我在项目里坚持“核心逻辑必须先写测试再写实现”的习惯不是为了追求理论正确而是贪图它在需求变更时给的安全感。真实经验是当你连续维护一个接入了TDD的模块半年每次改需求都能快速跑完测试确定没改坏旧功能的时候那种踏实感是裸写代码永远给不了的。最后分享一个小技巧TDD的测试用例最好“一个用例只验证一件事”。我见过太多团队一个test方法里又验证返回值、又验证异常、又验证mock调用次数结果挂了之后要排查很久。拆开写每个用例名字都写上期望失败时一眼定位。这个习惯看起来微不足道但实际折腾少了不是一点点。TDD这条路的起点就是老老实实写下第一个会失败的测试。只有真正跑过一个完整的红-绿-重构循环你才会理解为什么这一小步能带来这么大的改变。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。