feat(test): 引入测试规则文档与参数化测试库初始化脚本

对齐 wkbox 衍生项目的测试规则体系,剥离业务特定内容后移植到框架:

- 规则 .agents/rules/ulthon-testing.md(设计哲学/决策树/4层模型/安全规范/fixture原则)
- 技能 .agents/skills/ulthon-testing(运行/编写/模板/决策/回归验证)
- 参数化建库脚本 tests/setup_test_db.{php,ps1,sh} + README
- AGENTS.md 索引补充

与 wkbox 差异:测试库参数化(复用 .env 连接,database=env+'_test'),不硬编码 Docker;删去 canBind Oracle 业务约束与具体 fixture 工厂,业务工厂由衍生项目自行实现。
This commit is contained in:
augushong
2026-07-18 23:56:32 +08:00
parent ce8a0c795b
commit 37cb8291f8
7 changed files with 926 additions and 0 deletions

212
tests/setup_test_db.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
/**
* 测试数据库初始化脚本(参数化版).
*
* 在与 .env 同实例的 MySQL 上建出 ulthon_admin_test 库,并初始化全部表:
* 1. migrate:run 建系统/框架表database/migrations/,如 ul_system_admin / ul_system_menu
* 2. scheme:sync 建业务表app/admin/scheme/
* 3. seed:run 插入最小参考数据database/seeds/InitBaseAdminData
*
* 与 wkbox 衍生项目的区别:
* - 完全参数化连接参数hostname/hostport/username/password/charset/prefix/database
* 全部从 .env 读取,复用开发库同实例的 MySQL不依赖专用 Docker 容器,不硬编码端口。
* - 测试库名 = .env 中 DATABASE + '_test' 后缀(如 ulthon_admin → ulthon_admin_test
* - 脚本本身负责 CREATE DATABASE IF NOT EXISTS单机 MySQL库可能尚未创建
*
* 连接覆盖逻辑与 tests/bootstrap.php 完全一致(必须保持同步,否则脚本与 PHPUnit 跑的
* 不是同一个测试库)。关键技术点见 tests/README.md「技术细节」一节
* - ThinkPHP 的 Env 加载 .env 且 .env 值优先于 OS 环境变量putenv/$env:VAR/bash 前缀
* 都无法覆盖已存在的 .env 键,唯一可靠覆盖点是 config 级。
* - think\Config::set 的第二参数只支持单段一级名,不能传 'database.connections.main'。
*
* 幂等migrate:run / scheme:sync / seed:run 均可安全重复执行(详见 README
*/
declare(strict_types=1);
// ----------------------------------------------------------------------------
// 1. 启动 ThinkPHP 容器(必须先 require autoload再 initialize
// ----------------------------------------------------------------------------
require __DIR__ . '/../vendor/autoload.php';
$app = new \think\App();
$app->initialize();
// ----------------------------------------------------------------------------
// 2. 从 .env 读取连接参数,组装测试库连接配置
// 与 tests/bootstrap.php 完全一致:复用开发库同实例连接,仅替换 database 名。
// ----------------------------------------------------------------------------
$envHostname = \think\facade\Env::get('database.hostname');
$envHostport = \think\facade\Env::get('database.hostport');
$envUsername = \think\facade\Env::get('database.username');
$envPassword = \think\facade\Env::get('database.password');
$envCharset = \think\facade\Env::get('database.charset');
$envPrefix = \think\facade\Env::get('database.prefix');
$envDatabase = \think\facade\Env::get('database.database');
// 测试库名策略:原库名 + _test 后缀(如 ulthon_admin → ulthon_admin_test
$testDatabase = is_string($envDatabase) && $envDatabase !== ''
? $envDatabase . '_test'
: 'ulthon_admin_test';
$hostname = is_string($envHostname) && $envHostname !== '' ? $envHostname : '127.0.0.1';
$hostport = is_string($envHostport) && $envHostport !== '' ? $envHostport : '3306';
$username = is_string($envUsername) && $envUsername !== '' ? $envUsername : 'root';
$password = is_string($envPassword) ? $envPassword : '';
$charset = is_string($envCharset) && $envCharset !== '' ? $envCharset : 'utf8mb4';
$prefix = is_string($envPrefix) && $envPrefix !== '' ? $envPrefix : 'ul_';
// ----------------------------------------------------------------------------
// 3. 早期安全护栏:解析出的库名必须含 'test',否则拒绝继续
// (在任何写操作之前触发,避免误连开发库)
// ----------------------------------------------------------------------------
if (stripos($testDatabase, 'test') === false) {
throw new \RuntimeException("拒绝执行:解析到的测试库名 [{$testDatabase}] 不含 'test',已中止。");
}
fwrite(STDOUT, "==== 测试库目标:{$testDatabase} @ {$hostname}:{$hostport}user: {$username} ====\n");
// ----------------------------------------------------------------------------
// 4. 确保测试库存在(用 PDO 直连,不指定 database执行 CREATE DATABASE IF NOT EXISTS
// 单机 MySQL 场景下测试库可能尚未创建Docker 预 provision 的库也无妨IF NOT EXISTS 幂等。
// ----------------------------------------------------------------------------
try {
$dsn = "mysql:host={$hostname};port={$hostport};charset={$charset}";
$pdo = new \PDO($dsn, $username, $password, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
]);
$quotedDb = '`' . str_replace('`', '``', $testDatabase) . '`';
$pdo->exec("CREATE DATABASE IF NOT EXISTS {$quotedDb} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
fwrite(STDOUT, "==== 确保 TEST_DB 已存在:{$testDatabase}utf8mb4 / utf8mb4_unicode_ci ====\n");
} catch (\Throwable $e) {
fwrite(STDERR, "[ERROR] 创建测试库失败:{$e->getMessage()}\n");
fwrite(STDERR, " 请检查 .env 中 HOSTNAME/HOSTPORT/USERNAME/PASSWORD 是否能连上 MySQL。\n");
exit(1);
}
// ----------------------------------------------------------------------------
// 5. config 级覆盖 connections.main 指向测试库
// 覆盖目标 = connections.main本项目 config/database.php 的默认连接名是 main
// 坑点think\Config::set(array, $name) 的 $name 只支持单段一级配置名,
// 不像 Config::get 那样解析点号多级路径。正确做法:把整段 'database' 配置 pull 出来,
// 原地改 connections.main再 Config::set($dbConfig, 'database') 写回。
// 必须保留 ['query' => \app\common\provider\db\Query::class],否则 ORM 行为异常。
// ----------------------------------------------------------------------------
$dbConfig = \think\facade\Config::get('database');
if (!is_array($dbConfig)) {
$dbConfig = [];
}
$dbConfig['default'] = 'main';
if (!isset($dbConfig['connections']) || !is_array($dbConfig['connections'])) {
$dbConfig['connections'] = [];
}
if (!isset($dbConfig['connections']['main']) || !is_array($dbConfig['connections']['main'])) {
$dbConfig['connections']['main'] = [];
}
$dbConfig['connections']['main'] = array_merge(
$dbConfig['connections']['main'],
[
'type' => 'mysql',
'hostname' => $hostname,
'hostport' => $hostport,
'database' => $testDatabase,
'username' => $username,
'password' => $password,
'charset' => $charset,
'prefix' => $prefix,
'fields_cache' => false,
],
// 必须保留框架自定义 query 类,否则 ORM 行为异常。
['query' => \app\common\provider\db\Query::class],
);
\think\facade\Config::set($dbConfig, 'database');
// 二次护栏:写回 config 之后再校验一次,确认覆盖真的生效。
$resolvedDb = \think\facade\Config::get('database.connections.main.database');
if (!is_string($resolvedDb) || stripos($resolvedDb, 'test') === false) {
throw new \RuntimeException('Config 覆盖未生效resolved database = ' . var_export($resolvedDb, true));
}
// ----------------------------------------------------------------------------
// 6. 逐条执行建表/建库命令(同进程 Console::callconfig 覆盖对命令同样生效)
// Console::call 返回 think\console\Output命令输出被写进其私有 handleBuffer
// 用反射取出 Buffer 内容回显,便于排查;同时去掉 ANSI 着色码避免终端转义污染日志。
// --force-force 是框架全局选项,命中时 confirm() 直接返回默认值,跳过所有交互确认——
// scheme:sync 在非 TTY 下不带它会被 parent::confirm 抛 "Aborted"。
// ----------------------------------------------------------------------------
$commands = [
// 系统/框架表Phinx 迁移ul_system_admin / ul_system_menu / ul_system_auth_node / ul_system_config / ...
['migrate:run', '系统/框架表database/migrations/'],
// 业务表Scheme 同步app/admin/scheme/ 下声明的表
['scheme:sync', '业务表app/admin/scheme/'],
// 参考数据seedInitBaseAdminData自带 install-lock 跳过,可安全重跑
['seed:run', '参考数据database/seeds/'],
];
$handleProp = (new \ReflectionProperty(\think\console\Output::class, 'handle'));
$handleProp->setAccessible(true);
foreach ($commands as [$cmd, $desc]) {
fwrite(STDOUT, "\n>>>> [{$cmd}] {$desc}\n");
try {
/** @var \think\console\Output $output */
$output = \think\facade\Console::call($cmd, ['--force-force']);
// 取出 Buffer 里命令自身打印的内容并回显
$handle = $handleProp->getValue($output);
$captured = ($handle instanceof \think\console\output\driver\Buffer) ? $handle->fetch() : '';
if ($captured !== '') {
// 去掉 ANSI 着色码,避免终端转义污染日志
$captured = preg_replace('/\x1b\[[0-9;]*m/', '', $captured);
fwrite(STDOUT, rtrim($captured) . "\n");
}
fwrite(STDOUT, "<<<< [{$cmd}] 完成(无异常)\n");
} catch (\Throwable $e) {
// 单条命令失败不立刻退出,继续后续命令,最后由验证段汇总。
fwrite(STDERR, "<<<< [{$cmd}] 异常:{$e->getMessage()}\n");
}
}
// ----------------------------------------------------------------------------
// 7. 验证:直接查 information_schema.tables WHERE table_schema=?,证明表确实落在测试库
// ulthon 是框架仓库,没有 app_* 业务表,只断言系统表 ul_system_* 关键四张存在。
// ----------------------------------------------------------------------------
fwrite(STDOUT, "\n==== 验证:{$resolvedDb} 内表清单 ====\n");
try {
// 用裸 SQL 查 information_schema绕开 ORM 的前缀/字段缓存 quirks
$rows = \think\facade\Db::connect('main')->query(
'SELECT table_name AS t FROM information_schema.tables WHERE table_schema = ? ORDER BY table_name',
[$resolvedDb],
);
$names = array_column($rows, 't');
$totalCount = count($names);
fwrite(STDOUT, "表总数:{$totalCount}\n");
// 分类展示:系统表 vs 业务表 vs 其它(按命名约定 ul_system_* / ul_app_* / 其它)
$systemTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'system') !== false));
$appTables = array_values(array_filter($names, static fn ($n) => stripos($n, 'app_') !== false));
$otherTables = array_values(array_diff($names, $systemTables, $appTables));
fwrite(STDOUT, "系统表system来自 migrate:run" . (empty($systemTables) ? '(无)' : implode(', ', $systemTables)) . "\n");
fwrite(STDOUT, "业务表app_来自 scheme:sync" . (empty($appTables) ? '(无)' : implode(', ', $appTables)) . "\n");
if (!empty($otherTables)) {
fwrite(STDOUT, "其它表migrations / debug_log / backup 等):" . implode(', ', $otherTables) . "\n");
}
// 关键系统表必存在断言ulthon 框架核心:登录、菜单、权限节点、配置)
$required = ['ul_system_admin', 'ul_system_menu', 'ul_system_auth_node', 'ul_system_config'];
$missing = array_values(array_filter($required, static fn ($n) => !in_array($n, $names, true)));
if (!empty($missing)) {
fwrite(STDERR, "\n[警告] 缺少关键系统表:" . implode(', ', $missing) . "\n");
fwrite(STDERR, " 检查 migrate:run 是否真的连到测试库并成功执行。\n");
exit(2);
}
fwrite(STDOUT, "\n[OK] 关键系统表全部存在system_admin / system_menu / system_auth_node / system_config\n");
} catch (\Throwable $e) {
fwrite(STDERR, "验证查询失败:{$e->getMessage()}\n");
exit(3);
}
fwrite(STDOUT, "\n==== 测试数据库初始化完成 ====\n");
exit(0);