单元测试实践:从零到跑通)
FastAdminThinkPHP 5.0单元测试实践从零到跑通基于 FastAdmin PHP 7.3 PHPUnit 8.5 的真实项目经验。本文以一个短信网关的签名、限频、配额逻辑为例完整展示如何给一个传统的 TP5 项目建立可运行的单元测试体系。0. 为什么 FastAdmin 项目普遍没有单元测试FastAdmin 是成熟的后台框架但它的项目结构对写测试并不友好主要有三个拦路虎逻辑长在控制器里业务代码通常直接写在Controller的 protected 方法里依赖$this-request、Cache、Config等上下文实例化一个控制器要拉起来半个框架测不动。phpunit 启动即 fatal这是最隐蔽的坑。phpunit 的入口脚本会在加载任何配置之前require项目的vendor/autoload.php而 FastAdmin 的插件包fastadmin-addons在 composer 的autoload files里注册了路由代码这段代码依赖 TP 框架常量ROOT_PATH、DS和think\Loader。于是测试还没开始先给你一个Fatal error: Use of undefined constant ROOT_PATH。框架安装方式的历史包袱composer.lock里框架常以 git source 记录而部署机往往没有.git目录composer 一更新就报错。这三点不解决很多人就放弃了。本文逐一拆解。1. 架构准备把核心逻辑抽成纯函数这是整个测试体系的地基比装 PHPUnit 更重要。凡是需要验证正确性的核心算法抽到一个不依赖请求上下文的纯函数类里。以短信网关为例签名算法、限频计数、每日配额是三个最容易写错、出错后果最严重签名被绕过、短信被刷爆的逻辑全部抽到application/common/library/SmsGate.php?phpnamespaceapp\common\library;/** * 短信网关核心算法签名构造、限频、配额 * 纯函数式设计不依赖请求上下文便于单元测试 */classSmsGate{/** 构造签名串key 字典序 rawurlencode 拼接 */publicstaticfunctionbuildString($appId,$nonce,$timestamp,array$body){$parts[app_id$appId,nonce$nonce,timestamp(string)$timestamp,];ksort($body);foreach($bodyas$key$value){$parts[$key](string)$value;}ksort($parts);$pairs[];foreach($partsas$key$value){$pairs[]$key..rawurlencode($value);}returnimplode(,$pairs);}/** HMAC-SHA256 签名 */publicstaticfunctionsign($appId,$nonce,$timestamp,array$body,$secret){returnhash_hmac(sha256,self::buildString($appId,$nonce,$timestamp,$body),$secret);}/** 通用计数限频窗口内第 limit1 次起拒绝 */publicstaticfunctionrateLimit($redis,$key,$limit,$ttl){$now$redis-incr($key);if($now1){$redis-expire($key,$ttl);}return$now$limit;}/** 每日配额计数并判断是否超限超限回滚本次计数 */publicstaticfunctionincrQuota($redis,$appId,$dailyQuota,$timenull){$time$time?:time();$dateKeydate(Ymd,$time);$keysms:quota:{$appId}:{$dateKey};$now$redis-incr($key);if($now1){$tomorrowstrtotime(tomorrow,$time);$redis-expire($key,max(1,$tomorrow-$time));}if($now$dailyQuota){$redis-decr($key);// 超配额回滚避免永远多计returnfalse;}returntrue;}}三个设计细节值得注意$time参数可注入测试时可以传固定时间戳断言次日重置这类逻辑不用真的等一天。$redis参数可注入测试时传一个连到测试库的实例与线上数据隔离。控制器变成薄壳Controller里只做取参数、调SmsGate、抛业务错误逻辑本身不再需要被测试覆盖。抽离还带来一个附带好处这个纯函数类本身就是算法文档调用方对接系统实现签名时以此为参照两边不会漂移。2. 安装 PHPUnit版本、镜像与 Composer 2 的坑2.1 版本选择FastAdmin 要求 PHP 7.2但实际部署环境五花八门。PHPUnit 与 PHP 的兼容关系PHP 版本可用 PHPUnit7.2 / 7.37.5 / 8.57.48.5 / 9.x在composer.json里写区间让 composer 按环境自动解析require-dev:{phpunit/phpunit:^7.5 || ^8.5}2.2 Composer 2 的 allow-plugins 拦截Composer 2 出于安全考虑默认阻止所有插件。FastAdmin 依赖的topthink/think-installer、easywechat-composer会被拦截导致安装失败topthink/think-installer contains a Composer plugin which is blocked by your allow-plugins config.放行即可composerconfig --no-plugins allow-plugins.topthink/think-installertruecomposerconfig --no-plugins allow-plugins.easywechat-composer/easywechat-composertruecomposerconfig --no-plugins allow-plugins.karsonzhang/fastadmin-addonstrue2.3 Windows 多版本 PHP 环境phpstudy 等多版本环境里 PHP 可执行文件常叫php73、php74而 composer 的批处理内部调用的是php。解决把对应版本目录临时加入 PATH$env:PATH D:\phpstudy_pro\Extensions\php\php7.3.4nts;$env:PATH composer update--no-interaction重要提醒composer update会按当前 PHP 版本的平台约束解析依赖。如果 composer 用的是 PHP 8.x装出来的包可能在 PHP 7.3 上直接 platform_check fatal。务必用生产环境同版本 PHP 跑 composer。3. 解决 phpunit 启动 fatal自定义 runner 入口这是 FastAdmin 项目特有的最大障碍。前面说过phpunit 入口vendor/bin/phpunit开头就会require项目vendor/autoload.php触发 fastadmin-addons 的路由注册代码而 TP 常量还没定义直接 fatal。解法不经过 phpunit 的入口自己写 runner先初始化框架再启动 phpunit?php// tests/run.php/** * 单元测试运行入口 * 不直接用 vendor/bin/phpunit 的原因phpunit 入口会先 require 项目 vendor/autoload.php * 而 fastadmin-addons 的 composer autoload files 依赖 TP 框架常量未初始化时直接 fatal。 * 本入口先加载框架 base.php 再启动 phpunit。 * * 用法php73 tests/run.php */define(APP_PATH,dirname(__DIR__).DIRECTORY_SEPARATOR.application.DIRECTORY_SEPARATOR);// base.php 内部会加载 vendor/autoload.php先定义常量再加载避免 fastadmin-addons fatalrequiredirname(__DIR__).DIRECTORY_SEPARATOR.thinkphp.DIRECTORY_SEPARATOR.base.php;// 注册应用命名空间让测试用例中的 use app\xxx 能被自动加载\think\Loader::addNamespace(app,APP_PATH);// 被测库直接加载纯函数库不依赖 TP 运行时requireAPP_PATH.common.DIRECTORY_SEPARATOR.library.DIRECTORY_SEPARATOR.SmsGate.php;requiredirname(__DIR__).DIRECTORY_SEPARATOR.vendor.DIRECTORY_SEPARATOR.phpunit.DIRECTORY_SEPARATOR.phpunit.DIRECTORY_SEPARATOR.src.DIRECTORY_SEPARATOR.TextUI.DIRECTORY_SEPARATOR.Command.php;\PHPUnit\TextUI\Command::main(false);几个关键点base.php定义ROOT_PATH、DS等常量并注册think\Loader之后vendor/autoload.phpbase.php 内部加载里的插件代码就能正常执行。不要重复 require autoloadbase.php 已经加载过一次再 require 会报Cannot declare class ComposerStaticInit...。Loader::addNamespace(app, APP_PATH)是必须的TP 框架运行时由 App 注册app命名空间测试环境没有 App 运行时需要手动注册。命令行参数透传Command::main(false)读取$_SERVER[argv]所以php73 tests/run.php --filter testGoldenVector这类 phpunit 参数照常可用。4. phpunit.xml 配置?xml version1.0 encodingUTF-8?phpunitcolorstruestopOnFailurefalsetestsuitestestsuitenameSmsGateway Test Suitedirectorytests//directory/testsuite/testsuites/phpunit注意不需要bootstrap属性——初始化工作已由 runner 完成。phpunit 会自动从当前工作目录向上查找phpunit.xml所以在项目根目录执行php73 tests/run.php即可。5. 写测试用例5.1 纯函数单测黄金向量法签名算法最怕静默漂移——某个重构后签名串的拼接规则悄悄变了两边还能各自算出正确的结果但互不兼容。对抗漂移最有效的手段是黄金向量golden vector预先用独立工具本文用 .NET 的 HMACSHA256 手算算出一组已知输入输出的签名值硬编码进断言。算法一旦被改坏测试立刻红。?phpnamespacetests;usePHPUnit\Framework\TestCase;useapp\common\library\SmsGate;classSmsGateSignTestextendsTestCase{/** 黄金向量已知输入输出防止算法静默漂移 */publicfunctiontestGoldenVector(){$signSmsGate::sign(app_001,a1b2c3d4,1750000000,[mobile13800138000,scenelogin],abc123456789);$this-assertEquals(cb130b0e27999de42e2ca81646312d4ab77965c8e975775e8cf29e5d93b934cf,$sign);}/** 签名串的规范顺序必须确定 */publicfunctiontestBuildStringCanonicalOrder(){$strSmsGate::buildString(app_001,a1b2c3d4,1750000000,[mobile13800138000,scenelogin]);$this-assertEquals(app_idapp_001mobile13800138000noncea1b2c3d4scenelogintimestamp1750000000,$str);}/** 参数乱序不影响签名 */publicfunctiontestBodyOrderDoesNotMatter(){$aSmsGate::sign(app_001,n,1750000000,[b2,a1,c3],s);$bSmsGate::sign(app_001,n,1750000000,[c3,a1,b2],s);$this-assertEquals($a,$b);}/** 篡改 body 必然改变签名防篡改的核心保证 */publicfunctiontestTamperedBodyChangesSign(){$aSmsGate::sign(app_001,n,1750000000,[mobile13800138000],s);$bSmsGate::sign(app_001,n,1750000000,[mobile13800138001],s);$this-assertNotEquals($a,$b);}/** rawurlencode 对特殊字符的处理 */publicfunctiontestRawUrlEncodeSpecialChars(){$strSmsGate::buildString(app_001,n,1750000000,[remark中 文/]);$this-assertStringContainsString(remark.rawurlencode(中 文/),$str);}/** 空 body 的边界情况 */publicfunctiontestEmptyBody(){$signSmsGate::sign(app_001,n,1750000000,[],s);$this-assertEquals(hash_hmac(sha256,app_idapp_001noncentimestamp1750000000,s),$sign);}}5.2 Redis 集成测试隔离 可跳过限频、配额依赖 Redis属于集成测试。三个要点用独立数据库隔离应用连 select 3测试连 select 15setUp里flushDB绝不污染线上数据。环境不可用时跳过Redis 没启动时markTestSkipped而不是让测试红——单测机器不一定有 Redis。时间注入配额次日重置靠$time参数模拟不真等 24 小时。?phpnamespacetests;usePHPUnit\Framework\TestCase;useapp\common\library\SmsGate;classSmsGateRateLimitTestextendsTestCase{/** var \Redis */protectedstatic$redis;publicstaticfunctionsetUpBeforeClass():void{if(!extension_loaded(redis)){self::markTestSkipped(redis 扩展不可用);}self::$redisnew\Redis();if(!self::$redis-connect(127.0.0.1,6379)){self::markTestSkipped(Redis 服务不可用跳过集成测试);}self::$redis-select(15);// 测试专用库与应用 select3 隔离}publicfunctionsetUp():void{self::$redis-flushDB();}publicfunctiontestRateLimitWithinLimit(){for($i0;$i3;$i){$this-assertTrue(SmsGate::rateLimit(self::$redis,k1,3,60));}$this-assertFalse(SmsGate::rateLimit(self::$redis,k1,3,60),第 4 次应拒绝);}publicfunctiontestRateLimitSetsTtlOnFirstHit(){SmsGate::rateLimit(self::$redis,ttl,5,60);$ttlself::$redis-ttl(ttl);$this-assertGreaterThan(0,$ttl,首次计数应设置过期时间);$this-assertLessThanOrEqual(60,$ttl);}publicfunctiontestQuotaAllowsUpToLimitThenRollsBack(){$this-assertTrue(SmsGate::incrQuota(self::$redis,app_x,2));$this-assertTrue(SmsGate::incrQuota(self::$redis,app_x,2));$this-assertFalse(SmsGate::incrQuota(self::$redis,app_x,2),超配额应拒绝);// 超限后回滚本次计数值应停在配额上限$keysms:quota:app_x:.date(Ymd);$this-assertEquals(2,self::$redis-get($key));}publicfunctiontestQuotaResetsNextDay(){$timestrtotime(2026-10-13 10:00:00);$this-assertTrue(SmsGate::incrQuota(self::$redis,app_y,1,$time));$this-assertFalse(SmsGate::incrQuota(self::$redis,app_y,1,$time));// 次日为不同计数键重新放行$this-assertTrue(SmsGate::incrQuota(self::$redis,app_y,1,$time86400));}publicfunctiontestRollbackQuotaWithoutExistingKeyCreatesNothing(){// 不存在键时回滚不应创建无 TTL 的脏键exists 返回 0 表示不存在SmsGate::rollbackQuota(self::$redis,app_none);$this-assertSame(0,self::$redis-exists(sms:quota:app_none:.date(Ymd)));}}5.3 两个容易踩的断言细节Redisexists()返回 int0/1不是 boolassertFalse($redis-exists($key))会失败0 ! false要用assertSame(0, ...)。assertContains对字符串的用法在 PHPUnit 9 移除直接写assertStringContainsString两边兼容。6. 运行# 全部测试php73 tests/run.php# 只跑签名测试php73 tests/run.php tests/SmsGateSignTest.php# 只跑某个方法php73 tests/run.php--filtertestGoldenVector输出PHPUnit 8.5.38 by Sebastian Bergmann and contributors. ................ 16 / 16 (100%) Time: 82 ms, Memory: 6.00 MB OK (16 tests, 29 assertions)CI 里同样一条命令Redis 不可用的环境会自动跳过集成测试签名单测始终全跑。7. 踩坑清单汇总坑现象解法phpunit 入口先加载项目 autoloadundefined constant ROOT_PATHfatal自定义 runner先base.php后 phpunitautoload 重复加载Cannot declare class ComposerStaticInit...base.php 已加载过runner 里别重复 require测试环境无app命名空间Class app\xxx not foundLoader::addNamespace(app, APP_PATH)Composer 2 拦截插件think-installer blockedcomposer config allow-plugins...composer 用错 PHP 版本platform_check fatal要求 PHP 7.4用生产同版本 PHP 跑 composer框架 git source 无 .git 目录The .git directory is missingcomposer.json 锁 commit --prefer-distRedis exists() 返回 intFailed asserting that 0 is falseassertSame(0, ...)依赖外部时钟跨天逻辑测不了逻辑里时间参数可注入8. 总结给 FastAdmin 这类传统 TP5 项目建立单元测试核心就三件事抽纯函数把值得测的逻辑从控制器里拔出来做成无上下文依赖的静态类——这是投入产出比最高的一步也是唯一需要持续坚持的纪律。绕开启动 fatal用自定义 runner 替代 phpunit 入口先base.php再Command::main。黄金向量 隔离环境核心算法用预计算的已知向量锁定行为外部依赖Redis用独立库隔离、不可用时跳过。这套体系建立后改签名、动限频、调配额——跑一条命令就知道有没有改坏。对于一个要长期维护、对接多个业务系统的网关类项目这个安全感是值得的。