Unity を検索

Unity Test Runner でテスト駆動開発を試す

2018年11月2日 カテゴリ: テクノロジー | 7 分 で読めます
取り上げているトピック
シェア

テスト駆動開発(TDD)とは、コードそのものを書く前にコード部品の自動テストを書くプラクティスのことです。本ブログ記事では、私が同僚たちと、ゲーム開発においてどのように TDD を使ったかをコード例を示しながら説明し、うまくいったことも、うまくいかなかったことも両方お伝えしようと思います。TDD はすべてを解決してくれる手法ではありませんが、結果として私たちは多くのことを学び、そしてよりレベルの高い開発者になれたと思っています。なお私たちのプロジェクトでは、Unity Test Runnerを使いました。このツールは、Unity で NUnit テストの記述と実行を行うためのシステムです。

一般的な TDD のワークフローは次のようなものです。

  1. まず、コードが何をするかを決めます。たとえば、codeHasRun の値を false から true にするコードを書くとします。
  2. 次に、コードが正しく動作したかチェックするテストを書きます。この場合は、テストは codeHasRun が true であるかチェックします。
  3. テストを実行します。コードがまだ書かれていないので、テストは当然失敗します。
  4. コードを書きます。
  5. テストを実行します。今度はテストが成功するはずです。

このワークフローに従えば、コードをリファクタリングして変更を加えるプロセスをスピードアップできます。なぜなら、何が壊れていて、なぜそれが壊れているかが明確にわかるからです。

なぜコードそのものを書く前にテストを書くのか、不思議に思う方もいらっしゃるでしょう。その理由は、コードを書いてからテストを書くようにすると、開発者はしばしばテストが通るようにテストを書いてしまうことがあるからです。まず失敗するテストを書けば、テストが失敗するのには妥当な理由(たとえば、必要な機能が正しく実装されていない)があることを確信でき、誤検知を排除することができます。

TDD はソフトウェア開発で広く活用されていますが、ゲーム開発での活用例は稀です。

この記事を書いた前の月に、Unity のリリースエンジニアリンググループ内のさまざまなチームから 5 人が集まって、TDD を活用したゲーム制作について調査を行いました。私は以前、自分たちのゲームコードを使って自動化されたゲームを作ることはできず、TDD を使うこともできないと考える開発者がいると聞いていたので、それが本当かを自分の目で確かめてみたいと思ったのです。

私たちは、Pong、Snake、Asteroids、Flappy Bird など、古典的なゲームを作ってみることにしました。そうすれば、ゲームプレイをデザインする時間を省けるし、それぞれのゲームがどのように動くか(少なくとも、どのように動くかのアイデア)の大体のイメージはつけることができたからです。

しかし、それぞれのゲームがどのような感じであるか知っていても、テストの組み立て方を決めるには、やはりコンセプトを深く検討していく必要がありました。Pong を例にとれば、私は「パドルが動く」ことは知っていましたが、そもそも「パドル」がどのように作られているのか、十分に説明できませんでした。このように、すべてのアイデアを細かく検討していく必要があったのです。

私たちは、Pong のパドルを次のような属性に分解しました。

  • スケールを (0.5, 2, 0) に設定した長方形である
  • XY 平面上を移動できる
  • Z 方向には動けない
  • 左右には動けない
  • 画面外には移動できない
  • コリジョンがある
  • Kinematic な Rigidbody である

このように属性へと分解することで、どのようにテストを書き始めればよいかが明確になりました。テストは例えば次のようになります。

       [Test]
       public void AtLeastOnePaddleIsSuccesfullyCreated()
       {
           GameObject[] paddles = CreatePaddles();

           // paddles オブジェクトが存在するかチェックするための Assert
           Assert.IsNotNull(paddles);
       }
       [Test]
       public void TwoPaddlesAreSuccesfullyCreated()
       {
           GameObject[] paddles = CreatePaddles();

           // paddles に入っているオブジェクトが 2 つであるかチェックするための Assert
           Assert.AreEqual(2, paddles.Length);
       }

これらのテストから、私は GameObject を 2 つ格納した配列を生成する CreatePaddles というメソッドを書く必要があるとわかります。

Unity Test Runner には、UnityTest などの機能も搭載されています。UnityTest は、IEnumerator を 1 つ返し、再生モードではコルーチンとして実行されます。完了までに 1 フレーム以上必要なアクションをテストするために使うことができます。

この例では、パドルが盤面の端を超えていかないかチェックするために UnityTest を使いました。

