最近和几个做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.6,TDD可以先放放,先升级版本比测试更紧急。
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 version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" colors="true" failOnRisky="true" failOnWarning="true" cacheDirectory=".phpunit.cache" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"> <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,这样测试时composer的自动加载就生效,不需要手动require每个类。
3. 完整TDD流程实操:一个订单折扣模块从零到能上线
3.1 项目背景与第一个需求
假设我们要给一个商城系统开发订单折扣功能,需求很简单:订单满500元打9折,满1000元打8折,不满500原价计算。这是我用来演示TDD流程最经典的场景,因为它足够清晰,又隐含了边界条件。
传统开发流程下,我会先写一个DiscountCalculator类,直接算出结果,然后再写测试验证。TDD流程完全反着来,先写测试。
3.2 红灯:第一个测试必须失败
在tests/Unit目录下创建DiscountCalculatorTest.php:
<?php declare(strict_types=1); 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_types=1); 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_types=1); 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_types=1)的加持下,弱类型陷阱少很多。
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_types=1); 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_types=1); 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_types=1)下,能有效暴露类型混乱问题。
浮点数比较还有一个经典坑。比如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-autoload |
| Target [XXX] is not instantiable | 依赖注入时接口没有绑定实现,检查ServiceProvider |
| There was 1 risky test | 测试没执行断言,可能是测试写漏了,用failOnRisky=true把它当错误暴露出来 |
| Too many connections | 每个测试都新建数据库连接,连接池被耗尽,用事务回滚模式避免反复连接 |
| Deprecated: Using static "setUp" is deprecated | PHPUnit 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变成摆设。既然有测试,就配CI(GitHub Actions、GitLab CI、Gitea Actions都可以),每次push自动跑全量测试,失败就直接拦截合并。不然测试跑与不跑没人关心,质量防线很快形同虚设。
5.4 我自己的一点收尾心得
写了十几年PHP,我和大多数同行一样,最初对TDD是抵触的,觉得“耽误时间”“不够灵活”。后来在几次线上事故之后才开始认真对待。现在我在项目里坚持“核心逻辑必须先写测试再写实现”的习惯,不是为了追求理论正确,而是贪图它在需求变更时给的安全感。真实经验是:当你连续维护一个接入了TDD的模块半年,每次改需求都能快速跑完测试,确定没改坏旧功能的时候,那种踏实感是裸写代码永远给不了的。
最后分享一个小技巧:TDD的测试用例最好“一个用例只验证一件事”。我见过太多团队一个test方法里又验证返回值、又验证异常、又验证mock调用次数,结果挂了之后要排查很久。拆开写,每个用例名字都写上期望,失败时一眼定位。这个习惯看起来微不足道,但实际折腾少了不是一点点。
TDD这条路的起点,就是老老实实写下第一个会失败的测试。只有真正跑过一个完整的红-绿-重构循环,你才会理解为什么这一小步,能带来这么大的改变。