🦀

Rustのテストでsetup/teardownをする

2022/06/01に公開

TL;DR

同期版

fn run_test<F>(f: F)
where
    F: FnOnce(),
{
    setup(); // 予めやりたい処理
    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
    teardown(); // 後片付け処理

    if let Err(err) = result {
        panic::resume_unwind(err);
    }
}

※ 同期版に限っては Drop を使った実装をした方が良いと思いますが今回は割愛

非同期版

use futures::future::FutureExt as _;

async fn run_test_async<F>(test: F)
where
    F: std::future::Future,
{
    setup_async().await; // 予めやりたい処理
    let result = std::panic::AssertUnwindSafe(test).catch_unwind().await;
    teardown_async().await; // 後片付け処理

    if let Err(err) = result {
        panic::resume_unwind(err);
    }
}

モチベーション

というわけでテストを書きましょう。

僕が今作っているアプリではデータベースにデータを投入したりするため、各テストケースの最後にはデータベースをリセットする必要があります。そうなるとteardown処理( rspecで言うところの after )が欲しくなりますね。

panic

rustでテストを書く時は assert_eq マクロを使うことが多いと思いますが、このマクロはアサーションに失敗するとpanicするため、そのままでは後続の処理が行われません。

#[test]
fn test_something() {
    setup_database();

    let result = update_database();
    assert_eq!(result, 42);
    
    // 後始末
    // assert_eqでパニックすると呼ばれない!!
    clean_database();
}

Result型をテストで使うことで assert_eq などを使わないようにすれば多くの場合 clean_database まで辿り着くことができるでしょうが、それでも予期せぬpanicが発生すると clean_database が実行されません。

catch_unwind

rustの標準ライブラリには catch_unwind という関数があります。この関数は渡したクロージャー内でpanicが発生するとそれを捕捉し、ハンドリングを可能にします。

また、 resume_unwind で捕捉したpanic処理を再開することができます。

では簡単なサンプルを書いてみましょう。

#[test]
fn test_sample() {
    println!("setup!!!!");

    let result = std::panic::catch_unwind(|| {
        assert_eq!(1, 2); // 意図的に失敗させる
    });

    println!("teardown!!!!");

    if let Err(err) = result {
        std::panic::resume_unwind(err);
    }
}

実行します

running 1 test
test tests::test_sample ... FAILED

failures:

---- tests::test_sample stdout ----
setup!!!!
thread 'tests::test_sample' panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/main.rs:24:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
teardown!!!!


failures:
    tests::test_sample

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--bin async_test_sample'

setup!!!!teardown!!!! が表示されていますね。 test_sample 中でpanicが起きてもちゃんと setup 関数と teardown 関数が呼ばれているようです。

ちなみに「 teardown を呼んでから捕捉したpanicを再開しているから、 setupteardown → panicのメッセージという順序で表示されるのでは?」と思うかもしれませんが、 Rustのパニック機構 - 簡潔なQ によると、panicのメッセージは catch_unwind で捕捉される前のパニックハンドラで出力されているため teardown より先に出力されているように思われます。最後にteardownの表示がされた方が直感的な気がするのでこれはこれで良いと思います。

Rustのパニック機構 - 簡潔なQ はrustのパニックの仕組みについて詳しく説明されていて非常に勉強になりました。

UnwindSafe

先程の catch_unwind 関数のトレイト境界に UnwindSafe というトレイトが出てきました。こいつは一体何者でしょうか。

是非 公式ドキュメントRustのパニック機構 - 簡潔なQ を読んでいただきたいのですが、「処理の途中でpanicが発生すると不変条件が壊れる可能性があるので、不変条件が壊れる可能性がある処理では警告のために UnwindSafe が付かないようにしている」という理解をしています。

