2.8 测试:发车前的例行检修 本节摘要:测试是把"手动验证"固化为"自动检修":改完代码一键跑全部检查项,改坏什么当场现形。本节讲 PHPUnit 的最小上手路径、一个校验函数从手测到自动化改造的全过程、断言与数据提供器、测试金字塔的分层比例,以及"什么值得测、什么不值得"的取舍。目标不是测试驱动开发的完整方法论,而是让你从下一行业务代码起就有检修意识。 为什么要给代码配检修工 回忆 1.2 与 1.3 的校验逻辑:写完你怎么确认它对?多数人的答案是在浏览器里填一遍表单。这招的麻烦在于每次改动都得重填一遍,而且填得全不全全凭心情——改到第三轮,早忘了边界值那一组。自动化测试把"填表单"写成代码,一次写好,永久复用;更重要的是它给了你重构的胆量:2.1 节把函数整编成类、2.
本节摘要:测试是把"手动验证"固化为"自动检修":改完代码一键跑全部检查项,改坏什么当场现形。本节讲 PHPUnit 的最小上手路径、一个校验函数从手测到自动化改造的全过程、断言与数据提供器、测试金字塔的分层比例,以及"什么值得测、什么不值得"的取舍。目标不是测试驱动开发的完整方法论,而是让你从下一行业务代码起就有检修意识。
回忆 1.2 与 1.3 的校验逻辑:写完你怎么确认它对?多数人的答案是在浏览器里填一遍表单。这招的麻烦在于每次改动都得重填一遍,而且填得全不全全凭心情——改到第三轮,早忘了边界值那一组。自动化测试把"填表单"写成代码,一次写好,永久复用;更重要的是它给了你重构的胆量:2.1 节把函数整编成类、2.5 节把循环查询合并批量,这类动作没有测试兜底就是走夜路。
先安装 PHPUnit(走 2.7 节的采购处,作为开发期依赖):
composer require --dev phpunit/phpunit # 装完验证: ./vendor/bin/phpunit --version
拿 1.3 节的 checkAge 开刀,写出第一批测试:
<?php use PHPUnit\Framework\TestCase; require __DIR__ . '/../src/validators.php'; // 演示直接引入;正规项目靠自动加载 class CheckAgeTest extends TestCase { public function testNormalAgePasses(): void { $this->assertNull(checkAge(28)); // 通过时返回 null } public function testAgeOutOfRangeFails(): void { $this->assertNotNull(checkAge(0)); // 边界外返回错误文案 $this->assertNotNull(checkAge(121)); } public function testErrorMessageMentionsField(): void { $this->assertStringContainsString('年龄', (string) checkAge(-1)); } }
./vendor/bin/phpunit tests/ # 输出末行:OK (3 tests, 3 assertions) —— 全绿即检修通过
三个断言各有用意:assertNull 钉住"通过"契约,assertNotNull 钉住"拒绝"契约,assertStringContainsString 钉住文案要素(前端要靠它提示)。跑一下试试:故意把 checkAge 里的上限从 120 改成 12,测试立刻变红——这就是检修工的价值,改动引发的问题不用等旅客发现。
同一段代码测多组输入时,用数据提供器避免复制粘贴:
<?php #[\PHPUnit\Framework\Attributes\DataProvider('badAges')] public function testRejectsBadAges(int $bad): void { $this->assertNotNull(checkAge($bad)); } public static function badAges(): array { return [[0], [-5], [121], [9999]]; // 一组边界值,逐个自动喂给测试 }
测试按"离月台多远"分三层,数量呈金字塔分布:
底层单元测试占大头:速度快(毫秒级)、定位准,职责是钉死每个函数与类的契约;中层集成测试验证模块协作,比如"预处理的更新语句在数据库里真的生效",要用真实数据库;顶层功能测试模拟完整请求,数量最少但最接近旅客视角。比例失衡的典型病是"全靠顶层手点式测试"——慢、脆、失败了一头雾水。给新手的分寸建议:业务函数与类多写单元测试;数据库与外部交互选关键路径写集成测试;每个接口留一条能跑通全链路的功能测试。
2.2 节的"下单锁座"事务值得专门配集成测试,因为它有两条必须验证的路径:
<?php class BookingTransactionTest extends TestCase { private PDO $pdo; protected function setUp(): void { // 连接测试专用库;setUp 在每条测试前执行,保证互不污染 $this->pdo = new PDO('mysql:host=127.0.0.1;dbname=station_test;charset=utf8mb4', 'tester', '口令', [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]); $this->pdo->exec('TRUNCATE seats'); $this->pdo->exec("INSERT INTO seats (no, state) VALUES ('12A', 'free')"); } public function testLockFreeSeatSucceeds(): void { $this->assertTrue(bookSeat($this->pdo, '12A')); } public function testLockTakenSeatFailsAndRollsBack(): void { $this->pdo->exec("UPDATE seats SET state = 'sold' WHERE no = '12A'"); $this->assertFalse(bookSeat($this->pdo, '12A')); // 回滚验证:订单表不应残留半截数据 $count = (int) $this->pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn(); $this->assertSame(0, $count); } }
注意测试库与开发库分离(station_test),setUp 每次清场重来——测试之间互不知晓、互不依赖是铁律,任何"下一条测试依赖上一条留下的数据"都会让检修结果时准时不准。异常路径也别漏测:预期抛出的异常用断言接住,"该炸的时候没炸"同样是缺陷。
<?php public function testRejectsEmptySeatNo(): void { $this->expectException(InvalidArgumentException::class); bookSeat($this->pdo, ''); }
⚠️ 测试连真实支付与短信服务:外部依赖在测试里用替身(假实现或桩对象),既省钱又可控——2.1 的依赖注入正是为这一步铺的路。
⚠️ 追求覆盖率数字:覆盖率高不等于测得好,钉契约、钉边界、钉回归点比凑百分比有意义得多。
assertNull、拒绝给 assertNotNull、文案要素逐条钉住;多组输入用数据提供器。setUp 清场,测试之间零依赖。下一节打开站务员的工具箱:调试器、静态分析与常用扩展,工欲善其事前的"工欲善其事"。