[UnityTest]
       public IEnumerator Paddle1StaysInUpperCameraBounds()
       {
           // テストを素早く実行するために timeScale の値を大きくする
           Time.timeScale = 20.0f;

           // _setup は TestSetup クラスのメンバーで
           // テストシーンをセットアップするためのコードを格納する(大量のコードをコピーアンドペーストしなくてもいいように)
           Camera cam = _setup.CreateCameraForTest();

           GameObject[] paddles = _setup.CreatePaddlesForTest();

           float time = 0;
           while (time < 5)
           {
               paddles[0].GetComponent().RenderPaddle();
               paddles[0].GetComponent().MoveUpY("Paddle1");
               time += Time.fixedDeltaTime;
	        yield return new WaitForFixedUpdate();
           }

           // timeScale をリセット
           Time.timeScale = 1.0f;

           // パドルの縁はスクリーンの端を超えてはいけない
           // (Camera.main.orthographicSize - paddle.transform.localScale.y /2) は
           // パドルの縁が画面の端に触れる位置を示し、0.15 は誤差として加える値
           // 次のフレームまで待つ
           Assert.LessOrEqual(paddles[0].transform.position.y, (Camera.main.orthographicSize - paddles[1].transform.localScale.y /2)+0.15f);
        }

ユニットテストはある機能を最小限に細かく分割した部品をテストするように書くべきであり、そのため、私は片方ずつのパドルについて、盤面の上端と下端の両方にある場合をテストしました。

もしここで deltaTime を使ったら、deltaTime の値は一定ではないため、テストは不安定になります。私はTime.captureFramerate や、Time.fixedDeltaTime を設定して、フレームにかかる時間を予測可能なものにしました。また、最初に Time.timeScale を設定しない場合は、テスト完了までの時間が 5 秒を超えてしまいました。これは 1 つのテストにかける時間としては受け入れられない数字です。timeScale の値を 20 に設定すると、ゲーム内の時間は通常の 20 倍速く進むようになり、5 秒かかるテストも約 0.25 秒で完了できるようになります。

ここまでに示したテストは、パドルを 5 秒間上方向に動かす動作をシミュレートします。テストはパドルが MoveUpY メソッドを使って動かされるとき、盤面の端を超えていかないかをチェックします。最初にテストを書いたときは、MoveUpY にはボードを超えていかないようにパドルを止める機能がなかったため、テストが失敗しました。

テストを書いた後、実際の機能を実装する前の段階では確かにテストが失敗する、つまり最初は誤検知してしまうことを確認することは非常に重要です。このプロジェクトを始めてすぐの頃、テストが最初に失敗するかチェックしなかったために、ゲームの中である機能が壊れているのにテストは通ってしまう状態になったことがありました。そして自分の書いたテストをもう一度見直すと、テストを間違えて書いていたことに気が付いたのです(なんと、テストしているメソッドを呼ぶのを忘れていました)。この時はすごく恥ずかしい思いをしましたが、これを教訓として、そのあとは機能を実装する前にちゃんとテストが失敗することを確認する習慣がつきました。Unity Test Runner には失敗したテストを再実行する機能もあるので、プロジェクトに大量のテストがある場合も、素早くイテレーションを回すことが可能です。

TDD は、導入しはじめのときは少し作業速度を下げるかもしれませんが、習熟すると、作業上で大きな見返りが期待できる手法です。また、あとからプロジェクトに修正を加えるときも、より素早く、より安全に修正をすることができます。

また、このような作業スタイルは、ゲームの中でさまざまなシステムがどのように働くのかを理解する助けにもなりました。私たちはよく知っているゲームを真似して作ろうとしたのですが、それでもゲームの設計に入っているシステムがそれぞれどのように組み合わさるのかははっきりわかりませんでした。最初にテストを書くことで、システムの中にあるものをどのように動作させるかを計画することができ、実践を通してそれが本当に可能であるかを検証することができました。また、必要に応じてコードを書き直して試すこともできました。私自身についても、TDD を使うことで、何を書こうとしていて、なぜそれを書こうとするのかをより深く考えるようになったため、より良い、より整ったコードを書くようになったことに気づきました。

TDD がすべてを解決する手段ではないことは認識しておく必要がありますが、セーフティーネットとして非常に優秀な手段であり、また、プロジェクトで一度テストを整備されれば、イテレーションの速度を上げることも可能になります。また、Unity Test Runner ウィンドウに表示されたすべてのテストに、緑のチェックマークが付いていく様子は見ていて気持ちのいいものです。しかし、TDD を使えば他のテストは必要ないというわけではありません。TDD は品質駆動の開発アプローチの 1 つであり、QA 戦略ではないためです。

最後に、このプロジェクトに一緒に取り組んでくれた、同僚の Marc Di Luzio、Ugnius Dovidaukas、Linas Ratkevičius、Andy Selby に感謝の念を示します。

2018年11月2日 カテゴリ: テクノロジー | 7 分 で読めます
取り上げているトピック