FastAdmin(ThinkPHP 5.0)单元测试实践:从零到跑通

FastAdmin(ThinkPHP 5.0)单元测试实践:从零到跑通

基于 FastAdmin + PHP 7.3 + PHPUnit 8.5 的真实项目经验。本文以一个短信网关的签名、限频、配额逻辑为例,完整展示如何给一个"传统"的 TP5 项目建立可运行的单元测试体系。

0. 为什么 FastAdmin 项目普遍没有单元测试

FastAdmin 是成熟的后台框架,但它的项目结构对写测试并不友好,主要有三个拦路虎:

  1. 逻辑长在控制器里 :业务代码通常直接写在 Controller 的 protected 方法里,依赖 $this->request、Cache、Config 等上下文,实例化一个控制器要拉起来半个框架,测不动。
  2. 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。
  3. 框架安装方式的历史包袱 :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&timestamp=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&timestamp=1750000000', 's'),
            $sign
        );
    }
}

5.2 Redis 集成测试:隔离 + 可跳过

限频、配额依赖 Redis,属于集成测试。三个要点:

  1. 用独立数据库隔离 :应用连 select 3,测试连 select 15,setUp 里 flushDB,绝不污染线上数据。
  2. 环境不可用时跳过 :Redis 没启动时 markTestSkipped,而不是让测试红------单测机器不一定有 Redis。
  3. 时间注入 :配额"次日重置"靠 $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 项目建立单元测试,核心就三件事:

  1. 抽纯函数:把值得测的逻辑从控制器里拔出来,做成无上下文依赖的静态类------这是投入产出比最高的一步,也是唯一需要持续坚持的纪律。
  2. 绕开启动 fatal :用自定义 runner 替代 phpunit 入口,先 base.php 再 Command::main。
  3. 黄金向量 + 隔离环境:核心算法用预计算的已知向量锁定行为,外部依赖(Redis)用独立库隔离、不可用时跳过。

这套体系建立后,改签名、动限频、调配额------跑一条命令就知道有没有改坏。对于一个要长期维护、对接多个业务系统的网关类项目,这个安全感是值得的。

相关推荐
silvamux_2 小时前
大模型 API 单元测试怎么 mock 不误伤
单元测试·log4j·php
Helix2503 小时前
Python单元测试实战:pytest框架从入门到企业级测试体系搭建指南
python·单元测试·实战·mock·pytest·测试覆盖率·fixture
toooooop81 天前
FastAdmin Hook 机制与插件错误透传:`$result` 之外的信封
开发语言·fastadmin
2302_805771072 天前
Day24:DDT+EXCEL数据驱动
功能测试·单元测试·自动化·测试用例·需求分析·web自动化
孟陬2 天前
不用盲调性能参数 Vitest 5.0 新推出 `vitest doctor` 一键诊断测试慢卡点,自动给出最优提速配置
性能优化·单元测试·vite
网络毒刘6 天前
端到端:用 Cursor Agent 完成「小功能 + 单测 + PR 描述」并附人工验收清单
单元测试·agent·ai编程·cursor·工具实践
mont evergreen6 天前
RTMP 连接单元测试之旅
单元测试·音视频·源码解读
我就是不信7 天前
CuTest:轻量级 C 语言单元测试框架实战指南
c语言·单元测试·log4j
虫无涯11 天前
Coverity 如何结合 GJB8114-2013使用?
git·单元测试·嵌入式测试·coverity·静态扫描