Rust in Everything
基于 sqlx 0.9.0

#[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 做的事:

  1. 从 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 才连。
  2. 在主库里执行一段 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 邮件列表)。
  3. 算库名 Postgres::db_name(args),先 do_cleanup(drop database if exists 加删记录),再插入一条记录,create database "<库名>"。
  4. 返回给测试用的 PoolOptions:max_connections(5),idle_timeout 1 秒,.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。

📝NOTE
冷知识:库名原来是一个递增的序号

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 里也有它们的记录。

❗IMPORTANT
动脑筋:失败的测试再跑一遍

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;用户要能建库。

💬 关联讨论

发起讨论
加载中...