资讯详情

PHP单元测试实战:PHPUnit基础用法与Mock数据库测试策略

📅 2026/10/11 3:38:43 | 华诺云谱 👁 阅读
PHP单元测试实战:PHPUnit基础用法与Mock数据库测试策略
写单元测试这件事我最早是拒绝的。那时候觉得代码能跑就行写测试等于多写一遍业务逻辑纯粹浪费时间。直到有一次一个看似人畜无害的改动把线上一个用了很久的订单状态判断搞坏了回归测试全是绿的代码评审也没看出来最后是用户先发现的。从那以后我才意识到所谓代码能跑很多时候只是在特定的数据下能跑。没有测试托底改代码就像在没有护栏的山路上开车看着没事出事就是大事。PHPUnit 是 PHP 生态里事实上的单元测试标准绝大多数主流 PHP 项目和框架的测试底层都是它。这篇文章我不会去复述官方文档而是从实际项目出发讲讲我是怎么设计测试结构、怎么写第一个测试用例、怎么处理 Mock 和数据库测试以及这些年踩过的坑。无论你是刚接触测试的新手还是已经写了一阵子但总觉得哪里不对的老手这篇内容应该都能给你一些参考。1. 测试前先想清楚到底在测什么很多人写测试的第一步就是打开编辑器开始写断言我觉得这是最容易走偏的地方。测试的本质不是给代码验尸而是把代码应该有的行为用另一套语言固定下来让未来的每一次修改都有据可查。1.1 单元测试锁定的是行为契约不是实现细节假设你有一个订单校验类里面有个方法叫isValid()它检查订单金额是否大于零、状态是否合法。新手可能会这样测断言的不是isValid()返回了什么而是它在什么输入下应该返回什么。一旦将来有人把订单状态从字符串改成了整数枚举assertEquals(paid, $order-getStatus())这种断言就必然失败但业务逻辑其实没坏。这就是在锁定实现细节而不是行为契约。我建议你把测试当作一份可执行的文档来写。每个测试方法回答一个问题这个组件在被调用时和我约定好的行为是否一致只要约定没变哪怕你把内部实现整个重写测试也应该继续通过。这样测试才能真正保护你而不是在你重构的时候变成阻碍。1.2 什么样的代码值得写单测一个简单的判断标准无脑追求百分之百覆盖率没有意义。我现在的判断标准很简单这个代码有没有分支逻辑要不要根据输入做出不同决策calculateTotal()里面有一个if根据会员等级决定折扣率要测。一个纯 getter直接返回私有属性的值你可测可不测我通常不写。数据库迁移脚本、配置加载这类接近框架底层的代码一般也不写单元测试因为那属于集成测试的范畴硬写单测会非常别扭。另一个判断维度是这段代码出错的代价有多大。订单金额计算错了是要赔钱的用户头像上传逻辑错了顶多难看一点。优先把时间投到高风险、多分支的代码上收益率最高。我给模拟项目X里的订单服务写测试时订单状态流转和金额计算的测试数量占了整个项目测试量的六成以上这些恰好都是业务里最核心、最容易改坏的逻辑。2. 环境准备与项目结构设计工具链的配置直接决定了后续写测试的体验。很多项目测试难写根源不在测试本身而是从一开始就没把测试环境设计好。2.1 用 Composer 安装 PHPUnit版本不能乱选安装 PHPUnit 最省事的方式就是 Composer这也是 PHP 社区最主流的做法。在项目根目录执行composer require --dev phpunit/phpunit装到require-dev而不是require是因为测试工具只存在于开发环境不应该被打包到生产代码里。版本选择上有一个容易踩的坑PHPUnit 的大版本和 PHP 版本有严格的对应关系。比如 PHPUnit 10 以上要求 PHP 8.1 起步PHP 7.4 项目只能上 PHPUnit 9。装完之后用vendor/bin/phpunit --version确认一下比把项目搞崩再回头排查要快得多。如果你用的是某主流 PHP 框架框架本身通常会捆绑对应的 PHPUnit 版本直接用框架提供的命令就行不用重复安装。手动安装前先看一眼项目 composer.json 里的php版本约束避免版本冲突。2.2 phpunit.xml 配置里容易被忽略的关键项PHPUnit 默认会找phpunit.xml或phpunit.xml.dist。我一般用.dist作为模板提交到版本库每个开发者本地可选的配置写在.xml里不要提交。一个最基本的配置长这样?xml version1.0 encodingUTF-8? phpunit xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance bootstrapvendor/autoload.php colorstrue failOnWarningtrue failOnRiskytrue testsuites testsuite nameunit directorytests/Unit/directory /testsuite /testsuites source include directorysrc/directory /include /source /phpunit几个容易忽略的点bootstrap必须指向vendor/autoload.php否则测试类加载不出来。failOnWarningtrue告诉 PHPUnit 只要出现 warning 就算失败。这个配置我强烈建议打开很多隐藏问题比如断言写得不对但不影响结果一开始冒不出来等到出了问题才追悔莫及。failOnRiskytrue用来标记那些没有断言就通过的测试防止有人写出假测试。目录结构上我习惯把测试分成tests/Unit和tests/Feature两个目录Unit 下放真正隔离的单元测试Feature 下放带数据库、带 IO 的集成型测试。这样跑测试的时候可以按套件区分开发时只跑单元测试能节省大量时间。2.3 测试目录和源码目录保持镜像对应这一点算是我的经验之谈踩过一次坑才明白的。最开始我把所有测试文件一骨碌堆在tests目录下类名随便起时间一长根本分不清哪个测试对应哪段源码。后来改成镜像结构src/ Service/ OrderService.php tests/ Unit/ Service/ OrderServiceTest.phpOrderServiceTest.php和OrderService.php的相对路径一模一样找起来几乎不用动脑。新加入项目的同事也能根据这个规律快速定位测试文件。另外测试类名建议统一用被测类名 Test的格式PHPUnit 默认也是按照这个约定去发现测试方法的。3. 从第一个测试用例开始断言与命名测试代码写得好不好命名和断言的选择占了很大比重。我见过很多测试断言全用assertTrue失败信息全靠猜调试成本极高。3.1 测试方法命名描述行为而不是描述功能假设你在测试订单服务的createOrder()方法一个合格的测试方法名应该让人一眼看出验收标准。我喜欢用当……时应该……的句式来起名public function testCreateOrderWithZeroAmountShouldThrowException(): void { // ... } public function testCreateOrderWithValidDataShouldReturnOrderId(): void { // ... }比起testCreateOrder1、testCreateOrder2这种命名行为描述式的方法名既是测试名又是业务文档。测试失败的时候你从报告里看到的方法名能直接告诉你哪一个业务约定被破坏了不需要再点开文件去猜。3.2 常用断言的使用场景与注意事项PHPUnit 的断言方法很多但常用的核心就那么几个关键在于选对。我列一下我自己的选型习惯assertSame和assertEquals是最容易被搞混的两个。assertSame是严格比较要求类型和值都相等而assertEquals只比较值1和1会被当成相等。我现在基本只用assertSame因为框架底层的类型逻辑出乎意料的多用严格断言能提前暴露类型隐患。assertNull比assertTrue($var null)可读性好得多失败时的报错也更清晰直接告诉你是 null 还是非 null。assertInstanceOf用来验证返回对象的类型适合测试工厂类或者服务容器。assertCount用来断言数组或集合的元素个数比如测试订单明细筛选逻辑时非常直观。public function testDiscountCalculationForVipUser(): void { $calculator new DiscountCalculator(); $discount $calculator-calculate(100, vip); $this-assertSame(85, $discount); $this-assertSame(85.0, $discount); }这个例子其实是个反面教材我把两个断言写在一起是想说明断言要贴合业务语义。如果你要的是打了八五折100 块应收 85那么assertSame(85, $discount)就够了后面的assertSame(85.0, $discount)纯粹多余还容易因为浮点比较产生意外失败。浮点数比较通常不推荐直接断言相等真遇到金额场景先转成整数分再比较或者用assertEqualsWithDelta带误差比较。3.3 setUp 与 tearDown夹具的正确用法每个测试方法执行前PHPUnit 会调用一次setUp()执行后调用一次tearDown()。很多人以为 setUp 是用来随便初始化点东西的其实它有一个更重要的语义让每个测试在干净、可预期的状态下开始。class OrderServiceTest extends TestCase { private OrderService $service; private OrderRepository $repository; protected function setUp(): void { parent::setUp(); $this-repository new InMemoryOrderRepository(); $this-service new OrderService($this-repository); } public function testOrderCreationPersistsOrder(): void { $order $this-service-createOrder([amount 100]); $this-assertSame(1, $this-repository-count()); } }把重复的初始化逻辑放进 setUp每个测试方法体只关注自己的核心逻辑可读性直接上一个台阶。需要特别注意的是不要在 setUp 里做太重的操作比如启动网络连接、加载真实数据库那会把每个测试都拖慢而且这些都属于集成测试的范畴。4. 进阶数据供给器、测试依赖与有状态场景真实业务里一个方法往往要处理很多种输入组合。如果全写在单个测试方法里要么是大量重复代码要么是多个断言耦合在同一个用例里失败的时候很难定位到底哪一组数据出了问题。4.1 数据提供器用数据驱动消灭重复PHPUnit 提供了数据提供器Data Provider机制可以用属性声明的方式给同一个测试方法喂多组数据。use PHPUnit\Framework\Attributes\DataProvider; class OrderAmountValidatorTest extends TestCase { #[DataProvider(provideAmounts)] public function testAmountValidation(float $amount, bool $expected): void { $validator new OrderAmountValidator(); $this-assertSame($expected, $validator-isValid($amount)); } public static function provideAmounts(): array { return [ zero amount [0.0, false], positive amount [10.0, true], negative amount [-5.0, false], very large amount [1_000_000.0, true], ]; } }每个数据集的键名比如zero amount会出现在测试输出中失败时能直接看出是哪种情况失败排查效率高很多。数据供给器的返回值必须是数组套数组的结构外层是测试方法的每次执行内层是传给方法的参数。这个机制用在输入输出对照非常爽但要注意数据供给器本身不要塞进太复杂的业务逻辑否则数据供给侧变成藏 bug 的温床。4.2 测试依赖慎用但有时候真香PHPUnit 可以显式声明某个测试方法依赖于另一个测试方法的返回值。use PHPUnit\Framework\Attributes\Depends; public function testCreateOrderReturnsOrderId(): int { $orderId $this-service-createOrder([amount 100]); $this-assertGreaterThan(0, $orderId); return $orderId; } #[Depends(testCreateOrderReturnsOrderId)] public function testGetOrderAfterCreation(int $orderId): void { $order $this-service-getOrder($orderId); $this-assertSame(100, $order[amount]); }依赖测试的好处是能写出衔接自然的状态化用例。但我的经验是能不用尽量不用因为依赖一旦建立测试之间就产生了顺序耦合一段代码改动可能导致一连串测试连锁失败定位问题反而麻烦。真到了必须要用到前一个测试的结果的情况往往是测试设计有味道更好的方案是把共享状态拆到 setUp 里或者干脆用内存版仓储来构造数据。4.3 异常测试与边界场景测试异常的分支常有人用try...catch自己抓异常再断言其实 PHPUnit 提供了更简洁的方式public function testCreateOrderWithNegativeAmountThrowsException(): void { $this-expectException(InvalidArgumentException::class); $this-expectExceptionMessage(amount must be positive); $this-service-createOrder([amount -5]); }expectException的语义是接下来的代码执行过程中应该抛出指定异常。要注意的是如果被测方法内部把异常吞掉了这个测试就会失败而这恰恰是我们想要的异常就应该让上层知道。边界场景比如空字符串、零值、超大数字建议反复确认大量线上事故都发生在边界条件处理不当。5. 替身对象Mock 与 Stub 的实用套路单元测试的核心要求是隔离。被测类往往会依赖数据库连接、外部 HTTP API、缓存服务等如果每个测试都打到真实依赖上速度慢不说还会因为外部服务波动而间歇性失败。替身对象就是为了解决这个问题。5.1 为什么需要 Mock先理解被测单元的边界依赖注入是 Mock 的前提。假设OrderService构造函数里直接new PaymentGateway()那这段代码基本没法单测网关要是欠费了、断网了、接口升级了你的测试都会跟着挂你明明只改了个计算逻辑。把依赖通过构造函数或方法参数传进来测试时才能替换成替身对象。class OrderService { public function __construct( private PaymentGatewayInterface $paymentGateway, private OrderRepositoryInterface $repository ) { } }PaymentGatewayInterface 是一个接口生产环境传入真实网关实现测试环境传入 Mock。这样OrderService的单元测试彻底和外部服务解耦跑再多次都是稳定的。5.2 常用 Mock 场景返回值、异常、调用次数PHPUnit 的 Mock 语法其实非常直白。use PHPUnit\Framework\MockObject\MockObject; public function testOrderPaymentSuccessMarksOrderAsPaid(): void { $gateway $this-createMock(PaymentGatewayInterface::class); $gateway-method(charge) -with(100) -willReturn(true); $repository $this-createMock(OrderRepositoryInterface::class); $repository-method(findById) -with(1) -willReturn([id 1, amount 100, status pending]); $service new OrderService($gateway, $repository); $result $service-payOrder(1); $this-assertSame(paid, $result[status]); }这里method(charge)-with(100)-willReturn(true)的含义是当charge被调用且参数是100时返回true。如果某个调用参数不符合预期Mock 会在断言阶段直接报错告诉你期望的参数和你实际调用的不一致。如果你希望验证某个依赖方法至少被调用一次可以用expects($this-once())$gateway-expects($this-once()) -method(charge) -with(100) -willReturn(true);配合once()、never()、exactly(3)这类期望次数可以验证核心业务逻辑的调用路径是否符合设计。比如一笔退款你预期只调用一次退款接口如果代码不小心调了两次测试就会立刻失败这就是 Mock 在守护行为契约。5.3 Mock 过度使用的坑Mock 虽好但绝不是越多越好。过度 Mock 会让测试和实现细节绑得死死的尤其在重构的时候你可能只挪了个方法位置Mock 的调用顺序变了测试原地爆炸这种情况很打击人。我给自己定过三条规则第一能用内存实现就不用 Mock。比如内存版仓储一个简单的数组实现就能模拟数据库行为比 Mock 一个接口清晰得多也更能暴露业务逻辑问题。第二只 Mock 自己的边界依赖比如第三方支付网关、短信服务。系统内部的数据存取优先考虑真实的内存实现。第三Mock 的期望不要写得过细如果你根本不关心charge是否被调用就不要加expects($this-once())多余的限制只会让测试变脆。6. 数据库相关的单元测试策略数据库测试是很多 PHP 项目的痛点一个原因是数据库状态不干净导致测试互相影响。我的策略很简单把仓储层和业务层分开处理。6.1 内存数据库加事务回滚对仓储层做测试时真实数据库往往太重。可以改用 SQLite 内存数据库在setUp里建表、在测试结束自动丢弃测起来飞快。如果你必须用 MySQL 这样真实的数据库也能用事务回滚的思路在setUp里开启事务tearDown里回滚这样每个测试见过的数据都只存在于自己的事务里互不干扰。protected function setUp(): void { parent::setUp(); $this-connection $this-createConnection(); $this-connection-beginTransaction(); } protected function tearDown(): void { $this-connection-rollBack(); parent::tearDown(); }这里要注意某些框架会开启隐式提交事务回滚可能不生效需要确认框架的数据库连接配置允许你手动控制事务。此外在事务里测出来的结果和真实提交后的结果可能有微妙的差异比如自增主键的值、触发器的行为。如果对这类行为特别敏感建议单独再跑几个不依赖事务的集成测试做补充。6.2 业务层测试里用替身代替数据库业务层比如 OrderService测试时我倾向于完全不碰数据库而是在内存数组里维护一份假仓储。class InMemoryOrderRepository implements OrderRepositoryInterface { private array $orders []; private int $nextId 1; public function findById(int $id): ?array { return $this-orders[$id] ?? null; } public function save(array $order): int { $this-orders[$this-nextId] $order; return $this-nextId; } }这么做的理由很简单业务层测试专注在命名状态流转上数据库连接不是它关心的部分。内存仓储把 IO 全部抽掉测试速度极快一万个用例跑下来也就几秒方便本地开发时频繁执行。数据库有关的逻辑SQL 语句、索引执行计划单独留给仓储层测试。一层负责业务正确性一层负责数据访问正确性各司其职。真实项目里把两层混在一起写测试时间一长你根本分不清失败是因为业务逻辑坏了还是因为表结构变了。7. 运行、调试与持续集成写完了测试接下来就是日常运行和自动化的部分。这部分我整理了几个实际项目里用得很顺的命令和配置习惯。7.1 命令行运行与过滤器单个测试文件vendor/bin/phpunit tests/Unit/Service/OrderServiceTest.php只跑某个方法vendor/bin/phpunit tests/Unit/Service/OrderServiceTest.php --filter testCreateOrderWithZeroAmountShouldThrowException按组运行。如果你给测试加了#[Group(slow)]属性就能这样只跑慢测试或跳过慢测试vendor/bin/phpunit --group slow vendor/bin/phpunit --exclude-group slow日常开发的建议先把所有测试跑一遍然后结合--filter只跑你正在改的那个模块快速迭代。提交代码前再全量跑一次保证没有连带破坏。7.2 代码覆盖率看重点别只看数字生成 HTML 格式的覆盖率报告vendor/bin/phpunit --coverage-html build/coverage打开build/coverage/index.html能看到每个文件的覆盖详情。覆盖率数字会被人当成 KPI但我提醒一句它代表这些行被执行了不代表这些行为被验证了。出现过很多次覆盖率上了 90%、业务逻辑照样通的情况因为那句代码是被执行了但没人断言它的输出结果。我一般重点看对分支逻辑、条件组合的覆盖是否充分尤其是新增的if和异常路径有没有对应的测试。7.3 持续集成里的基础接入方式在持续集成环境里PHPUnit 通常只需要一个标准步骤composer install --no-interaction vendor/bin/phpunit流水线里如果配了failOnWarning和failOnRiskyPHPUnit 返回非零退出码流水线就会自动标红防止不合格的代码合入主分支。实测下来这一套配置很稳唯一的额外建议是CI 里不要跑那些需要外部服务的测试组把它们单独摘出来放到集成测试阶段不然每次流水线都因为短信服务不稳定而挂会很打击团队用测试的信心。8. 常见问题与排查技巧实录最后这部分是这些年真实踩过的坑。每个坑背后都是一段不明所以的调试时间整理出来希望你能绕开。8.1 测试提示找不到类怎么办大部分情况是 Composer 自动加载没有把测试目录加进去。检查项目根目录的autoload-dev配置{ autoload-dev: { psr-4: { Tests\\: tests/ } } }修改完执行composer dump-autoload。如果测试类继承框架的基类还要确认框架提供的测试工具类是不是也被自动加载了。8.2 Mock 期望失败明明调用了却说没调用Mock 期望失败最常见的两个原因一是参数对不上。你with(100)但代码里调用charge(100.0)或charge(100)在严格模式下都会判定为不一致。排查时可以先把with去掉看测试是否变绿再用debug输出实际参数。二是同一个 Mock 被调用两次但期望写的是once()。代码里面可能无意中把同一个操作执行了两遍这本身就是业务逻辑的问题测试正确地抓到了它。8.3 测试之间互相污染数据串台很多人用真实数据库做测试一套跑下来感觉没问题单跑某个文件就失败。典型的污染来源是测试 Ainsert了一条数据没清理测试 B 查询到这条多余的数据断言就炸了。解决办法是坚持隔离原则每个测试尽量不依赖外部状态非用数据库不可就上事务回滚或者每条数据用唯一的前缀字段来区分。共享静态变量也需要警惕PHP 进程内静态变量在整个测试运行期间不会重置不少隐蔽的随机失败都是它引起的。另外一个容易忽略的细节PHPUnit 默认是并行测试吗不是默认是串行的但如果你的项目配置了并行扩展文件之间的状态隔离问题会被放大要特别小心数据库和静态变量。8.4 测试全绿但代码上线还是出问题这种情况最头疼。排查思路一般是先看测试是不是没测到真正的业务路径。比如你测了calculate()方法本身但控制器里传入的字段名和calculate()期望的不一致单测根本没覆盖到参数映射那层。这时候需要补一层集成测试或者把那层映射逻辑抽成一个小函数单独测。覆盖率数字只是参考真正要关注的是核心路径有没有在测试里走通。写在最后我的几条实操经验用 PHPUnit 这么多年最深的体会倒不是哪一个 API 用法而是测试的设计比测试的数量重要这个朴素的道理。同一个功能测在业务层可以通过内存仓储快速跑完测在框架集成层就要起全套环境前者你愿意每次改完都跑一遍后者你可能一天都懒得跑一次。次数多了维护频率上去了项目质量才是真的稳。另外一个经验是测试代码也是代码要维护要重构。你新增一个字段记得同步改测试的断言你重命名一个方法用搜索把这些测试方法名一道改了别留着不合时宜的测试装样子。测试变成摆设比没有测试还危险因为它给你虚假的安全感。最后分享一个小技巧跑测试的时候多用--testdox参数。它会把你testCreateOrderWithZeroAmountShouldThrowException这种方法名转换成一行行为描述的输出看起来就像创建订单 金额为零时 应该抛异常。跑一遍测试等于过一遍业务行为清单这种反馈比单纯的通过两字有价值得多。工具是好工具关键看你怎么用好它。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