🧩

unityにおける移植性とテスタビリティ

2021/05/19に公開

要するに

Q. unityでしか動かす予定のないのに、移植性とか考慮して実装する意味なんてあるの?

A. あります。テストを実装する際に効いてくると筆者は考えています。

移植性(とは)

プリンシプル オブ プログラミング 3年目までに身につけたい 一生役立つ101の原理原則
の P.167 に「効率性より移植性」の章があり、以下の文があります。

ソフトウェアの成功を測る物差しの1つに「いくつのプラットフォームで稼働するか」という尺度があります。ハードウェアと切り離すことができないソフトウェアは、そのハードウェアが競争力を持ち続ける間しか価値を持続できません。

unityというゲームエンジンの文脈で移植性というと、unityでプログラミングをすれば、PC/スマートフォン/PS4/NintendoSwitchをはじめ様々なハードウェアで動作可能なので無縁のように感じますが、この記事ではプログラミングにフォーカスし、他のC#の開発環境に移植する 場合を指すものとさせてください。

ハードウェア間の移植性というよりも、今の実装を他の環境/フレームワークに持っていける(あるいはそのままコピペできる)移植性について考えます。

移植を妨げるもの

using Hoge;Hoge が移植先の環境で使用可能かどうかがわかりやすい指標になると思います。例えば、

using System;

public class MyApp {
  public static void Main() {
    Console.WriteLine("hello world!");
  }
}

という実装は、unityでも.Net Coreでもコンパイルエラーにはなりません。(unityで意図通りに実行できるかはおいておいて)しかし、

using UnityEngine;

public class MyBehaviour : MonoBehaviour {
  void Start() {
    Debug.Log("hello world!");
  }
}

という実装は、unityではコンパイルも実行もできますが、.Net Coreでは

エラー	CS0246	型または名前空間の名前 'UnityEngine' が見つかりませんでした (using ディレクティブまたはアセンブリ参照が指定されていることを確認してください)

となります。つまり、UnityEngine をはじめ、特定の環境に依存したコードは別の環境にはおいそれと移植できないのです。

移植性を考えると良い設計になる?

筆者は、unityでのプログラミングにおいて良い設計の方針に

「unityに依存する箇所/しない箇所を分けて、依存する実装のスコープを最小限にすると良いのでは」

という持論を持っていました。文脈は異なるのですが、こちらの記事でも

まずC#の言語機能を抑えよう
・MonoBehaviourを使わないクラス定義のやり方を覚える
・今使っている機能がUnityAPIなのかどうかの区別をつける

と、unity依存の処理かどうかをちゃんと見極められるようになろうという一文があります。

unityが必要な個所とそれ以外を分離して、「それ以外」の箇所をできるだけ増やし、unityでないC#環境にも最大限移植可能にするのが良い方針だと考えたわけです。

しかし、以下の反論も考えられます。

😎「unityでしか使わないんだから class や interface を分けたりする意味なんてないだろ。移植性?YAGNIって知ってる?これだから自称設計中級者は困るんだよな。」
🙄「ぐぬぬ」

なるほど、確かに予定もないのに移植性を考慮して徒に class や interface を増やすと必要以上に実装が複雑になり可読性の低下が懸念されます。しかし、この記事では反論としてテスタビリティ(テスト容易性)という視点を取り入れることで、持論である

「unityに依存する箇所/しない箇所を分けて、依存する実装のスコープを最小限にすると良いのでは」

を補強していきます。ここまで前置きです。

unityにおけるテスト

こちらの記事でunityにおけるテスト導入方法を解説しています。さらに序文に、

レガシーコードとはテストのないコードのことである

ともあり、unityでもテスト書かないとなと思わせる内容になっています。

さて、unityでは EditMode テストと PlayMode テストの2種類ありますが、基本的に高速に実行できる EditMode で実行したいと思うはずです。しかし、MonoBehaviour を EditMode テストで呼び出そうとすると、該当のインスタンスをどうテストに持ってくるかや、[SerializeField] の依存先をどう用意するのかを考慮する必要があるなど障害もあります。いっそシーンをロードしようとなると PlayMode でのテストするのを余儀なくされる場合もあるでしょう。

実装例

秒数を表示するクラスを考えます。

using UnityEngine;
using UnityEngine.UI;

public class TimeView : MonoBehaviour {
    [SerializeField] Text text = default;

    public void SetTime(float seconds) {
        text.text = $"{seconds:F1}秒";
    }
}

さて仮に、

😤「テストのないコードはレガシーコードですよ。ちゃんと実装に対するテストを書きましょうね」

と指摘された場合、どうしますか?

  • TimeView って テストコードにどう持ってくるの?MonoBehaviour だけど new できるっけ?
  • text ってどうやって持ってくるの?
  • SetTime() の結果をどう評価する? text をpublicにするか?

みたいな疑問がきっと浮かんでくるはずです。上記は今のままでも解決できないことは無いと思いますが、ここはテストのために実装を変えた方が良いでしょう。

