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.`,供 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 . '`'); } } }