🕊

BevyでUIの結合テスト

2023/02/14に公開

まえがき

当記事では、BevyというゲームエンジンにおけるUIの結合テストについて記述します。
UIとは銘打っていますが、本質的にはウィンドウのモックの作成手法についてです。
クリック判定等インタラクティブな操作に関するテストではないことに留意ください。

要約

  • WinitPluginを切ることで自動テスト含むサブループでもウィンドウを扱うテストが行える
  • WinitPluginを切るとWindowはデフォルトでは存在しない
  • WindowのPrimary化はコンストラクト時のWindowIdで行う

リポジトリ

https://github.com/NULL-header/ui_anim_handson/tree/729113addb65b4aef8d158259f0f7d8b7c1ae3d1

上リンクの状態にテストを実装します。
振る舞いとしてはテキストが左から右へ移動するだけのシンプルなものです。ただしVal::PxVal::Percentの変換のために画面幅を必要とする場面があります。
なお、以下の記事ではリポジトリのコードを記述するハンズオンが記載されています。参考になるかもしれません。

https://zenn.dev/nullheader/articles/82d640e175eb68

Bevyにおける結合テスト

BevyはCLI上、またはコードベースでの操作をサポートしています。
基礎を知りたい方は以下のリンクが参考になると思われます。

https://github.com/bevyengine/bevy/blob/main/tests/how_to_test_systems.rs

ただし基本的にはロジックは可能な限り切り出し、単体テストとして検証すべきです。
そのため当記事でのBevyでの結合テストは、StateやSystemSet、その他イベントの連携など、Bevy上である程度動作することを確認する程度に留め、あくまでBevyへのAPIの制御に正当性を担保するためのものであるとします。

要件定義

ターゲットとなるリポジトリがState変更によってアニメーションの変更、つまりStyleコンポーネントのデータが書き換えられることをテストします。またStateの変更によってアニメーションが停止することを確認する他、結合することによって、ロジックの単体テストではpanicする筈だった部分がカバーされているかどうかも検証します。

WinitPlugin

まず下準備として、DefaultPluginからWinitPluginを外します。自動テストなのだから元来はWindowの仕組み丸ごとをオフにしたいのですが、UIをテストする以上Windowそのものは不可欠です。
よってWindowを制御するOSのAPIと繋がる部分のみを切ることで、WindowのEntityとしての機能を損なわずにUIをテストすることができます。
なお、WinitPluginを切らなかった場合、WinitPluginに関するエラーが出力されてテストの実行が失敗します。Cargoによる自動テストは並行で動作するものなので、サブスレッドでアプリが起動されますが、ここでWinitPluginはメインスレッドでの起動しか受け付けないからです。
当項のソースは以下になります。

https://github.com/bevyengine/bevy/issues/3754#issuecomment-1024952641

なお、該当コメントが書かれたときのBevyのバージョンは低かったようで、現在とはAPIが異なります。具体的には、以下のリンクを参考にしてください。

https://bevyengine.org/learn/book/migration-guides/0.8-0.9/#plugins-own-their-settings-rework-plugingroup-trait

またコードに表すと以下の通りです。Bevyのバージョンは0.9.1です。

fn app() -> App {
    let mut app = App::new();
    app.add_plugins(DefaultPlugins.build().disable::<WinitPlugin>());
    app
}

WindowIdとPrimaryWindowの仕様

次にWindowのモックを作成します。ここで、WindowのコンストラクタにWindowId::primary()を渡すことで、特定のウィンドウをPrimaryに設定することができます。
逆説的に、WindowId::primary()を渡さなかった場合、ウィンドウのモックを追加してもプライマリとして認識されず、Windowが存在しないエラーが吐かれ続けます。

fn app() -> App {
    let mut app = App::new();
    let window = Window::new(
        WindowId::primary(),
        &WindowDescriptor {
            width: 200.,
            height: 200.,
            ..default()
        },
        200,
        200,
        1.,
        None,
        None,
    );
    app.add_plugins(DefaultPlugins.build().disable::<WinitPlugin>());
    app.world.resource_mut::<Windows>().add(window);
    app
}

結合テスト

これで後はBevyでよくあるような結合テストを行う準備ができました。
テストの実装を見たい方は、リポジトリのmainブランチ最新版を確認してください。

あとがき

Winitのみを無効化するというステップに思い当たらず一日、Windowがプライマリにならずに一日潰したので、備忘録も兼ねて書きました。
なお、当記事、該当リポジトリのテスト部分に限らず、本実装部にもマサカリがあれば投げつけてもらえると嬉しいです。
ところで、実際にテストを組んでいると、Windowのモックに関係はありませんが、マルチスレッドでテストが実行されるためにLogPluginがエラーを吐く現象に当たりました。
その場合もWinitPluginを無効化する手法の応用で対応しました。
またtime.delta_secondsがテスト中にのみ無効化される(0で固定される)部分については、以下のリンクを参考にしました。

https://github.com/bevyengine/bevy/pull/6159#issuecomment-1272382329

ただし、そのままapp.updateをコールしただけでは内部的にフレームが更新されていないようで、フレームを更新したい段階でtime.updateをテストコードから読んでやる必要もありました。

GitHubで編集を提案

Discussion