Files
ulthon_admin/extend/base/common/test/TestCaseBase.php

246 lines
9.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace base\common\test;
use PHPUnit\Framework\TestCase;
use think\facade\Config;
use think\facade\Db;
/**
* 框架测试基建基类(内核层).
*
* 提供通用的、与 ThinkPHP 容器/数据库无关的测试基础设施:
* - 测试数据库安全校验setUp 第一道:拒绝连非测试库)
* - 事务回滚隔离PRIMARYsetUp 开事务 / tearDown 回滚,撤销所有 DB 写入)
* - DB 断言助手assertDatabaseHas / assertDatabaseMissing
* - 隔离探针assertIsolationWorks自验证事务回滚是否真正生效
* - Plan BtruncateTables事务回滚在 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 事务 APIstartTrans() 开启 / rollback() 回滚 / commit() 提交。
Db::startTrans();
}
/**
* 每个测试结束后:回滚事务撤销所有 DB 写入PRIMARY 隔离).
*
* 重要:这里【不会】调用 Container::setInstance(null)。
* 原因ThinkPHP 容器在 tests/bootstrap.php 中只引导一次once-bootstrapped 模型),
* 跨测试复用同一个 App 实例。如果在这里把容器单例置 null第二个测试起
* 所有 facadeDb / Config 等)都会失去解析目标,整个测试套件崩溃。
* 事务回滚才是隔离手段;容器是长生命的共享基础设施,不应被销毁。
* 若确需清理框架级缓存单例,子类把 $resetContainerBetweenTests 置 true
* 由 resetContainerState() 做最小化、安全的清理。
*
* {@inheritdoc}
*/
protected function tearDown(): void
{
// 回滚事务:撤销本测试对数据库的一切写入 —— 真正的隔离在这里。
// think-orm 3.0 事务 APIstartTrans() 开启 / 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_menumigrate: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 BTRUNCATE 指定表(事务回滚隔离的降级方案).
*
* 用途:如果事务回滚在 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 . '`');
}
}
}