aster_forge_test
aster_forge_test 提供 Aster 产品共享的集成测试基础设施:隔离临时目录、文件型 SQLite fixture、可复用 Docker 容器(testcontainers)、跨进程容器状态登记、真实子进程 guard 和异步等待工具。目标是让每个产品的测试套件不必重复实现测试资源命名、生命周期清理、容器复用、状态锁、孤儿资源清理和进程日志收敛。
这是测试支持 crate,publish = false,只作为 dev-dependency 使用。所有失败路径直接 panic,因为测试设施出错时测试本来就不该继续。
适用场景
- 集成测试需要真实 Redis / PostgreSQL / MySQL / Mailpit,而不是 mock。
- 多次
cargo test之间复用同一个容器,避免每次启动几秒的开销。 - 多个 checkout / worktree 并行跑测试,容器名不能互相打架。
- 测试进程在容器里创建了 per-test 资源(如独立数据库),后续运行需要清理已退出进程留下的孤儿资源。
- E2E 需要启动一个或多个真实服务进程,并在失败时自动附带 stdout/stderr 尾部。
- 测试需要跨平台唯一临时目录,或需要把 SQLite 主文件与 WAL/SHM/journal sidecar 一起清理。
不适合放在这里的内容:
- 产品自己的测试模型、fixture、seed 数据。
- 产品语义的资源命名规范(数据库名、key 前缀仍由产品侧决定)。
- CI 编排逻辑(service container 怎么起是 CI 的事)。
临时文件系统 fixture
temp 模块默认可用,不需要 Cargo feature:
use aster_forge_test::temp::{SqliteTestDatabase, TestTempDir};
let directory = TestTempDir::new("config-loader");
let config_path = directory.join("data/config.toml");
let database = SqliteTestDatabase::new("repository-case");
let db = sea_orm::Database::connect(database.url()).await?;
// ... assertions ...
db.close().await?;TestTempDir::new(scope)在平台临时目录下创建带 PID、进程内计数器和时间戳的隔离目录。TestTempDir::new_in(root, scope)用于必须位于项目目录下的测试,例如验证相对配置路径;清理仍由 guard 负责。TestTempDir复用aster_forge_utils::raii::TempDirGuard,不重复实现资源回收。SqliteTestDatabase把数据库放进独立目录,drop 时主文件、journal、WAL 和 SHM sidecar 会随目录一起清理。- SQLite URL 使用 percent-encoded
sqlite:opaque path,Windows drive letter、反斜杠、空格和 URL 保留字符不会改变文件名语义。 - Windows 会锁住仍然打开的 SQLite 文件;测试必须先显式关闭连接池,再让 fixture 离开作用域。
Cargo feature
无默认 feature。
containers:启用共享容器状态机(fs2 文件锁、PID 登记、租约清理)。process:启用真实子进程 guard 和 loopback 临时端口分配。redis/postgres/mysql:启用对应数据服务的容器 helper,各自隐含containers。smtp:启用 Mailpit 容器、SMTP endpoint、消息清理和消息计数 API,隐含containers。
[dev-dependencies]
aster_forge_test = { git = "https://github.com/AsterCommunity/AsterForge", features = ["redis"] }最小示例
use aster_forge_test::{redis::RedisTestContainer, suite::TestContainerSuite};
use std::sync::OnceLock;
fn test_suite() -> &'static TestContainerSuite {
static SUITE: OnceLock<TestContainerSuite> = OnceLock::new();
SUITE.get_or_init(|| TestContainerSuite::new("myproduct-cache"))
}
#[tokio::test]
async fn works_against_real_redis() {
let container = RedisTestContainer::start(test_suite()).await;
let client = redis::Client::open(container.url()).unwrap();
// ... 用真实连接跑断言
}PostgreSQL 测试应让 Forge 持有隔离库生命周期,产品只运行自己的 migration 和 seed:
let postgres = PostgresTestContainer::start(test_suite()).await;
let database = postgres.create_database("myproduct_case_123").await;
let connection = database.connect().await;
// ... product migration and assertions
connection.close().await.unwrap();
database.cleanup().await;需要复用已经完成 migration/seed 的模板库时,模板内容仍由产品负责,Forge 只负责安全建库、登记和清理:
let database = postgres
.create_database_from_template("myproduct_case_456", "myproduct_template")
.await;跨进程复用 migration template 时,先由产品在 SuiteFixtureLock 内调用 create_shared_database()、迁移并发布 SuiteFixtureState;普通测试继续用 create_database_from_template() 创建进程所有的隔离库。MySQL 对应提供 create_shared_database() / drop_shared_database(),但产品仍负责账号、migration 和 schema snapshot。
容器复用与隔离
- 容器命名:
aster-test-{suite}-{instance}-{service}。instance是当前 checkout 路径的 hash,所以同一机器上多个 worktree 各自持有独立容器,互不干扰。 ReuseDirective::Always:同一 checkout 的多次测试运行复用同一容器。容器数据在运行之间保留,测试 key / 数据库名必须带进程唯一前缀(例如 pid + 自增计数)。- 容器镜像 tag 固定(redis:7-alpine、postgres:16、mysql:8.4),升级 tag 是有意识的变更。
- PostgreSQL helper 显式为容器配置 1 GiB
/dev/shm,避免 GitHub-hosted Docker 的 64 MiB 默认值在长时间并行数据库测试中耗尽 dynamic shared memory。 - MySQL helper 会在每次启动或复用时配置足够大的
table_definition_cache,避免大型测试二进制并行创建隔离 schema 时耗尽 prepared statement 的自动 reprepare 次数。 - MySQL helper 同时配置
max_connections,覆盖 nextest process-per-test 下每个测试进程的 writer/reader pool;这两个值都属于 endpoint identity,配置契约变化会强制重新探测并更新 reusable container。
状态机与错误边界
state 模块在 std::env::temp_dir()/aster-testcontainers-{suite}/ 下维护 per-service 的状态文件:
ContainerStateLock:fs2 独占文件锁,保护 read-modify-write 周期。SharedContainerState:登记存活测试进程 PID 和它们创建的资源名(如 per-test 数据库)。SharedContainerEndpoint:保存 reusable container 最近一次通过探针的 image/container identity 与 host port;新测试进程先直接探测该 endpoint,失效时才重新走 Docker attach/start。ContainerLease:Drop 时 prune 已退出进程的条目。测试进程异常退出时,下一次运行的start()也会 prune,孤儿资源最终会被回收。- nextest 子进程按
NEXTEST_RUN_ID登记 execution ownership。PostgreSQL 在同一 run 内滚动回收已退出进程的隔离库,保留仍存活进程和 suite-scoped template;MySQL 继续延迟保留同一 run 的资源,避免建 schema 时持续 drop 前序 schema,并在下一次 run 统一回收。 SuiteFixtureLock/SuiteFixtureState:用于跨 nextest 进程复用产品拥有的迁移 template 或 schema snapshot。它保存 fixture/backend identity、container identity、migration/schema fingerprint、resource 和 producer version,并以临时文件 + rename 发布完整状态;产品在持锁期间验证或重建 fixture。- fixture lock/state 文件与容器状态一样包含 checkout instance hash;并行 worktree 不会争抢或覆盖彼此的 template metadata。
- PostgreSQL / MySQL 孤儿库在删除前会转记到当前测试进程;即使回收过程再次中断,下一次运行仍能继续清理。产品完成整批 MySQL 清理后调用
forget_resources()一次性解除登记。 - 共享容器的
start()只在真实服务探针通过后返回:PostgreSQL/MySQL 使用数据库连接,Redis 完成认证并执行PING,Mailpit 同时检查 SMTP 与 HTTP API;不把镜像日志文案或仅建立 TCP 连接当作 readiness 契约。
边界说明:
- 状态文件损坏或锁失败会直接 panic。这是测试设施,不是生产服务。
- 非 Unix 平台没有
kill -0,保守地假设进程存活,即孤儿资源不会被清理(功能正确,只是清理变懒)。 PostgresTestContainer::create_database()/create_database_from_template()返回PostgresTestDatabase,统一负责安全建库、连接重试、孤儿库清理和显式销毁。产品侧只负责数据库名称、模板内容、migration 与 seed 数据。remember_shared_resource()/forget_shared_resource()用于登记或移除 suite-scoped 的产品 fixture。它们不会随 producer PID 退出而进入 stale cleanup;产品必须以 fingerprint 驱动的协议显式清理 superseded resource。- MySQL helper 当前只提供 root 连接 URL;涉及产品用户授权时仍由产品测试 harness 负责,避免 Forge 猜测产品账号模型。
SmtpTestContainer封装 Mailpit 的 SMTP/API 端口和消息 API。start()会同时等待 SMTP 和 HTTP API ready,返回后可以直接调用clear_messages()/message_count();产品测试只负责邮件业务配置与投递断言,不重复拼 Mailpit URL、启动重试或解析其 JSON。
真实子进程
process::TestProcess 为每个服务进程创建独立临时工作目录,将 stdout/stderr 写入日志文件,并在进程异常退出时把日志尾部放进 panic。Drop 会终止仍在运行的子进程并删除临时目录。产品侧仍负责命令参数、环境变量、HTTP readiness 和业务断言。
Unix 平台可使用 terminate_gracefully(timeout) 发送 SIGTERM 并等待进程退出,用于验证真实 shutdown drain 和 lease release;超时后由调用方决定如何报告,再由 Drop 兜底终止进程。
use aster_forge_test::process::{TestProcess, available_loopback_port};
use std::process::Command;
let port = available_loopback_port();
let mut command = Command::new(env!("CARGO_BIN_EXE_my_service"));
command.env("APP_PORT", port.to_string());
let mut process = TestProcess::spawn("primary-a", &mut command);
process.assert_running();测试要求
- crate 自身的单元测试覆盖状态机(注册、prune、锁文件 JSON round-trip、lease 清理)和
wait_until。 - 消费方 crate 负责自己服务语义的集成测试,参考
aster_forge_cache/tests/redis_container.rs。
参考项目
- AsterDrive:
tests/common是这套机制的原始实现来源。 - AsterYggdrasil:同样形态的共享容器状态机,验证了抽象可以跨产品复用。