#[sqlx::test]
主线位置:三条主线之外的一个属性宏,展开在编译期,干活在运行期。它生成的测试先用一个主池在服务端建一个临时库,再用一个挂在主池下面的子池(连接池里的
parent)连这个库、交给测试函数,测试里的查询走的就是普通的查询主线。这一页讲建库、命名、迁移和 fixtures、清理,以及它和DATABASE_URL的关系。
flowchart TB
attr["属性宏 expand"] --> noargs{"测试函数有参数吗"}
noargs -->|没有| simple["block_on 包一层"]
noargs -->|有| adv["TestArgs 加迁移和 fixtures"]
adv --> ctx["test_context 建库"]
ctx --> setup["setup_test_db"]
setup --> body["运行测试函数"]
body --> ok{"成功了吗"}
ok -->|是| drop["cleanup_test 删库"]
ok -->|否| keep["库留着"]
代码在 sqlx-macros-core/src/test_attr.rs(403 行,大半是解析 fixtures(...))、sqlx-core/src/testing/mod.rs(275 行)和 sqlx-postgres/src/testing/mod.rs(207 行)。本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,sqlx = "=0.9.0"(postgres、macros、migrate)。实验 crate test-demo 有一个迁移、一个 fixture 和一个集成测试文件:
test-demo/migrations/0001_items.sql create table item (id int8 primary key, name text not null);
test-demo/tests/fixtures/items.sql insert into item values (1, 'a'), (2, 'b');
test-demo/tests/db.rs
展开成什么
属性宏的入口 expand 先看测试函数有没有参数。没有参数的 #[sqlx::test] async fn foo() 只是「给 async 测试一个运行时」,expand_simple 生成 #[test] fn foo() { ::sqlx::test_block_on(async { .. }) },不碰数据库;这种情况下写 fixtures、migrations 等参数会报 control attributes are not allowed unless automatic test DB management is used; see docs。
有参数时走 expand_advanced:
Ok(quote! {
#(#attrs)*
#[::core::prelude::v1::test]
fn #name() #ret {
async fn #name(#inputs) #ret {
#body
}
let mut args = ::sqlx::testing::TestArgs::new(concat!(module_path!(), "::", stringify!(#name)));
#migrations
args.fixtures(&[#(#fixtures),*]);
// We need to give a coercion site or else we get "unimplemented trait" errors.
let f: fn(#(#fn_arg_types),*) -> _ = #name;
::sqlx::testing::TestFn::run_test(f, args)
}
})
外层是普通的 #[test] 函数,里面重新定义一个同名的 async 函数装你的函数体。TestArgs 记下三样东西:测试路径(module_path!()::函数名,后面拿它算库名)、Migrator、fixtures。f 被强制成函数指针 fn(_) -> _,TestFn 就是按这个函数指针的类型分派的,sqlx-core/src/testing/mod.rs 给四种签名各写了一个实现(第一个是 impl TestFn for fn(Pool<DB>) -> Fut):
| 测试函数的参数 | 拿到什么 |
|---|---|
Pool<DB> | 连到临时库的池 |
PoolConnection<DB> | 从这个池里取出的一条连接,测试结束后池被关闭 |
PoolOptions<DB>, ConnectOptions | 池的选项和连临时库的选项,自己建池 |
| 无参数 | 不建库(fixtures 必须为空) |
DB 从参数类型推出来(PgPool 就是 Pool<Postgres>),所以同一个宏能用于三种数据库。
每个测试一个库
run_test 在 test_block_on 里依次调 DB::test_context、setup_test_db、测试函数、DB::cleanup_test。Postgres 的 test_context 做的事:
- 从
DATABASE_URL建一个主池,放进进程级的static MASTER_POOL: OnceLock<Pool<Postgres>>,同一个测试二进制里的所有测试共用。主池max_connections(20)(注释:Postgres 默认 100 条连接,不想用光),after_release永远返回false,连接用完就关(注释:Tokio's I/O streams don't like hopping runtimes,每个测试有自己的运行时)。用的是connect_lazy_with,第一次acquire才连。 - 在主库里执行一段 SQL:先
pg_advisory_xact_lock(8318549251334697844)(注释说这个数是 ASCII 的sqlxtest八个字节),再create schema if not exists _sqlx_test、建表_sqlx_test.databases (db_name, test_path, created_at)、建一个序列。锁是为了让并发的测试二进制不在create schema if not exists上撞车(注释引了 2007 年的 Postgres 邮件列表)。 - 算库名
Postgres::db_name(args),先do_cleanup(drop database if exists加删记录),再插入一条记录,create database "<库名>"。 - 返回给测试用的
PoolOptions:max_connections(5),idle_timeout1 秒,.parent(master_pool);连接选项是主库的选项换个库名。
库名由 TestSupport::db_name 算:
fn db_name(args: &TestArgs) -> String {
let mut hasher = Sha512::new();
hasher.update(args.test_path.as_bytes());
let hash = hasher.finalize();
let hash = URL_SAFE.encode(&hash[..39]);
let db_name = format!("_sqlx_test_{}", hash).replace('-', "_");
debug_assert!(db_name.len() == 63);
db_name
}
SHA-512 的前 39 个字节,URL 安全的 base64 编码成 52 个字符,加上 11 个字符的前缀正好 63,是 Postgres 标识符的长度上限(NAMEDATALEN - 1)。- 换成 _;大小写混合,所以建库和删库都用 {db_name:?} 加双引号。名字只取决于测试路径,同一个测试每次运行都是同一个库名。
实验:测试里看到的库
tests/db.rs 的每个测试打印 current_database()、public 下的表,再另开一条连接到 DATABASE_URL 的库,列出 pg_database 里所有 _sqlx_test_ 开头的库:
#[sqlx::test]
async fn default_args(pool: PgPool) { /* ... */ }
#[sqlx::test(fixtures("items"))]
async fn with_fixture(pool: PgPool) { /* ... */ }
#[sqlx::test(migrations = false)]
async fn no_migrations(pool: PgPool) { /* ... */ }
$ DATABASE_URL=postgres://lab:lab@192.168.64.106/lab cargo test -q --offline --test db -- --test-threads=1 --nocapture
running 5 tests
[default_args] current_database = _sqlx_test_4PfWI0AKzwlC3V4o4Aon_MC6qqwW8ab88QlzMtmgcUvdvrc_ZCQa
[default_args] tables = ["_sqlx_migrations", "item"]
[default_args] pg_database = ["_sqlx_test_4PfWI0AKzwlC3V4o4Aon_MC6qqwW8ab88QlzMtmgcUvdvrc_ZCQa"]
[default_args] item rows = 0
.i[no_migrations] current_database = _sqlx_test_bsnyS51FoAxmrfc3vP9MnxEEsWJD9CfFl5fx4nharRfD2JOyV12x
[no_migrations] tables = []
[no_migrations] pg_database = ["_sqlx_test_bsnyS51FoAxmrfc3vP9MnxEEsWJD9CfFl5fx4nharRfD2JOyV12x"]
.i[with_fixture] current_database = _sqlx_test_Z30lhbhBGVmnhhGrqvRmvSW96_Jyt3iNTBltV8uSJZIfPyGo5I8H
[with_fixture] tables = ["_sqlx_migrations", "item"]
[with_fixture] pg_database = ["_sqlx_test_Z30lhbhBGVmnhhGrqvRmvSW96_Jyt3iNTBltV8uSJZIfPyGo5I8H"]
[with_fixture] items = [(1, "a"), (2, "b")]
.
test result: ok. 3 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 0.92s
(另外两个测试标了 #[ignore],后面用。)每个测试都在自己的库里,前一个测试的库在下一个开始前已经删掉了。用 Python 照 db_name 的算法重算一遍,结果对得上:
db::default_args _sqlx_test_4PfWI0AKzwlC3V4o4Aon_MC6qqwW8ab88QlzMtmgcUvdvrc_ZCQa 63
db::with_fixture _sqlx_test_Z30lhbhBGVmnhhGrqvRmvSW96_Jyt3iNTBltV8uSJZIfPyGo5I8H 63
db::no_migrations _sqlx_test_bsnyS51FoAxmrfc3vP9MnxEEsWJD9CfFl5fx4nharRfD2JOyV12x 63
集成测试文件 tests/db.rs 自成一个 crate,module_path!() 是 db,所以测试路径是 db::default_args。
0.9.0 的 test_context 还在执行 create sequence if not exists _sqlx_test.database_ids,可整个 sqlx-postgres 里再没有别处用到这个序列。它是旧命名方式留下的:#3334 之前,库名是 '_sqlx_test_' || nextval('_sqlx_test.database_ids'),清理靠一个 DO_CLEANUP 标志:每个测试二进制启动后第一个测试删掉所有 created_at 早于启动时间的库。PR 修的是 #2123(#[sqlx::test] should play nicely with nextest),issue 里的描述是 the test binary that has a #[sqlx::test] in it deletes the db on exit, stomping on the next invocation of the binary by nextest:nextest 并行启动很多个测试二进制,后启动的会把先启动的正在用的库当成旧库删掉。改法就是今天的样子:库名由测试路径决定,每个测试只删自己名下的那一个。CHANGELOG 把它列在 0.8.4(已撤回)下,第一个带它的 tag 是 v0.8.5。
迁移和 fixtures
setup_test_db 用连接选项直连新库(不经过池),先跑 Migrator,再按顺序执行每个 fixture,最后关掉这条连接:
if let Some(migrator) = args.migrator {
migrator
.run_direct(None, &mut conn, false)
.await
.expect("failed to apply migrations");
}
for fixture in args.fixtures {
(&mut conn)
.execute(fixture.contents)
.await
.unwrap_or_else(|e| panic!("failed to apply test fixture {:?}: {:?}", fixture.path, e));
}
fixture 是把 &str 直接交给 Executor::execute,走简单查询协议(见 Executor 与 Acquire),所以一个文件里可以写多条语句。
迁移从哪来,由属性参数决定(parse_args):
| 写法 | 效果 |
|---|---|
| 不写 | crate 根目录下的 migrations(或 sqlx.toml 的 migrate.migrations-dir)存在就用,不存在就不跑 |
migrations = "dir" | 用这个目录,等于 migrate!("dir") |
migrator = "crate::MIGRATOR" | 用一个现成的 Migrator 静态量 |
migrations = false | 不跑迁移 |
目录在编译期由 migrate! 的同一套代码嵌进测试二进制(见迁移),实验里 default_args 的库里有 _sqlx_migrations 和 item,no_migrations 的库是空的。migrations = true 会被拒绝(migrations = true is redundant)。
fixture 也是编译期嵌入的,生成的是 TestFixture { path, contents: include_str!(path) }。fixtures("items") 补上 .sql、加上 fixtures/ 前缀,成了 include_str!("fixtures/items.sql")。include_str! 的相对路径相对的是写这行代码的源文件,所以 tests/db.rs 里的 fixture 在 tests/fixtures/ 下,这一点和 migrations、query_file!(相对 crate 根目录)不同。另外两种写法:带目录的 fixtures("../fixtures/a.sql") 原样使用,不补 .sql;fixtures(path = "dir", scripts("a", "b")) 换掉 fixtures/ 前缀。fixtures 按书写顺序执行,在迁移之后。
跑完以后:只有成功才删
run_test 的最后一段:
let res = test_fn(test_context.pool_opts, test_context.connect_opts).await;
if res.is_success() {
if let Err(e) = DB::cleanup_test(&DB::db_name(&args)).await {
eprintln!(
"failed to delete database {:?}: {}",
test_context.db_name, e
);
}
}
is_success 对 () 是 true,对 Result 是 is_ok()。测试函数返回 Err 时不删;panic 时这段代码根本走不到。库留下来,方便你连上去看测试失败时的数据。Pool 参数的版本在测试函数返回后还会 pool.close(),10 秒关不掉就打印 test {test_path} held onto Pool after exiting,然后照常继续。
tests/db.rs 里两个 #[ignore] 的测试,一个 panic!,一个返回 Err:
#[sqlx::test]
#[ignore]
async fn fails(pool: PgPool) {
report("fails", &pool).await;
panic!("deliberate failure");
}
#[sqlx::test]
#[ignore]
async fn returns_err(pool: PgPool) -> sqlx::Result<()> {
report("returns_err", &pool).await;
sqlx::query("select * from no_such_table").execute(&pool).await?;
Ok(())
}
$ cargo test -q --offline --test db -- --ignored --test-threads=1 --nocapture
running 2 tests
[fails] current_database = _sqlx_test_UJ6ocwj8OwLgcfnjgLo_vzWcz8Ly_GxgGLGkzXbfrYuM_WRoVG_e
[fails] tables = ["_sqlx_migrations", "item"]
[fails] pg_database = ["_sqlx_test_UJ6ocwj8OwLgcfnjgLo_vzWcz8Ly_GxgGLGkzXbfrYuM_WRoVG_e"]
thread 'fails' (3388917) panicked at tests/db.rs:50:3:
deliberate failure
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
fails --- FAILED
[returns_err] current_database = _sqlx_test_FGxAC84grXoVggU6_y7liL3q2JtTO53vEMpy3Rj47tfRNkquLafH
[returns_err] tables = ["_sqlx_migrations", "item"]
[returns_err] pg_database = ["_sqlx_test_FGxAC84grXoVggU6_y7liL3q2JtTO53vEMpy3Rj47tfRNkquLafH", "_sqlx_test_UJ6ocwj8OwLgcfnjgLo_vzWcz8Ly_GxgGLGkzXbfrYuM_WRoVG_e"]
Error: Database(PgDatabaseError { severity: Error, code: "42P01", message: "relation \"no_such_table\" does not exist", ... })
returns_err --- FAILED
...
$ psql "$DATABASE_URL" -Atc "select db_name, test_path from _sqlx_test.databases order by 1"
_sqlx_test_FGxAC84grXoVggU6_y7liL3q2JtTO53vEMpy3Rj47tfRNkquLafH|db::returns_err
_sqlx_test_UJ6ocwj8OwLgcfnjgLo_vzWcz8Ly_GxgGLGkzXbfrYuM_WRoVG_e|db::fails
returns_err 运行时,fails 的库还在;跑完以后两个库都留着,_sqlx_test.databases 里也有它们的记录。
M2 Max、macOS、rustc 1.99.0、Postgres 16.14,sqlx 0.9.0。接着上面,不做任何清理,把这两个仍然会失败的测试原样再跑一遍。跑完以后 pg_database 里有几个 _sqlx_test_ 开头的库?
TestSupport 还有一个 cleanup_test_dbs,删掉 _sqlx_test.databases 里记着的所有库,但 0.9.0 里没有任何代码调用它(sqlx-cli 也没有)。清理留下的库只能靠「同一个测试下次运行」或者手动:
select format('drop database %I;', datname) from pg_database where datname like '\_sqlx\_test\_%';
DATABASE_URL
test_context 读 URL 的那一行是 dotenvy::var("DATABASE_URL").expect("DATABASE_URL must be set"):先看环境变量,再看 .env,变量名写死。sqlx.toml 的 database-url-var(见离线模式与 sqlx.toml)对它无效。给 test-demo 加上 database-url-var = "LAB_DATABASE_URL" 和 sqlx-toml feature,只设 LAB_DATABASE_URL:
---- default_args stdout ----
thread 'default_args' (3391523) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-postgres-0.9.0/src/testing/mod.rs:93:44:
DATABASE_URL must be set: EnvVar(NotPresent)
这个 URL 指向的是主库,测试库建在同一个服务端上,所以 URL 里的用户要有 CREATEDB 权限。按测试单独指定 URL 或用户,上游有两个未关闭的 issue(#2220、#4302)。连接选项是主库的选项换了库名,URL 里的 options=-c search_path=... 之类会原样带进测试库;测试库是新建的,里面只有 public,search_path 指向一个不存在的 schema 时,迁移里不带 schema 的 create table 会失败。把 DATABASE_URL 换成带 ?options=-c%20search_path%3Dmacros 的版本跑 default_args:
---- default_args stdout ----
thread 'default_args' (3433751) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/sqlx-core-0.9.0/src/testing/mod.rs:262:14:
failed to apply migrations: Execute(Database(PgDatabaseError { severity: Error, code: "3F000", message: "no schema has been selected to create in", detail: None, hint: None, position: Some(Original(29)), where: None, schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("namespace.c"), line: Some(500), routine: Some("RangeVarGetCreationNamespace") }))
panic 发生在 setup_test_db 里,库已经建好了,同样不会被删。
动脑筋答案
$ cargo test -q --offline --test db -- --ignored 2>&1 | grep 'test result'
test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 3 filtered out; finished in 0.26s
$ psql "$DATABASE_URL" -Atc "select datname from pg_database where datname like '\_sqlx\_test%' order by 1"
_sqlx_test_FGxAC84grXoVggU6_y7liL3q2JtTO53vEMpy3Rj47tfRNkquLafH
_sqlx_test_UJ6ocwj8OwLgcfnjgLo_vzWcz8Ly_GxgGLGkzXbfrYuM_WRoVG_e
还是 2 个,名字和上一次一样。库名只取决于测试路径,test_context 在建库之前先对这个名字 do_cleanup,上一次留下的库被删掉、重建,测试再失败一次,又留下来。所以失败的库最多每个测试一个,不会越积越多;但改了名或删掉的测试,它的库没有人再来删。
小结
- 没有参数的
#[sqlx::test]只是给 async 测试套一个运行时;有参数时生成TestArgs(测试路径、Migrator、fixtures),按测试函数的签名(Pool、PoolConnection、PoolOptions+ConnectOptions)分派。 - 每个测试一个库:库名是
_sqlx_test_加测试路径 SHA-512 前 39 字节的 base64,63 个字符;主池(max_connections(20))建库,测试拿的是max_connections(5)的子池;记录在主库的_sqlx_test.databases里。#3334(0.8.5)以前库名是序号,和 nextest 冲突。 - 迁移默认用 crate 的
migrations目录,可改路径、换Migrator或关掉;fixtures 按顺序在迁移之后执行,路径相对写测试的源文件。两者都在编译期嵌入。 - 只有测试成功才删库;panic 或返回
Err的库留着,直到同一个测试下次运行时先删后建。cleanup_test_dbs没有调用者。 - URL 写死读
DATABASE_URL(环境变量或.env),不看sqlx.toml;用户要能建库。