mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-08-30 12:45:32 +08:00
feat(test): 框架测试基建基类TestCaseBase+app层入口+冒烟测试
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
16
app/common/test/TestCase.php
Normal file
16
app/common/test/TestCase.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
/**
|
||||
* app 层测试基建入口(空壳).
|
||||
*
|
||||
* 架构约束(Base/App 铁律):每新增一个 Base 类,必须同时在 app/ 提供入口类。
|
||||
* 本类是 extend/base/common/test/TestCaseBase 的 app 层入口,空壳继承。
|
||||
*
|
||||
* 业务测试用例应继承本类(而非直接继承 TestCaseBase),业务 fixture 工厂
|
||||
* (创建业务实体、业务表探针重写等)下沉到本类或其子类实现。
|
||||
*/
|
||||
|
||||
namespace app\common\test;
|
||||
|
||||
class TestCase extends \base\common\test\TestCaseBase
|
||||
{
|
||||
}
|
||||
245
extend/base/common/test/TestCaseBase.php
Normal file
245
extend/base/common/test/TestCaseBase.php
Normal file
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
namespace base\common\test;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use think\facade\Config;
|
||||
use think\facade\Db;
|
||||
|
||||
/**
|
||||
* 框架测试基建基类(内核层).
|
||||
*
|
||||
* 提供通用的、与 ThinkPHP 容器/数据库无关的测试基础设施:
|
||||
* - 测试数据库安全校验(setUp 第一道:拒绝连非测试库)
|
||||
* - 事务回滚隔离(PRIMARY:setUp 开事务 / tearDown 回滚,撤销所有 DB 写入)
|
||||
* - DB 断言助手(assertDatabaseHas / assertDatabaseMissing)
|
||||
* - 隔离探针(assertIsolationWorks:自验证事务回滚是否真正生效)
|
||||
* - Plan B(truncateTables:事务回滚在 think-orm 3.0 下失效时的降级手段)
|
||||
*
|
||||
* 架构约束(Base/App 铁律):
|
||||
* 本类位于 extend/base/(内核层),只放通用测试基建。
|
||||
* 严禁引用 app\ 下任何业务命名空间、严禁硬编码业务表名、
|
||||
* 严禁实现业务 fixture 工厂。
|
||||
* 这些业务能力一律下沉到 app 层的 app\common\test\TestCase。
|
||||
*
|
||||
* 继承关系:app 层入口 app\common\test\TestCase 继承本类,
|
||||
* 再由具体测试用例继承 app 层入口。
|
||||
*/
|
||||
abstract class TestCaseBase extends TestCase
|
||||
{
|
||||
/**
|
||||
* 是否在每个测试之间清理容器缓存单例。
|
||||
*
|
||||
* 默认 false:容器在 tests/bootstrap.php 中一次性引导并跨测试复用,
|
||||
* 事务回滚才是真正的隔离手段,容器不需要每个测试重置。
|
||||
* 仅当某个测试确实污染了框架级单例时,子类可将其置 true。
|
||||
*/
|
||||
protected bool $resetContainerBetweenTests = false;
|
||||
|
||||
/**
|
||||
* 每个测试开始前:先做安全校验,再开启事务.
|
||||
*
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 安全第一道:绝对不能连生产库。事务开启之前必须先校验。
|
||||
$this->assertTestDatabase();
|
||||
|
||||
// 开启事务:本测试所有 DB 写入都在此事务内,tearDown 统一回滚。
|
||||
// think-orm 3.0 事务 API:startTrans() 开启 / rollback() 回滚 / commit() 提交。
|
||||
Db::startTrans();
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个测试结束后:回滚事务撤销所有 DB 写入(PRIMARY 隔离).
|
||||
*
|
||||
* 重要:这里【不会】调用 Container::setInstance(null)。
|
||||
* 原因:ThinkPHP 容器在 tests/bootstrap.php 中只引导一次(once-bootstrapped 模型),
|
||||
* 跨测试复用同一个 App 实例。如果在这里把容器单例置 null,第二个测试起
|
||||
* 所有 facade(Db / Config 等)都会失去解析目标,整个测试套件崩溃。
|
||||
* 事务回滚才是隔离手段;容器是长生命的共享基础设施,不应被销毁。
|
||||
* 若确需清理框架级缓存单例,子类把 $resetContainerBetweenTests 置 true,
|
||||
* 由 resetContainerState() 做最小化、安全的清理。
|
||||
*
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// 回滚事务:撤销本测试对数据库的一切写入 —— 真正的隔离在这里。
|
||||
// think-orm 3.0 事务 API:startTrans() 开启 / rollback() 回滚 / commit() 提交。
|
||||
Db::rollback();
|
||||
|
||||
if ($this->resetContainerBetweenTests) {
|
||||
$this->resetContainerState();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 最小化清理框架级缓存单例(可选).
|
||||
*
|
||||
* 默认实现为空:因为容器是跨测试复用的(见 tearDown 注释),
|
||||
* 绝大多数场景不需要清理。只有当某测试确实污染了框架级单例、
|
||||
* 且事务回滚无法覆盖时,子类重写此方法做定向清理
|
||||
* (例如清某个缓存绑定,但【不要】整体销毁容器)。
|
||||
*/
|
||||
protected function resetContainerState(): void
|
||||
{
|
||||
// 有意为空:扩展点,子类按需重写。
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析当前默认数据库连接的 config 键前缀.
|
||||
*
|
||||
* 不硬编码连接名(本项目的默认连接是 `main`,但其它 ulthon 应用可能是 `mysql` 或别的)。
|
||||
* 动态读取 `database.default`(config/database.php: 'default' => Env::get('database.main', ...)),
|
||||
* 拼成 `database.connections.<default>`,供 assertTestDatabase / truncateTables 共用。
|
||||
*/
|
||||
protected function connectionConfigKey(): string
|
||||
{
|
||||
$default = (string) Config::get('database.default', 'main');
|
||||
|
||||
return 'database.connections.' . $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全校验:断言当前连接的数据库名包含 "test",拒绝连生产库.
|
||||
*
|
||||
* 通用判断(内核层不硬编码任何具体库名):库名含子串 "test" 视为测试库。
|
||||
* 例如 ulthon_admin_test(含 test)会被放行;远程生产库名(不含 test)会被拦截。
|
||||
* 这是 setUp 的第一道闸门,在任何 DB 写入之前执行。
|
||||
*/
|
||||
protected function assertTestDatabase(): void
|
||||
{
|
||||
$name = (string) Config::get($this->connectionConfigKey() . '.database', '');
|
||||
|
||||
if ($name === '' || stripos($name, 'test') === false) {
|
||||
$this->fail('Refusing to run tests against non-test database: ' . ($name !== '' ? $name : '(unknown)'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言指定表存在【至少一条】匹配 $where 的记录.
|
||||
*
|
||||
* @param string $table 逻辑表名(不含前缀,与 Db::name() 一致)
|
||||
* @param array $where where 条件
|
||||
*/
|
||||
protected function assertDatabaseHas(string $table, array $where): void
|
||||
{
|
||||
$count = Db::name($table)->where($where)->count();
|
||||
|
||||
if ($count <= 0) {
|
||||
$this->fail(sprintf(
|
||||
'Failed asserting that table [%s] contains a row matching %s.',
|
||||
$table,
|
||||
json_encode($where, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言指定表【不存在】任何匹配 $where 的记录.
|
||||
*
|
||||
* @param string $table 逻辑表名(不含前缀,与 Db::name() 一致)
|
||||
* @param array $where where 条件
|
||||
*/
|
||||
protected function assertDatabaseMissing(string $table, array $where): void
|
||||
{
|
||||
$count = Db::name($table)->where($where)->count();
|
||||
|
||||
if ($count > 0) {
|
||||
$this->fail(sprintf(
|
||||
'Failed asserting that table [%s] does NOT contain any row matching %s (found %d).',
|
||||
$table,
|
||||
json_encode($where, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
$count
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 隔离探针:自验证事务回滚隔离是否真正生效.
|
||||
*
|
||||
* 工作原理(跨测试自验证):
|
||||
* 1. 先断言探针行【不存在】—— 若存在,说明上一个测试的事务没有被回滚(隔离失败);
|
||||
* 2. 插入一条探针行;
|
||||
* 3. 断言探针行【现在可见】—— 证明本测试事务内写入正常。
|
||||
*
|
||||
* 完整契约:本方法插入的探针行必须被本测试 tearDown 的 Db::rollback() 撤销,
|
||||
* 从而下一个测试再调 assertIsolationWorks() 时,第 1 步的"不存在"断言才能成立。
|
||||
* 如果事务回滚失效,第 1 步会在下一个测试立刻失败 —— 这就是探针的报警机制。
|
||||
*
|
||||
* 内核层不硬编码业务表,探针表/数据由可重写的隔离钩子提供默认值(框架表 system_menu),
|
||||
* app 层 app\common\test\TestCase 可重写 isolationProbeTable()/isolationProbeData()
|
||||
* 改用真实业务表,使探针更贴近业务写入路径。
|
||||
*
|
||||
* @api public —— 可直接作为测试用例方法或被测试用例显式调用
|
||||
*/
|
||||
public function assertIsolationWorks(): void
|
||||
{
|
||||
$table = $this->isolationProbeTable();
|
||||
$data = $this->isolationProbeData();
|
||||
|
||||
// 1. 前置:探针行必须不存在(证明上一测试已回滚干净)
|
||||
$this->assertDatabaseMissing($table, $data);
|
||||
|
||||
// 2. 插入探针行
|
||||
Db::name($table)->insert($data);
|
||||
|
||||
// 3. 后置:探针行必须可见(证明本测试事务内写入正常)
|
||||
$this->assertDatabaseHas($table, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 隔离探针表(可重写).
|
||||
*
|
||||
* 默认返回框架表 system_menu(migrate:run 必建,所有应用都有)。
|
||||
* app 层可重写为真实业务表,使探针更贴近业务写入路径。
|
||||
* 注意:内核层不允许返回业务表名,那属于 app 层重写。
|
||||
*/
|
||||
protected function isolationProbeTable(): string
|
||||
{
|
||||
return 'system_menu';
|
||||
}
|
||||
|
||||
/**
|
||||
* 隔离探针插入数据(可重写).
|
||||
*
|
||||
* 默认向 system_menu 插入一条带高辨识度标记的行(system_menu 的 title 列有默认值,
|
||||
* 仅写 title 即可,其余列走默认)。返回的数组同时作为可见性断言的 where 条件。
|
||||
*/
|
||||
protected function isolationProbeData(): array
|
||||
{
|
||||
return ['title' => '__isolation_probe__'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan B:TRUNCATE 指定表(事务回滚隔离的降级方案).
|
||||
*
|
||||
* 用途:如果事务回滚在 think-orm 3.0 下证明不足以隔离(例如某些 DDL / 自动提交场景),
|
||||
* 子类可在 setUp 中显式调用本方法清空指定表。事务回滚仍是首选方案,本方法仅作兜底。
|
||||
*
|
||||
* @param array $tables 逻辑表名数组(不含前缀,与其它方法一致;内部会拼接配置前缀)
|
||||
*/
|
||||
protected function truncateTables(array $tables): void
|
||||
{
|
||||
$prefix = (string) Config::get($this->connectionConfigKey() . '.prefix', '');
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$table = (string) $table;
|
||||
|
||||
// 标识符白名单校验,杜绝 SQL 注入(TRUNCATE 是高危操作)
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Refusing to TRUNCATE: invalid table identifier (only [A-Za-z0-9_] allowed): ' . $table
|
||||
);
|
||||
}
|
||||
|
||||
Db::execute('TRUNCATE TABLE `' . $prefix . $table . '`');
|
||||
}
|
||||
}
|
||||
}
|
||||
73
tests/Feature/IsolationSmokeTest.php
Normal file
73
tests/Feature/IsolationSmokeTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace tests\Feature;
|
||||
|
||||
use app\common\test\TestCase;
|
||||
use PHPUnit\Framework\AssertionFailedError;
|
||||
use ReflectionMethod;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 隔离机制冒烟测试.
|
||||
*
|
||||
* 验证 TestCaseBase 的两个核心契约:
|
||||
* 1. 事务回滚隔离真正生效(探针行跨测试不残留)
|
||||
* 2. 安全校验拦截非测试库(拒绝连生产库)
|
||||
*
|
||||
* 继承 app 层入口 app\common\test\TestCase(Base/App 铁律:业务测试不直接继承 Base)。
|
||||
*/
|
||||
class IsolationSmokeTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 探针自验证:先断言不存在→插入→断言可见.
|
||||
*
|
||||
* 完整契约依赖 tearDown 的 Db::rollback():本用例插入的探针行必须被回滚撤销,
|
||||
* 从而【下次运行】本用例时第 1 步"不存在"断言才能成立。
|
||||
* 单次运行验证写入路径;连续两次运行验证回滚隔离。
|
||||
*/
|
||||
public function test_isolation_probe_rolls_back(): void
|
||||
{
|
||||
// assertIsolationWorks 内部完成:missing → insert → has 三段断言。
|
||||
$this->assertIsolationWorks();
|
||||
|
||||
// 显式成功标记(assertIsolationWorks 不带返回值,避免 risky test 告警)。
|
||||
$this->assertTrue(true, 'isolation probe inserted and is visible within the transaction');
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝非测试库:临时把连接库名改为不含 "test" 的值,
|
||||
* 反射调用 protected assertTestDatabase 应触发 fail()。
|
||||
*
|
||||
* 使用 try/catch/finally 而非 expectException,目的是无论是否抛出
|
||||
* 都能在 finally 中恢复 config,避免污染后续测试。
|
||||
*/
|
||||
public function test_refuses_non_test_database(): void
|
||||
{
|
||||
// think 的 Config::set 不支持点号多级路径(见 bootstrap 注释),
|
||||
// 必须像 bootstrap 那样整段 pull 出来改了再 set 回去。
|
||||
$dbConfig = Config::get('database');
|
||||
$backup = is_array($dbConfig) ? $dbConfig : [];
|
||||
|
||||
$thrown = null;
|
||||
try {
|
||||
$dbConfig['connections']['main']['database'] = 'ulthon_admin_production_safe_marker';
|
||||
Config::set($dbConfig, 'database');
|
||||
|
||||
// 反射调用 protected assertTestDatabase(setUp 里已自动调过一次并通过,
|
||||
// 这里在事务内二次校验非测试库名,应当 fail)。
|
||||
$method = new ReflectionMethod(self::class, 'assertTestDatabase');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this);
|
||||
} catch (AssertionFailedError $e) {
|
||||
$thrown = $e;
|
||||
} finally {
|
||||
// 无论是否抛出,恢复 config —— 不污染同一进程后续测试。
|
||||
Config::set($backup, 'database');
|
||||
}
|
||||
|
||||
$this->assertNotNull($thrown, 'assertTestDatabase should fail when database name lacks "test"');
|
||||
$this->assertStringContainsString('non-test database', $thrown->getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user