もしテストを書くとしたらこんな感じかな
[UnityTest]
IEnumerator 指定した時間が表示されるかテスト() {
    // AddComponentでクラスは生成できるが text にアクセスできない
    // var obj = new GameObject();
    // var view = obj.AddComponent<TimeView>();
        
    // シーンにTimeViewという名前で配置しておいてFindで持ってくる
    UnityEngine.SceneManagement.SceneManager.LoadScene("MyScene");
    yield return null;
    var obj = GameObject.Find("TimeView");
    var view = obj.GetComponent<TimeView>();
    view.SetTime(999.9F);
    // TimeViewの子にTextがある前提で反映された結果をFindで取得して評価
    var text = obj.transform.Find("Text").GetComponent<Text>();
    Assert.That(text.text, Is.EqualTo("999.9秒"));

    // 事前にPrefabにしておいて、AssetDatabaseからパスでTimeViewを取得する方法もある
}

テストのための実装

TimeView がテストしづらいのは実装がunityにべったり依存しているからです。
テストしたい実装をunityから引き剥がし、テストしやすい実装に変えていきましょう。

😎「テストのために本体の実装を変えるのってなんか違くない?オレはやだな。」

と思いました?そんなことはありません。プリンシプルオブプログラミング P.112の「テスト容易性」の章にも

テストの品質が本体の品質

とあります。

interfaceによる分離

TimeView の依存関係ははこうなっています

全てのクラスの矢印をたどると結局 UnityEngine に依存しているので、それを interface を挟んで変えてやります。

テストすべき、「秒数を表示する」処理を PresentationAgent というクラスに任せる方針に変更します。そして、間に ITimeView という interface を挟みます。こうすることでPresentationAgent の依存の矢印の先から UnityEngine を無くすことができました。

PresentationAgent.cs
public class PresentationAgent {
    readonly ITimeView view;

    public PresentationAgent(ITimeView view) {
        this.view = view;
    }

    public void SetTime(float seconds) => view.SetTime($"{seconds:F1}秒");
}
ITimeView.cs
public interface ITimeView {
    void SetTime(string value);
}
TimeView.cs
using UnityEngine;
using UnityEngine.UI;

public class TimeView : MonoBehaviour, ITimeView {
    [SerializeField] Text text = default;

    public void SetTime(string value) {
        text.text = value;
    }
}

しれっと float から "XX.X秒" と文字列に変換する処理も TimeView から PresentationAgent に移動させています。unityに依存したテストが難しいクラスからはできるだけロジックを排除するのがunityプロジェクトのテスタビリティを向上させるコツといえるかもしれません。

テストしたい処理からunityへの依存を引き剥がせたため、以下のように書くことができます。

using NUnit.Framework;

public class TestScript {
    class TestTimeView : ITimeView {
        public string DisplayText { set; get; } // setはprivateでもよい

        public void SetTime(string value) {
            DisplayText = value;
        }
    }

    [Test]
    void 指定した時間が表示されるかテスト() {
        var testView = new TestTimeView();
        var agent = new PresentationAgent(testView);
        agent.SetTime(999.9F);
        Assert.That(testView.DisplayText, Is.EqualTo("999.9秒"));
    }
}

もしかすると、

😎「修正後のテストコードだと、最終的にunityの画面に秒数が表示されるか担保されないのでは?」

と疑問に思う人もいるかもしれません。しかし、そこまでの確認は不要と考えています。
なぜなら、テストは自分たちが書いた実装に対して行えば十分 であり「このメソッドの引数に正しい string を渡せばあとはunityが画面に表示してくれる」ところまでテストすればよいのです。unityをテストする必要はないですし、unity社や3rdのライブラリが提供する機能はその開発者がテストしてくれているはずなので自分たちでテストする必要はないのです。

移植性の話は?

修正前の TimeView はunity以外のプロジェクトでは使用できません。しかし、interface分離後の PresentationAgentITimeView やテストコードはC#のプロジェクトであれば移植できるはずです。

今回のような簡単な例ではメリットを実感するのは難しいでしょうが、例えば以下の仕様が追加されているとするとどうでしょうか

  • 時間の表示を hh\:mm\:ss 形式にする
  • 0秒以下の時は --秒 と灰色で表示する
  • 逆に規定秒を超えると赤色で表示する
  • リアルタイムに経過秒数を更新する
  • さらにストップウォッチのように、ボタン操作でポーズ/再開やリセットを行う

MonoBehaviour の中に文字列や色の変更処理を書いてしまうと前述のようにテストが難しくなるので、unityとは独立できるようにするのがベターです。しかし、Color クラスのように unityが提供してくれているから実装が楽になるものもあるので、適用範囲は難しいところです。
また、リアルタイムな時間取得と画面更新もプラットフォーム依存になりそうですし、ボタンのイベント処理をどう扱うかもプロジェクトの方針やプログラマの腕によるでしょう。

まとめ

本記事ではunityプロジェクトのみで利用するクラス実装に関してもテスタビリティを加味すれば、unityに依存する箇所/しない箇所を吟味し分離する方が良いだろうという持論を述べました。一般的にも関心の分離や単一責任の原則などもあるので、基本的にクラスは小さく疎結合にするのが良いと考えています。

しかし、それはunityというゲームエンジンで実装するメリットをつぶしてしまうことにもなります。安易にプロジェクト全体にポリシーを適用するのではなく、適用範囲を自分の頭で考え、プロジェクトの開発が最も捗るような設計を目指していきたいと思います。

Discussion