単体テスト その2 - 単体テスト MonoBehaviours

TOMEK PASZEK Anonymous
Jun 3, 2014|10 分
単体テスト その2 - 単体テスト MonoBehaviours
このウェブページは、お客様の便宜のために機械翻訳されたものです。翻訳されたコンテンツの正確性や信頼性は保証いたしかねます。翻訳されたコンテンツの正確性について疑問をお持ちの場合は、ウェブページの公式な英語版をご覧ください。

前回のブログ記事「Unit testing part 1 - Unit tests by the book」でお約束した通り、今回はテストしやすさを念頭に置いたMonoBehavioursの設計についてお話します。MonoBehaviourは、Unityによって特別な方法で扱われる特別なクラスのようなものです。MonoBehaviourの派生物をインスタンス化しようとするたびに、許可されていないという警告が表示されます。警告を無視せず(警告を無視するのは長期的には良くないことだ!)、良い子のスカウトであれば、どうすればMonoBehaviourをコケにできるのかと自問したかもしれない。良いニュースは、その必要がないということだ!紹介しよう。

謙虚なオブジェクト・パターン

もしあなたがすでにテストを書こうとしているなら、おそらくUI、レガシーコード、ソースコードにアクセスできない悪い設計、高度な並行性を持つ領域など、ユニットテストの天敵につまずいたことがあるだろう。これらの部品がテストしにくいのはなぜか?分離の達成:テストされるものをコンテクストから分離する。レガシーコードに役立つツールはあるが、新しいコードには非常にシンプルなパターンが使える:謙虚なオブジェクト・パターン

このパターンの背景にある考え方はとてもシンプルだ。テストしにくい依存関係を持つコンポーネントをテストしたい場合は、コンポーネントからすべてのロジックを切り出して、別の分離された(したがってテスト可能な)クラスにし、それを参照する。言い換えれば、(テスト作成者の生活を悲惨なものにする依存関係を持つ)問題のあるコンポーネントは、すべてのロジック操作が新しく作成されたクラスに委譲され、ロジックコードをできるだけ持たない非常に薄いコードの層になる。

テストがテスト不可能なコンポーネントへの間接的な依存関係を持つ状態から...。

example-dependancy1

...私たちは、テストが悪い(まあ、単にテスト不可能な)コードにさえ気づかない状態になった:

それだけだ。正直なところ、何の問題もない。

ゲームとテスタビリティ

コードとテストのしやすさという点で、ゲームが特別なのはなぜか?ゲームのテストは、他のソフトウェアのテストとどう違うのですか?個人的には、ゲームはかなり洗練されたソフトウェアだと考えている。ゲームは、あなたが毎日使っているソフトウェアと大差ないというのは甘い考えだ。ゲームでは(もちろん例外はあるが)、ピカピカに磨き上げられたグラフィック、BGM、その他のよくできたサウンドサンプルが見られる。ゲームは多くの場合、さまざまなソースからのリアルタイム入力や、さまざまな出力デバイス(解像度を読み取る)を処理する必要があります。ゲームの場合、非機能要件も厳しくなることがある。マルチプレイヤーゲームでは、フレームレートを一定に保つために必要なパフォーマンスを維持すると同時に、信頼性の高い同期されたネットワーク接続が必要になります。

そのため、さまざまな種類のメディアやテクノロジーに触れる複雑なシステムになる可能性がある。私にとって、ゲームは常にソフトウェアの最終成果物としての傑作であり、(古典的な視覚的な面だけでなく、技術的な舞台裏の面でも)芸術作品として認められることを熱望するものもあった。

統一性 vs テスタビリティ