先程のサンプルは簡単過ぎて UnwindSafe な処理だったようです。今度は UnwindSafe じゃない処理にしてみましょう。

 #[test]
 fn test_sample() {
     println!("setup!!!!");

+    let mut a = 0;

     let result = std::panic::catch_unwind(|| {
+        a[0] = 1;
         assert_eq!(1, 2); // 意図的に失敗させる
     });

     println!("teardown!!!!");

     if let Err(err) = result {
         std::panic::resume_unwind(err);
     }
 }
[Running 'cargo test']
   Compiling async_test_sample v0.1.0 (/home/rust/async_test)
error[E0277]: the type `&mut i32` may not be safely transferred across an unwind boundary
   --> src/main.rs:25:18
    |
25  |       let result = std::panic::catch_unwind(|| {    
    |  __________________^^^^^^^^^^^^^^^^^^^^^^^^_-
    | |                  |
    | |                  `&mut i32` may not be safely transferred across an unwind boundary
26  | |         a = 1;
27  | |         assert_eq!(1, 2); // 意図的に失敗させる
28  | |     });
    | |_____- within this `[closure@src/main.rs:25:43: 28:6]`
    |
    = help: within `[closure@src/main.rs:25:43: 28:6]`, the trait `UnwindSafe` is not implemented for `&mut i32`
    = note: `UnwindSafe` is implemented for `&i32`, but not for `&mut i32`
    = note: required because it appears within the type `[closure@src/main.rs:25:43: 28:6]`
note: required by a bound in `catch_unwind`
   --> /home/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std/src/panic.rs:128:40
    |
128 | pub fn catch_unwind<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R> {
    |                                        ^^^^^^^^^^ required by this bound in `catch_unwind`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_test_sample` due to previous error

コンパイルエラーになりました。

「全部 catch_unwind に渡すクロージャーの中に入れてしまえば良いのでは?」と思いますがそうとも限らない場合があります。そのようなケースの1つが非同期処理の時です。

非同期処理

まずは UnwindSafe のことを考えずに非同期版のteardown処理を書いてみます。

ありがたいことに futures crate にfuture版 catch_unwind が定義されているのでこれを使います。

use futures::future::FutureExt as _;

#[tokio::test]
async fn test_sample() {
    println!("setup!!!!");

    let result = async {
        assert_eq!(1, 2); // 意図的に失敗させる
    }.catch_unwind().await;

    println!("teardown!!!!");

    if let Err(err) = result {
        std::panic::resume_unwind(err);
    }
}

これはまだコンパイルが通ります。

しかし catch_unwind しようとするasyncブロックに適当な非同期処理を追加するとエラーになります。

 use futures::future::FutureExt as _;

 #[tokio::test]
 async fn test_sample() {
     println!("setup!!!!");

     let result = async {
+        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
         assert_eq!(1, 2); // 意図的に失敗させる
     }.catch_unwind().await;

     println!("teardown!!!!");

     if let Err(err) = result {
         std::panic::resume_unwind(err);
     }
 }

長大なメッセージに驚きますがどれも「 UnwindSafe ではない」という内容です。

error[E0277]: the type `UnsafeCell<tokio::time::driver::InnerState>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  --> src/main.rs:26:7
   |
26 |     }.catch_unwind().await;
   |       ^^^^^^^^^^^^ `UnsafeCell<tokio::time::driver::InnerState>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
   |
   = help: within `tokio::time::driver::Inner`, the trait `RefUnwindSafe` is not implemented for `UnsafeCell<tokio::time::driver::InnerState>`
note: future does not implement `UnwindSafe` as it awaits another future which does not implement `UnwindSafe`
  --> src/main.rs:24:9
   |
24 |         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `Sleep`, which does not implement `UnwindSafe`

error[E0277]: the type `UnsafeCell<Result<(), tokio::time::error::Error>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  --> src/main.rs:26:7
   |
