FastAdmin(ThinkPHP 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:
php
<?php
namespace app\common\library;
/**
* 短信网关核心算法:签名构造、限频、配额
* 纯函数式设计:不依赖请求上下文,便于单元测试
*/
class SmsGate
{
/** 构造签名串:key 字典序 + rawurlencode 拼接 */
public static function buildString($appId, $nonce, $timestamp, array $body)
{
$parts = [
'app_id' => $appId,
'nonce' => $nonce,
'timestamp' => (string)$timestamp,
];
ksort($body);
foreach ($body as $key => $value) {
$parts[$key] = (string)$value;
}
ksort($parts);
$pairs = [];
foreach ($parts as $key => $value) {
$pairs[] = $key . '=' . rawurlencode($value);
}
return implode('&', $pairs);
}
/** HMAC-SHA256 签名 */
public static function sign($appId, $nonce, $timestamp, array $body, $secret)
{
return hash_hmac('sha256', self::buildString($appId, $nonce, $timestamp, $body), $secret);
}
/** 通用计数限频:窗口内第 limit+1 次起拒绝 */
public static function rateLimit($redis, $key, $limit, $ttl)
{
$now = $redis->incr($key);
if ($now === 1) {
$redis->expire($key, $ttl);
}
return $now <= $limit;
}
/** 每日配额:计数并判断是否超限,超限回滚本次计数 */
public static function incrQuota($redis, $appId, $dailyQuota, $time = null)
{
$time = $time ?: time();
$dateKey = date('Ymd', $time);
$key = "sms:quota:{$appId}:{$dateKey}";
$now = $redis->incr($key);
if ($now === 1) {
$tomorrow = strtotime('tomorrow', $time);
$redis->expire($key, max(1, $tomorrow - $time));
}
if ($now > $dailyQuota) {
$redis->decr($key); // 超配额回滚,避免永远多计
return false;
}
return true;
}
}
三个设计细节值得注意:
$time参数可注入:测试时可以传固定时间戳,断言"次日重置"这类逻辑不用真的等一天。$redis参数可注入:测试时传一个连到测试库的实例,与线上数据隔离。- 控制器变成薄壳 :
Controller里只做取参数、调SmsGate、抛业务错误,逻辑本身不再需要被测试覆盖。
抽离还带来一个附带好处:这个纯函数类本身就是算法文档,调用方(对接系统)实现签名时以此为参照,两边不会漂移。
2. 安装 PHPUnit:版本、镜像与 Composer 2 的坑
2.1 版本选择
FastAdmin 要求 PHP >= 7.2,但实际部署环境五花八门。PHPUnit 与 PHP 的兼容关系:
| PHP 版本 | 可用 PHPUnit |
|---|---|
| 7.2 / 7.3 | 7.5 / 8.5 |
| 7.4+ | 8.5 / 9.x |
在 composer.json 里写区间让 composer 按环境自动解析:
json
"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.
放行即可:
bash
composer config --no-plugins allow-plugins.topthink/think-installer true
composer config --no-plugins allow-plugins.easywechat-composer/easywechat-composer true
composer config --no-plugins allow-plugins.karsonzhang/fastadmin-addons true
2.3 Windows 多版本 PHP 环境
phpstudy 等多版本环境里 PHP 可执行文件常叫 php73、php74,而 composer 的批处理内部调用的是 php。解决:把对应版本目录临时加入 PATH:
powershell
$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
<?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 fatal)
require dirname(__DIR__) . DIRECTORY_SEPARATOR . 'thinkphp' . DIRECTORY_SEPARATOR . 'base.php';
// 注册应用命名空间,让测试用例中的 use app\xxx 能被自动加载
\think\Loader::addNamespace('app', APP_PATH);
// 被测库直接加载(纯函数库,不依赖 TP 运行时)
require APP_PATH . 'common' . DIRECTORY_SEPARATOR . 'library' . DIRECTORY_SEPARATOR . 'SmsGate.php';
require dirname(__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.php(base.php 内部加载)里的插件代码就能正常执行。- 不要重复 require autoload :base.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
<?xml version="1.0" encoding="UTF-8"?>
<phpunit colors="true"
stopOnFailure="false">
<testsuites>
<testsuite name="SmsGateway Test Suite">
<directory>tests/</directory>
</testsuite>
</testsuites>
</phpunit>
注意不需要 bootstrap 属性------初始化工作已由 runner 完成。phpunit 会自动从当前工作目录向上查找 phpunit.xml,所以在项目根目录执行 php73 tests/run.php 即可。
5. 写测试用例
5.1 纯函数单测:黄金向量法
签名算法最怕"静默漂移"------某个重构后签名串的拼接规则悄悄变了,两边还能各自算出"正确"的结果,但互不兼容。对抗漂移最有效的手段是黄金向量(golden vector):预先用独立工具(本文用 .NET 的 HMACSHA256 手算)算出一组已知输入输出的签名值,硬编码进断言。算法一旦被改坏,测试立刻红。
php
<?php
namespace tests;
use PHPUnit\Framework\TestCase;
use app\common\library\SmsGate;
class SmsGateSignTest extends TestCase
{
/** 黄金向量:已知输入输出,防止算法静默漂移 */
public function testGoldenVector()
{
$sign = SmsGate::sign(
'app_001',
'a1b2c3d4',
1750000000,
['mobile' => '13800138000', 'scene' => 'login'],
'abc123456789'
);
$this->assertEquals(
'cb130b0e27999de42e2ca81646312d4ab77965c8e975775e8cf29e5d93b934cf',
$sign
);
}
/** 签名串的规范顺序必须确定 */
public function testBuildStringCanonicalOrder()
{
$str = SmsGate::buildString(
'app_001', 'a1b2c3d4', 1750000000,
['mobile' => '13800138000', 'scene' => 'login']
);
$this->assertEquals(
'app_id=app_001&mobile=13800138000&nonce=a1b2c3d4&scene=login×tamp=1750000000',
$str
);
}
/** 参数乱序不影响签名 */
public function testBodyOrderDoesNotMatter()
{
$a = SmsGate::sign('app_001', 'n', 1750000000, ['b' => '2', 'a' => '1', 'c' => '3'], 's');
$b = SmsGate::sign('app_001', 'n', 1750000000, ['c' => '3', 'a' => '1', 'b' => '2'], 's');
$this->assertEquals($a, $b);
}
/** 篡改 body 必然改变签名(防篡改的核心保证) */
public function testTamperedBodyChangesSign()
{
$a = SmsGate::sign('app_001', 'n', 1750000000, ['mobile' => '13800138000'], 's');
$b = SmsGate::sign('app_001', 'n', 1750000000, ['mobile' => '13800138001'], 's');
$this->assertNotEquals($a, $b);
}
/** rawurlencode 对特殊字符的处理 */
public function testRawUrlEncodeSpecialChars()
{
$str = SmsGate::buildString('app_001', 'n', 1750000000, ['remark' => '中 文/&=']);
$this->assertStringContainsString('remark=' . rawurlencode('中 文/&='), $str);
}
/** 空 body 的边界情况 */
public function testEmptyBody()
{
$sign = SmsGate::sign('app_001', 'n', 1750000000, [], 's');
$this->assertEquals(
hash_hmac('sha256', 'app_id=app_001&nonce=n×tamp=1750000000', 's'),
$sign
);
}
}
5.2 Redis 集成测试:隔离 + 可跳过
限频、配额依赖 Redis,属于集成测试。三个要点:
- 用独立数据库隔离 :应用连 select 3,测试连 select 15,
setUp里flushDB,绝不污染线上数据。 - 环境不可用时跳过 :Redis 没启动时
markTestSkipped,而不是让测试红------单测机器不一定有 Redis。 - 时间注入 :配额"次日重置"靠
$time参数模拟,不真等 24 小时。
php
<?php
namespace tests;
use PHPUnit\Framework\TestCase;
use app\common\library\SmsGate;
class SmsGateRateLimitTest extends TestCase
{
/** @var \Redis */
protected static $redis;
public static function setUpBeforeClass(): void
{
if (!extension_loaded('redis')) {
self::markTestSkipped('redis 扩展不可用');
}
self::$redis = new \Redis();
if (!@self::$redis->connect('127.0.0.1', 6379)) {
self::markTestSkipped('Redis 服务不可用,跳过集成测试');
}
self::$redis->select(15); // 测试专用库,与应用 select=3 隔离
}
public function setUp(): void
{
self::$redis->flushDB();
}
public function testRateLimitWithinLimit()
{
for ($i = 0; $i < 3; $i++) {
$this->assertTrue(SmsGate::rateLimit(self::$redis, 'k1', 3, 60));
}
$this->assertFalse(SmsGate::rateLimit(self::$redis, 'k1', 3, 60), '第 4 次应拒绝');
}
public function testRateLimitSetsTtlOnFirstHit()
{
SmsGate::rateLimit(self::$redis, 'ttl', 5, 60);
$ttl = self::$redis->ttl('ttl');
$this->assertGreaterThan(0, $ttl, '首次计数应设置过期时间');
$this->assertLessThanOrEqual(60, $ttl);
}
public function testQuotaAllowsUpToLimitThenRollsBack()
{
$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), '超配额应拒绝');
// 超限后回滚本次计数,值应停在配额上限
$key = 'sms:quota:app_x:' . date('Ymd');
$this->assertEquals(2, self::$redis->get($key));
}
public function testQuotaResetsNextDay()
{
$time = strtotime('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, $time + 86400));
}
public function testRollbackQuotaWithoutExistingKeyCreatesNothing()
{
// 不存在键时回滚不应创建无 TTL 的脏键(exists 返回 0 表示不存在)
SmsGate::rollbackQuota(self::$redis, 'app_none');
$this->assertSame(0, self::$redis->exists('sms:quota:app_none:' . date('Ymd')));
}
}
5.3 两个容易踩的断言细节
- Redis
exists()返回 int(0/1)不是 bool :assertFalse($redis->exists($key))会失败(0 !== false),要用assertSame(0, ...)。 assertContains对字符串的用法在 PHPUnit 9 移除 :直接写assertStringContainsString,两边兼容。
6. 运行
bash
# 全部测试
php73 tests/run.php
# 只跑签名测试
php73 tests/run.php tests/SmsGateSignTest.php
# 只跑某个方法
php73 tests/run.php --filter testGoldenVector
输出:
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 入口先加载项目 autoload | undefined constant ROOT_PATH fatal |
自定义 runner,先 base.php 后 phpunit |
| autoload 重复加载 | Cannot declare class ComposerStaticInit... |
base.php 已加载过,runner 里别重复 require |
测试环境无 app 命名空间 |
Class 'app\xxx' not found |
Loader::addNamespace('app', APP_PATH) |
| Composer 2 拦截插件 | think-installer blocked | composer config allow-plugins... |
| composer 用错 PHP 版本 | platform_check fatal(要求 PHP >= 7.4) | 用生产同版本 PHP 跑 composer |
| 框架 git source 无 .git 目录 | The .git directory is missing |
composer.json 锁 commit + --prefer-dist |
| Redis exists() 返回 int | Failed asserting that 0 is false |
assertSame(0, ...) |
| 依赖外部时钟 | 跨天逻辑测不了 | 逻辑里时间参数可注入 |
8. 总结
给 FastAdmin 这类"传统" TP5 项目建立单元测试,核心就三件事:
- 抽纯函数:把值得测的逻辑从控制器里拔出来,做成无上下文依赖的静态类------这是投入产出比最高的一步,也是唯一需要持续坚持的纪律。
- 绕开启动 fatal :用自定义 runner 替代 phpunit 入口,先
base.php再Command::main。 - 黄金向量 + 隔离环境:核心算法用预计算的已知向量锁定行为,外部依赖(Redis)用独立库隔离、不可用时跳过。
这套体系建立后,改签名、动限频、调配额------跑一条命令就知道有没有改坏。对于一个要长期维护、对接多个业务系统的网关类项目,这个安全感是值得的。