このような複雑さは、コード・アーキテクチャにも影響を及ぼす。不幸なことに、高性能アーキテクチャは通常、優れたコード設計とは相反する働きをする。特別な設計が必要だったコアメカニズムのひとつに、モノベアビアのメカニズムがある。MonoBehavioursのコールバックがなぜインターフェースや継承で実装されていないのか不思議に思うかもしれませんが、それはパフォーマンス上の理由です(コメント欄のLucas Meijerの説明を参照してください)。詳細は省くが、これはモノベアーズのテスト可能性にも不利に働く。new演算子でMonoBehaviourをインスタンス化できないということは、モッキング・フレームワークを使うことを禁止しているようなものだ。MonoBehaviourが使われるたびに、裏でいろいろなことが起こっているのだから。この行動を阻止することは、多くの問題を引き起こすだろう。

あなた vs テスト可能性

結局のところ、テスト可能なコードを書こうという意欲があるかどうか、それがすべてなのだ。同じ問題を解決するアプローチは数多くあるが、テストの自動化にうまく機能するものは限られている。テスト可能なコードを書こうと思えば、時には必要以上にコードを書かなければならないこともある。もしあなたがまだ学習中であったり(とにかく、私たちは生涯学習すべきではないでしょうか)、テスト自動化の冒険の道に入ったばかりであれば、コードの一部や設計上の仮定が不必要なオーバーヘッドであると感じるかもしれません。しかし、これらはすぐに習慣となり、プロ・オートメーションのデザインを意識せずに使い始めても気づかないほどだ。

このブログ記事では、MonoBehaviourを設計して、その後にそれらをテストできるようにする方法を紹介します。というのも、モノベアビアそのものをテストするわけではないからだ。あなたのデザインにHumble Objectパターンを実装して、よりテストしやすくする方法については、おそらくすでにアイデアをお持ちだと思いますが、それでも、実際のプロジェクトで実装されたアイデアをお見せしましょう。

この例のためにユースケースを作ってみよう。宇宙船を操縦するシンプルなプレイヤー・コントローラーを想像してみてほしい。例を簡単にするために、2Dの世界空間に置いてみよう。宇宙船はあらゆる方向に飛び回れるようにしたい。弾丸(宇宙ロケット?)をまっすぐ撃てる銃を持っているが、一定の発射速度以上の発射はできない。弾丸の数も弾丸ホルダーの容量によって制限されているので、すべて撃ち尽くしたらリロードする必要がある。もっと面白くするために、移動速度を宇宙船の体力に依存させることにしよう。

宇宙船のコントローラーとなるMonobehaviourは次のようになる:

画像

FixedUpdateコールバックでは、入力を読み取り、ユーザーがどのボタンを押したかによってアクションを実行します。スペースシップの周りを移動するには、軸の方向に従って一定の速度でスペースシップの位置を変換する必要がある。コードを見てわかるように、deltaXと deltaY変数は乗算である:Time.fixedDeltaTime、入力軸からの値、およびそれ自体が健康レベルに依存する速度定数。

Fire1イベント(例:マウスの左ボタンクリック)で、弾丸を発射できるかどうかをチェックしたい。そもそも、弾丸ホルダーに少なくとも1発の弾丸が残っている必要がある。第二に、スペース・シップが一定の割合(この場合、半秒に一回)だけ射撃できるようにしたい。そこで、最後の弾丸が発射されてからどれだけの時間が経過したかをチェックする。問題なければ、弾丸をスポーンする。

Fire2イベントは、単に弾丸ホルダーをリロードします。

このロジックのユニットテストを書くには、2つの問題を克服する必要がある。最初のものは、前述したように、継承によって依存する非モックMonoBehaviourクラスです。2つ目の問題は、リアルタイム・ソフトウェアにとってより一般的なものだ。私たちのロジックは時間(発射速度)に依存しているため、Unityから静的なTimeクラスをインターセプトすることができず、ユニットテストを実行することができません。良いニュースは、これらすべてが解決できるということだ。

簡単なメソッド抽出のリファクタリングを適用し、ロジックメソッドがUnity API(この場合は入力処理と弾丸のインスタンス化)を参照しないように留意して、コードを少しリファクタリングしてみましょう。if文の時間依存性も、別のメソッドに抽出すべきである。最終的な結果は、多かれ少なかれこのようになるはずだ:

example2

見ての通り、ここでのFixedUpdateメソッドは、ユーザーからの入力をロジック部分のメソッドに渡すだけである。発射速度チェックはCanFireメソッドに抽出され、指定された時間が経過した場合に "true "という結果を生成する。この抽出は、後でユニットテストを書くために重要である。もし今、SpaceshipMotorクラスをモックできたとしたら、単純にCanFireメソッドをインターセプトして、意図したときにtrueかfalseを返すようにするだけだ。そうすれば、テストは時間に左右されなくなる。しかし、SpaceshipMotorは Monobehaviourを継承しているためモックできないのでHumble Objectパターンを適用する必要がある。

どうすればいいんだ?UnityのAPIを使わないロジックコードをすべて別のクラスに抽出し、その参照をSpaceshipMotorに導入するだけです。もう一度クラスを見て、何を抽出するか見てみよう。TranformPositionと InstanciateBulletはUnity APIを使用しますが、それ以外はすべて抽出できます。静的なTimeクラスもあることは知っているが、それはまた後で。

実際の抽出を行う前に最後に説明しなければならないのは、抽出されたロジックがUnity APIに依存することなく、どのようにUnity APIと通信するかということです。ここがインターフェイスの出番だ。ロジックを持つクラスはインターフェイスへの参照を持ち、実際の実装は気にしない。物事をシンプルにするために、これらのインターフェースをMonoBehaviour自体に直接実装することができます!次の2つのクラスを見てみよう:

例3
例4

SpaceshipMotorクラスから始めよう。このクラスは、スペース・シップの位置を変換したり、弾丸をインスタンス化したりするインターフェースを実装している。クラス自体には、現在すべてのロジックを実装しているSpaceshipControllerを参照するフィールドがある。SpaceshipControllerクラスはSpaceshipMotorについて何も知らず、唯一できることは、参照するインターフェースのメソッドを呼び出すことだけだ。

Unityはインターフェイスへの参照をシリアライズしない。シリアライズを気にしないのであれば、SpaceshipControllerクラスを作成するときにインターフェイスの参照を渡せばいい。そうでなければ、シリアライズが行われた後に毎回呼び出されるOnEnableコールバックで参照を設定することができる。念のために言っておくと、SpaceshipMotorクラス全体は通常の方法でシリアライズされる。

SpaceshipMotorで Timeクラスが参照されていることにお気づきだろう。ここにUnity APIのリファレンスは入れるべきではないと言いましたが、時間依存の依存関係を処理するための別のアプローチを示すために入れておきました。理想的には、単純にTime.timeの値をメソッドの引数として渡せばいい。

UMLファンにとっては、これが(簡略化された)UMLダイアグラムとしての最終結果である:

example-uml1
単体テスト

非連結化されたSpaceshipMotorクラスを使えば、ユニットテストを書くことを妨げるものは何もない。テストのひとつを見てみよう:

例5

このテストでは、弾が残っていない場合は発射できないことを検証している。テストそのものは、アレンジ・アクト・アサートのパターンに従って構成されている。アレンジ・パートでは、GetGunMockメソッドとGetControllerMockメソッドでオブジェクト・モックを作成する。GetControllerMock はモックを作成するだけでなく、CanFireメソッドの振る舞いをオーバーライドして常に true を返すようにします。これにより、コントローラオブジェクトから時間依存性が取り除かれる。次に、現在の弾丸の番号を0に設定する。その後、コントローラクラスにfireを適用し、ガンコントローラのインターフェイスでfireが呼び出されていないことを確認する。

プロジェクトにはまだいくつかテストがある。ここから入手して、ちょっと遊んでみてください。モッキング・オブジェクトにはNSubstituteを使った。また、Unity Test Toolsにもこのバージョンを同梱しています。ここで説明した3つのバージョンのコントローラーは、すべてプロジェクトに添付されている。

今日は以上だ。楽しんで読んでいただけたら幸いです!

トメック