26 |     }.catch_unwind().await;
   |       ^^^^^^^^^^^^ `UnsafeCell<Result<(), tokio::time::error::Error>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
   |
   = help: within `tokio::time::driver::entry::TimerShared`, the trait `RefUnwindSafe` is not implemented for `UnsafeCell<Result<(), tokio::time::error::Error>>`
note: future does not implement `UnwindSafe` as it awaits another future which does not implement `UnwindSafe`
  --> src/main.rs:24:9
   |
24 |         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `Sleep`, which does not implement `UnwindSafe`

error[E0277]: the type `UnsafeCell<tokio::util::linked_list::Pointers<tokio::time::driver::entry::TimerShared>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  --> src/main.rs:26:7
   |
26 |     }.catch_unwind().await;
   |       ^^^^^^^^^^^^ `UnsafeCell<tokio::util::linked_list::Pointers<tokio::time::driver::entry::TimerShared>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
   |
   = help: within `tokio::time::driver::entry::TimerShared`, the trait `RefUnwindSafe` is not implemented for `UnsafeCell<tokio::util::linked_list::Pointers<tokio::time::driver::entry::TimerShared>>`
note: future does not implement `UnwindSafe` as it awaits another future which does not implement `UnwindSafe`
  --> src/main.rs:24:9
   |
24 |         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `Sleep`, which does not implement `UnwindSafe`

error[E0277]: the type `UnsafeCell<AtomicUsize>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  --> src/main.rs:26:7
   |
26 |     }.catch_unwind().await;
   |       ^^^^^^^^^^^^ `UnsafeCell<AtomicUsize>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
   |
   = help: within `tokio::time::driver::entry::TimerShared`, the trait `RefUnwindSafe` is not implemented for `UnsafeCell<AtomicUsize>`
note: future does not implement `UnwindSafe` as it awaits another future which does not implement `UnwindSafe`
  --> src/main.rs:24:9
   |
24 |         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `Sleep`, which does not implement `UnwindSafe`

error[E0277]: the type `UnsafeCell<Option<Waker>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
  --> src/main.rs:26:7
   |
26 |     }.catch_unwind().await;
   |       ^^^^^^^^^^^^ `UnsafeCell<Option<Waker>>` may contain interior mutability and a reference may not be safely transferrable across a catch_unwind boundary
   |
   = help: within `tokio::time::driver::entry::TimerShared`, the trait `RefUnwindSafe` is not implemented for `UnsafeCell<Option<Waker>>`
note: future does not implement `UnwindSafe` as it awaits another future which does not implement `UnwindSafe`
  --> src/main.rs:24:9
   |
24 |         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ await occurs here on type `Sleep`, which does not implement `UnwindSafe`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `async_test_sample` due to 5 previous errors

AssertUnwindSafe

このエラーは AssertUnwindSafe を使うことで解決できます。

この構造体で包むと任意のデータを UnwindSafe にできます。「panicによるunwindが起きても不変条件は壊れない( あるいは壊れても問題ない )」ということを表明できます。

 use futures::future::FutureExt as _;

 #[tokio::test]
 async fn test_sample() {
     println!("setup!!!!");

-    let result = async {
+    let result = std::panic::AssertUnwindSafe(async {
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
         assert_eq!(1, 2); // 意図的に失敗させる
-    }.catch_unwind().await;
+    }).catch_unwind().await;

     println!("teardown!!!!");

     if let Err(err) = result {
         std::panic::resume_unwind(err);
     }
 }

AssertUnwindSafe で包んでいるfutureを引数で受け取るようにしたのがTL;DRに書いた run_test run_test_async になります。

まとめ

  • catch_unwind を使うとpanicを捕捉できる
  • catch_unwind に渡す処理は UnwindSafe である必要がある
  • UnwindSafe はpanicしても不変条件が壊れないなことのマーカー
  • 非同期処理は UnwindSafe にならないことが多い
  • AssertUnwindSafe を使うと UnwindSafe であることを表明できる

余談

非同期版 Drop 早く来て欲しい…

Discussion