Qiita Advent Calender 2021 Unity カレンダー1の8日目の記事です。
前の日は @neusstudio さんの 【Unity】Vivox でボイスチャットを始めよう! - Qiita です!
次の日は @nkjzm さんの【Unity】テスト対象のプレハブを名前で検索するUtilityメソッド - Qiita です!
この記事は、Unityに標準で用意されているテストフレームワークを使ったことがない人や、使ってみたけど今一つ使いどころが分からなかった人向けに、既存のプロジェクトに手軽にテストを導入する例をご紹介します。
目次
ブログの動作環境
- Unity 2020.3.9f1
- Creator Kit - Puzzle 1.0
Unity Test Frameworkとは
Unityには、.NETプラットフォーム向けのテストフレームワークである NUnit を元にした Unity Test Framework がデフォルトで用意されています(以降、UnityTestと書きます)。以下、公式マニュアルです。
ソフトウェアのテストについてはあちこちで述べられているので詳細は割愛しますが、関数やクラス、システムが予想通りに動くかどうかを自動的に確認するためのものです。テストのために引数と結果の組み合わせを検討することで実装内容が明確化できることや、手動でテストする手間の削減、実装後のリファクタリングによるエンバグの発見など、テストの導入には多くのメリットがあります。プログラミングにある程度慣れてきた段階で、軽くでもよいので一度試してみると発見があって面白いと思います。
ゆるく使うとは
テストというとTDD(テスト・ドリブン・デベロップメント)などを語りたくなりますが、既存のプロジェクトでも導入するメリットはあります。このブログでは、Unity Hub2.4.5の「使い方を学ぶ」にあるCreator Kit: Puzzleに用意されているサンプルステージのレベル1を自動操作して、星3つを獲得したかを確認するテストを作ってみます。すぐに享受できるメリットからはじめて、徐々にテストへの理解を深めていくのもよいだろうと思います。
Creator Kit: Puzzleをテストする
プロジェクトの読み込みからテストの作成までの手順です。
対象プロジェクトを開く
- Unity Hubを起動して、使い方を学ぶからCreator Kit: Puzzleを選択します
- はじめての時は、プロジェクトをダウンロード をクリックします
- ダウンロードが完了したら、 プロジェクトを開く をクリックします
- Projectウィンドウから Creator Kit - Puzzle > Scenes > ExampleScenes を開いて Level01 シーンをダブルクリックすると、以下のシーンが開きます
Playして遊んでみてください。スペースキーで仕掛けを動かして、ゴールに玉を転がしたらクリアです。
テスト内容
作成するテストは以下のような流れにします。
- Level01を読み込む
- 開始時に星の獲得数を0に設定
- 適当な秒数が経過するまで、ゴール待機のループ
- ゴールしたら星が3つ獲得できたか確認して、ループ終了
- ボールが一定の位置を通過したら仕掛けを作動
- ループが終了してゴールしていなければ失敗
テストスクリプトの作成
UnityTestで最初にやることは、テスト用のフォルダーを作成することです。
- ProjectウィンドウでTestsフォルダーを作成したいフォルダー(
Assets
など)を右クリックして、Create > Testing > Tests Assembly Folder を選択します。フォルダー名はTests
のままでよいかと思います
この手順はプロジェクトで1回だけでOKです。あとは必要に応じてテストスクリプトを作成します。
作成したスクリプトから不要な行を削除したりして整理します。単体テスト用のメソッドの前には[Test]
、コルーチンで複合的なテストを行うメソッドには[UnityTest]
を書きます。今回は単体テストはしないので[Test]
は不要です。コメントも消して以下のようにすっきりさせておきます。
using System.Collections; using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; public class Level01Tests { [UnityTest] public IEnumerator Level01TestsWithEnumeratorPasses() { yield return null; } }
以上で上書き保存します。
テストの実行
テストコードは何も書いてませんがテストを実行してみましょう。Unityに切り替えて、以下を操作してください。
- Windowメニューから General > Test Runner を選んで、テストランナーウィンドウを起動します
- PlayMode をクリックします
- 先ほど作成したテスト Level01TestsWithEnumeratorPasses をクリックして選択したら、Run Selected をクリックしてテストを実行します
以上でテストランナーが起動します。まだ何もテストコードを書いていないので成功して終了します。
テストのためのAssembly Definitionファイルの作成
UnityTestの面倒ポイントの一つが、テストスクリプトからプロジェクトのスクリプトを参照するための Assembly Definitionファイルを作る必要があることです。ある程度の段階までは触らない機能なので、知らない場合はとにかく以下の操作をして必要なファイルを作成してください。
- Assets/Creator Kit - Puzzle Scripts フォルダーを右クックして、Create > Assembly Definition を選択します
- 作成されたアセットの名前を
CKPuzzle
などにします - Inspectorウィンドウの Assembly Definition References欄の List is Empty の右下の + をクリックします
- 追加される欄の None の右の二重丸をクリックして、Unity TextMeshProを選択します
- 同様に、com.unity.cinemachine と Unity.Postprocessing.Runtime を追加します
- 下の方の Apply ボタンを押します
スクリプトから参照しているものが他にもある場合は、都度、同様の操作で追加する必要があります。
- 先ほど作成した Tests フォルダー内の Testsファイル をクリックして選択します
- Inspectorウィンドウの Assembly Definition References欄に、先ほどと同様の手順で CKPuzzle への参照を追加します
- 下の方の Apply ボタンを押します
以上でテストランナーからプロジェクトのスクリプトを参照できるようになります。
シーンの切り替えとタイムアウト
テストコードの作成開始です。まずは「Level01を読み込んで」「指定秒数待って」「終了時に指定秒数の経過状態をテスト」してみましょう。
先ほどのLevel01Tests
スクリプトを以下のようにします。
- 冒頭に以下の
using
を追加します
// 6: using UnityEngine.SceneManagement;
- テストメソッドを以下のように実装します
// 10: [UnityTest] public IEnumerator Level01TestsWithEnumeratorPasses() { float timeOut = 10; SceneManager.LoadScene("Level01"); yield return null; var timing = GameObject.FindObjectOfType<TimingRecording>(); while (timing.timer < timeOut) { yield return null; } Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか"); }
以上できたら上書き保存してテストランナーを実行してください。操作せずに放っておくと、10秒経過したらテストが失敗して実行が停止します。
テストを開始するとInitTestScene?????
という感じの名前のテスト用のシーンが起動します。このシーン上にテストに必要なオブジェクトをInstantiateで配置する想定なのですが、今回のようにステージが必要なテストをコードで用意するのは面倒です。15行目のようにLoadScene()
でシーンを読み込めば手軽にテストを始められます。
16行目:シーンを読み込んだら yield return null;
で1フレーム待って、シーンを初期化させてます。
17行目:経過時間はTimingRecording
スクリプトのtimer
で確認できます。この辺がpublic
なのは助かります(アクセサなどで読み出し専用にしてたらなお良し)。必要なインスタンスはテストコードなので多少効率が悪くても問題ないだろうということで FindObjectOfType<>()
で強引に取得しました。
18行目:timeOut
に設定した秒数が経過するまで待つwhile()
には、後ほどパドル操作とクリアチェックのコードを追加します。
23行目:最後にAssert
で経過秒数がtimeOut
の時間以内であることを確認します。今は時間が経過するまでwhileループを抜けないので必ずテストは失敗します。
状態をテストするのはAssert.That()
というNUnitが提供するstaticメソッドを使います。最初に検証したいデータが入っている変数、2番目に予想する結果、3番目に失敗時に表示するコメントを書きます。2番目の引数には色々なものが用意されています。詳しくはこの辺りやこの辺りを参照してください。
仕掛けを動かす
テストから仕掛けを動かせるようにするために、元のコードに少し手を加える必要があります。Projectウィンドウから Assets > Creator Kit - Puzzle > Scripts > InteractivePuzzlePieces フォルダーを開いて InteractivePuzzlePiece
スクリプトをエディターで開きます。24行目あたりに仕掛けを動かすためのコードがありますが、Input
が直書きされているのでテストから操作できません。ちょこっと手を加えます。
- InteractivePuzzlePieceクラスに以下のstatic変数を追加します
// 22: public static bool interactKeyState;
- FixedUpdate()のif文に以下のように条件を追加します
// 24: protected void FixedUpdate () { if ((Input.GetKey (interactKey) || interactKeyState) && m_IsControlable) { ApplyActiveState (); } else { ApplyInactiveState (); } }
これでBaseInteractivePuzzlePiece.interactKeyState
にtrue
を代入すれば仕掛けが動き、false
なら解除します。
本来は、入力を管理するクラスを作成して入力を取りまとめた方がいいのですが、本論から外れるので今回はお手軽な手法にしました。
テストを以下のように変更します。
// 10: [UnityTest] public IEnumerator Level01TestsWithEnumeratorPasses() { float timeOut = 10; float activateX = -1.5f; SceneManager.LoadScene("Level01"); yield return null; var marble = GameObject.Find("Marble"); var timing = GameObject.FindObjectOfType<TimingRecording>(); while (timing.timer < timeOut) { yield return null; if (marble.transform.position.x > activateX) { BaseInteractivePuzzlePiece.interactKeyState = true; } } Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか"); }
上書きしてテストを実行したら操作せずに眺めていてください。玉がactivateX
を越えたら仕掛けが自動的に動いてクリアします。まだクリア判定をしていないのでテストは終わりません。UnityのPlayボタンを押して手動で停止してください。
クリアと結果の判定
クリアと獲得した星の数の確認を追加します。どちらもSceneCompletion
スクリプトで確認できます。必要なパラメーターはpublicで宣言されているのでそのまま利用できます。
以下のようにテストスクリプトにコードを追加します。
// 10: [UnityTest] public IEnumerator Level01TestsWithEnumeratorPasses() { float timeOut = 10; float activateX = -1.5f; SceneManager.LoadScene("Level01"); yield return null; var comp = GameObject.FindObjectOfType<SceneCompletion>(); comp.sceneReference.earnedStars = 0; var marble = GameObject.Find("Marble"); var timing = GameObject.FindObjectOfType<TimingRecording>(); while (timing.timer < timeOut) { yield return null; if (marble.transform.position.x > activateX) { BaseInteractivePuzzlePiece.interactKeyState = true; } if (comp.panel.activeSelf) { Assert.That(comp.sceneReference.earnedStars, Is.EqualTo(3), "星3つ"); break; } } Assert.That(timing.timer, Is.LessThan(timeOut), "クリアしたか"); }
追加したのは以下の行です。
- 19行目付近
var comp = GameObject.FindObjectOfType<SceneCompletion>();
comp.sceneReference.earnedStars = 0;
- 32行目付近
if (comp.panel.activeSelf) { Assert.That(comp.sceneReference.earnedStars, Is.EqualTo(3), "星3つ"); break; }
上書き保存をしてテストを実行したら操作せずに見ていてください。クリアしたのち、結果が表示されるタイミングでテストが失敗します。
Expected: 3 But was: 0
上記は、獲得した星の数が「3
を予想したが結果は0
だった」ということで動作結果と一致します。これでテストコード完成です!
仕上げ
テストコードの14行目付近のfloat activateX = -1.5f;
の値を変えれば、仕掛けが動く場所を調整できます。星3つ獲得できたらテストが成功するので、良さそうな値を探してみてください。数当てみたいでこれはこれで楽しめると思います。
これは本来の使い方ではありませんが、自動テストで同じ状況を繰り返し再現できることで、バグを探したり、コード変更の影響を確認するのが楽になりそうなことを実感いただければ幸いです。
また、テストコードの15行目付近に以下のコードを追加してみてください。
// 15: Time.timeScale = 4;
テストを4倍速で実行できます。これもテストの便利なところです。ただし、あまり速くしすぎると物理演算の誤差で結果が不正確になるので倍率はほどほどに。
得られた知見
今回のようなテストをする場合に重要になるのが以下の2点です。
- 操作を外部からできるようにする
- 状態を外部から把握しやすくする
何かを操作するコードに直接Input
を書き込むと今回のようにテストがしにくくなります。オブジェクトを制御するクラスと、入力値を読み取ってアクションに変換するクラスは分けた方がテストがしやすくなります。テストがしやすくなるだけではなく、カットシーンでプレイヤーを自動制御したり、リプレイ機能を付けることも簡単にできるようになるオマケが付いてきます。
ユーザー操作を想定したテストをする場合、人間が無意識にやっている操作できるようになるまで待つというのをテストコードで実装する必要があります。状態を外部から把握しやすくすることで、操作したい状況になるまでの待機が簡単に実装できるようになります。このことは、例えば会話中はプレイヤーの操作を停止させたい、とか、メニューを表示するアニメ中は操作を停止したい、というような仕組み作りに役立ちます。最初からこのような設計で作っておけばゲーム中の面倒な処理が簡単に実装できます。
テストを前提にしたシステムを考えることと、設計について学ぶことは近い関係にあります。ゆるい使い方に慣れてきたら、改めてテストや設計について学んでみてください。
実用例
現在開発中のVoxelorer BirdでもUnityTestを利用しています。
個人制作のそれほど大きなプロジェクトではないので必要と感じた以下のようなもののみ用意しています。
- 保存データの確認
- テスト用のファイルにすることで、自由に保存状態を変えてテストできます
- 発話やメニュー、操作の排他処理確認
- 確認のための手順が多いので、テストで自動操作させることで大幅に省力化できました
- 起動からステージクリア、ステージ選択の流れの確認
- 機能追加や変更をすると不測の不具合が出る可能性があるので、時々、ゲームを一通り操作してみるのが肝要です。手動でやるのは億劫なので、これこそ自動テストの出番です
- Undoやシナリオキューの開発
- 動きがややこしい処理はTDDの出番です。予想外の不具合がいくつも見つかったので大いに助かりました
こんな感じで普通にアプリを起動してステージを読み込んで、じゃかじゃかボタンやプレイヤーの操作をテストランナーに実行させて不具合がないかも確認できます。これで見つけた予想外の不具合が結構あるのでテストの恩恵を実感しています。
最後に
UnityTestは最初から用意されているので、Assembly Definitionファイルの作成さえなんとかなれば導入ハードルはそれほど高くありません。今回紹介したPlayモードのテストは、コルーチンに馴染んでいれば感覚的に利用できると思います。
記事の中で入力のコードを変更しましたが、「テストをしやすいコードにするにはどうしたらよいか」を考えるのは、良い設計への道しるべです。テストの雰囲気が掴めたら、本来のテスト手法や手順も学んでみてください。とっかかりとして、新しい挑戦をしたい時に見る本 Vol3 - まんてらスタジオ - BOOTHの第6章、murnanaさん執筆の「Unityとテスト駆動開発で作る〇×ゲーム」がオススメです。
以上、Qiita Advent Calender 2021 Unity カレンダー1の8日目の記事でした。
前の日は @neusstudio さんの 【Unity】Vivox でボイスチャットを始めよう! - Qiita です!
次の日は @nkjzm さんの【Unity】テスト対象のプレハブを名前で検索するUtilityメソッド - Qiita です!