1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityエンジニアのための設計入門⑤ 保守性を高めるテスト, モジュール境界の強制

Last updated at Posted at 2025-07-10

シリーズ目次

①設計を学ぶ意義
②複雑な分岐にState, 要求と実行の分離にCommand
③DIとPub/Subと疎結合
④クリーンアーキテクチャ入門
⑤保守性を高めるテスト, モジュール境界の強制 ← 今ここ

今回の目的

  • 主にジュニアエンジニアの方が以下をイメージできるようになることを目指します
    • 前回のアーキテクチャを実装した場合、どのレイヤーにどの種類のテストを書くか
    • テストやモジュール境界の強制などによって、設計で確保した保守性をさらに強固に出来ること

テストとは

ソフトウェアのテストには手動テストと自動テスト(テストコード)の2種類があります。

  • 手動テスト

    • UnityではPlayを押して画面を触り、目視で動くかを確かめる方法
    • 直感的ですが、毎回人が操作するため再現性は低く、時間も掛かります
  • 自動テスト

    • 「入力 → 実行 → 期待結果」をC#で書き、PCに高速かつ同条件で期待結果となるか確認させる方法
    • 変更による破壊をすぐに検知できます

例)足し算メソッドが正しく動くかを確認する自動テスト:

// Production code
public static class Calculator
{
    public static int Add(int a, int b) => a + b;
}

// Test code
[Test]
public void Add_ReturnsCorrectSum()
{
    int result = Calculator.Add(2, 3);
    Assert.That(result, Is.EqualTo(5));
}

自動テストを組み込むと、コードを変更しても不具合を検知しやすくなるため、安心して開発を進められます。
このとき、設計で確保した保守性がさらに強固になり、長期的なメンテナンスも楽になります。

Unity Test Framework

Unity公式のUnity Test Frameworkでは、用途に応じて次の2種類の自動テストが実行できます。

  • EditMode — C#のみで動く高速テスト
  • PlayMode — シーンをロードして物理挙動やUIを確認

詳しくは以下が理解しやすいかと思います。

テストピラミッドとは

テストピラミッドの解説はこちらがそのものだと思います。

image.png
※先ほどのgihyo.jpの記事から引用

前回のアーキテクチャを実装した場合、以下の対応関係になると考えられます。

ピラミッドの層 主なテスト種別 主な対象レイヤー
Unit(単体) EditMode Domain, Application層内の処理
Integration(結合) PlayMode Presentationを介して層をまたぐ処理
E2E PlayMode(in player) Infrastructureを含むアプリ全体

前回のクリーンアーキテクチャ実装例でテストを行う

Domain, Applicationで単体テスト

Domain層

Domain層の純粋なC#クラスはEditModeテストだけで確実に検証できます。
ここではScoreクラスのコードとテストコードを並べて示します。

まずはScoreクラスのコードです。

public class Score
{
    public int Value { get; }

    public Score(int rawValue)
    {
        Value = Math.Clamp(rawValue, 0, 10_000);
    }

    public Score Add(int inc)
    {
        return new Score(Value + inc);
    }
}

Scoreのテストコードは以下のようになります。

    public class ScoreTests
    {
        // コンストラクタ
        [TestCase(-1, 0)]
        [TestCase(0, 0)]
        [TestCase(123, 123)]
        [TestCase(10_000, 10_000)]
        [TestCase(12_345, 10_000)]
        public void Ctor_Clamps_RawValue(int raw, int expected)
        {
            var score = new Domain.Score(raw);
            Assert.That(score.Value, Is.EqualTo(expected));
        }

        // Addメソッド
        [Test]
        public void Add_PositiveIncrement_ReturnsNewInstance()
        {
            var original = new Domain.Score(100);
            var updated = original.Add(50);

            Assert.That(original.Value, Is.EqualTo(100), "元インスタンスが不変");
            Assert.That(updated.Value, Is.EqualTo(150), "加算結果が正しい");
        }

        // ~~~ (他にもテストケースがあるが省略) ~~~
    }

テストコードが正しく実装されていれば、結果は次のようになります。

スクリーンショット 2025-07-10 3.32.27.png

Application層

Application層は外部依存(Repository/Pub‑Sub)をモックに差し替えることで、
純粋なロジックだけをEditModeでテストできます。

実装によってはApplication層で結合テストをすることもあり得ます。
ただ、この層で生じる不具合はPresentation層などを変更しても再現する根本的なものなので、
単体テスト出来ることが望ましいと考えられます。

ここではAddScoreInteractorを題材として順に解説します。

  1. AddScoreInteractorクラス
  2. 依存のモック実装
  3. テスト用LifetimeScopeを実装
  4. テストコード
AddScoreInteractorクラス

Scoreは既に単体テストが存在する上に副作用ゼロの値オブジェクトなので、この中でnewしてもテスト粒度は保たれます。
ただし、説明のために省きましたが、Factoryのようなクラスがあっても良いかもしれません。

// DTO
public struct AddScoreRequest  { public int Gain; }
public struct AddScoreUpdated { public int Total; }

// 具体
public class AddScoreInteractor : IStartable, IDisposable
{
    private IScoreRepository repository;
    private IPublisher<AddScoreUpdated> publisher;
    private ISubscriber<AddScoreRequest> addScoreRequested;
    private IDisposable subscription;

    private Score current;

    public AddScoreInteractor(
        IScoreRepository repository,
        IPublisher<AddScoreUpdated> publisher,
        ISubscriber<AddScoreRequest> addScoreRequested
    )
    {
        this.repository = repository;
        this.publisher = publisher;
        this.addScoreRequested = addScoreRequested;
    }

    public void Start()
    {
        InitializeAsync().Forget();
        subscription = addScoreRequested.Subscribe(req => HandleAsync(req).Forget());
    }

    public void Dispose() => subscription?.Dispose();

    async UniTask InitializeAsync()
    {
        int raw = await repository.LoadAsync();
        current = new Score(raw);
        
        publisher.Publish(new AddScoreUpdated { Total = current.Value });
    }

    async UniTask HandleAsync(AddScoreRequest req)
    {
        current = current.Add(req.Gain);
        
        await repository.SaveAsync(current.Value);
        
        publisher.Publish(new AddScoreUpdated { Total = current.Value });
    }
}
依存のモック実装
    // Repositoryをメモリに置き換え
    public class InMemoryScoreRepository : IScoreRepository
    {
        public int Store;
        public int SaveCallCount;

        public InMemoryScoreRepository(int initial = 0) => Store = initial;

        public UniTask<int> LoadAsync()
        {
            return UniTask.FromResult(Store);
        }

        public UniTask SaveAsync(int v)
        {
            Store = v;
            SaveCallCount++;
            return UniTask.CompletedTask;
        }
    }

    // Publishされたメッセージを格納するモック
    public class MockPublisher<T> : IPublisher<T>
    {
        public List<T> Messages = new();

        public void Publish(T m) => Messages.Add(m);
    }
    
    // テスト側から手動でFireできるモックSubscriber
    public class MockSubscriber<T> : ISubscriber<T>
    {
        readonly List<IMessageHandler<T>> _handlers = new();

        public IDisposable Subscribe(IMessageHandler<T> handler, params MessageHandlerFilter<T>[] _)
        {
            _handlers.Add(handler);
            return new Unsubscriber(() => _handlers.Remove(handler));
        }

        public void Fire(in T message)
        {
            foreach (var h in _handlers.ToArray())
                h.Handle(message);
        }

        sealed class Unsubscriber : IDisposable
        {
            Action _dispose;

            public Unsubscriber(Action d) => _dispose = d;

            public void Dispose()
            {
                _dispose?.Invoke();
                _dispose = null;
            }
        }
    }
テスト用LifetimeScopeを実装

PlayerPrefsではなくモックで実装する必要があるため、初期値_initialを設けています。

    public static class QuizTestScope
    {
        public static LifetimeScope Create(int initialScore = 0)
        {
            var scope = LifetimeScope.Create(
                builder =>
                {
                    // Repository
                    builder.RegisterInstance<IScoreRepository>(
                        new InMemoryScoreRepository(initialScore)
                    );

                    // Pub/Sub
                    builder.RegisterInstance<IPublisher<AddScoreUpdated>>(
                        new MockPublisher<AddScoreUpdated>()
                    );
                    builder.RegisterInstance<ISubscriber<AddScoreRequest>>(
                        new MockSubscriber<AddScoreRequest>()
                    );

                    // Interactor
                    builder.Register<AddScoreInteractor>(Lifetime.Singleton).AsSelf();
                },
                "QuizTestScope"
            );

            scope.Build();
            return scope;
        }

        /// <summary>
        /// EditMode用の安全な破棄ヘルパ。
        /// </summary>
        public static void Destroy(LifetimeScope scope)
        {
            if (scope == null)
                return;

            scope.Container.Dispose();
            Object.DestroyImmediate(scope.gameObject);
        }
    }
テストコード

ここまでの実装を利用して、外部I/Oを完全に切り離した単体テストが実装できます。

    public class AddScoreInteractorTests
    {
        private (
            LifetimeScope Scope,
            MockPublisher<AddScoreUpdated> Pub,
            InMemoryScoreRepository Repo,
            MockSubscriber<AddScoreRequest> Sub
        ) Build(int initial = 0)
        {
            var scope = QuizTestScope.Create(initial);
            var c = scope.Container;

            c.Resolve<AddScoreInteractor>().Start();

            return (
                scope,
                (MockPublisher<AddScoreUpdated>)c.Resolve<IPublisher<AddScoreUpdated>>(),
                (InMemoryScoreRepository)c.Resolve<IScoreRepository>(),
                (MockSubscriber<AddScoreRequest>)c.Resolve<ISubscriber<AddScoreRequest>>()
            );
        }

        // 初期化 Publish
        [Test]
        public void Initialize_PublishesSavedScore()
        {
            var (scope, pub, _, _) = Build(42);

            Assert.That(pub.Messages.Count, Is.EqualTo(1));
            Assert.That(pub.Messages[0].Total, Is.EqualTo(42));
            QuizTestScope.Destroy(scope);
        }

        // ~~~~ (他にもテストケースが考えられるが省略) ~~~
        
        // Dispose後は反応しない
        [Test]
        public void AfterDispose_IgnoresRequests()
        {
            var (scope, pub, repo, sub) = Build();

            // DIスコープ破棄 → 購読解除
            QuizTestScope.Destroy(scope);

            sub.Fire(new AddScoreRequest { Gain = 10 });

            Assert.That(pub.Messages.Count, Is.EqualTo(1));
            Assert.That(repo.Store, Is.EqualTo(0));
        }
    }

テストを実行すると次のようになります。

スクリーンショット 2025-07-10 5.22.02.png

Presentationで層を跨いだ結合テスト

Play ModeでUIを自動操作し、ボタン入力がゲーム内ロジックに正しく届き、画面に期待どおりの結果が出るかを検証します。
層をまたぎ、副作用のあるクラスが複数関わるため、結合テストと言えます。

データ保存の扱い

ファイルやサーバーと通信するとテストが遅くなり、原因の切り分けも難しくなります。
そこで本番用のリポジトリの代わりにInMemoryScoreRepositoryを挿し替え、メモリ上で保存処理だけを素早くチェックします。

DIの変更

今回は先ほどのInMemoryScoreRepositoryを使用しますが、
PlayModeでのテストなので、Scene内のLifetimeScopeを編集する必要があります。

この場合、本番用のシーンを複製して、そこで新しいLifetimeScopeを使用する方が安全です。
ただ説明の本筋ではないので、今回は本番用のシーンで済ませてしまいます。

public class QuizLifetimeScope : LifetimeScope
{
    [SerializeField]
    private ScoreView scoreView;

    protected override void Configure(IContainerBuilder builder)
    {
        // ~~~ 省略 ~~~

        // Infrastructure
        // builder.Register<IScoreRepository, PlayerPrefsScoreRepository>(Lifetime.Singleton);
        builder.RegisterInstance<IScoreRepository>(new InMemoryScoreRepository());

        // ~~~ 省略 ~~~
    }
}

テストコード

テストコードではUnityEngine.TestToolsを使い、ボタンを探して押す処理を呼び出します。
フレームを一つ進めたあと、Assert.That()で画面のテキストと内部のスコア値が期待どおりに更新されたかを確認します。
この一連の手順で「ユーザーが操作 → 得点計算 → 表示反映」の流れをテストできます。

    public class AddScorePresenterIntegrationTests
    {
        [UnityTest]
        public IEnumerator CorrectButton_UpdatesView_AndRepository()
        {
            // シーンをロード
            yield return SceneManager.LoadSceneAsync("Main", LoadSceneMode.Single);

            // シーン上のオブジェクトを取得
            var scope = GameObject.Find("QuizLifetimeScope").GetComponent<QuizLifetimeScope>();
            var view = Object.FindAnyObjectByType<ScoreView>();
            var label = view.GetComponentInChildren<TMP_Text>();

            Assert.IsNotNull(scope);
            Assert.IsNotNull(view);
            Assert.IsNotNull(label);

            // DIが立ち上がるのを1フレーム待機
            yield return null;

            // 初期表示は0 Point
            Assert.AreEqual("0 Point", label.text);

            // 正解ボタンクリック → 10 Point へ
            view.OnCorrectAnswerButton();
            yield return null;

            Assert.AreEqual("10 Point", label.text);

            // Repositoryにも10が保存されている
            var repo = scope.Container.Resolve<IScoreRepository>() as InMemoryScoreRepository;
            Assert.IsNotNull(repo);
            Assert.AreEqual(10, repo.Store);
            Assert.AreEqual(1, repo.SaveCallCount);

            // もう一度クリック → 20 Point
            view.OnCorrectAnswerButton();
            yield return null;

            Assert.AreEqual("20 Point", label.text);
            Assert.AreEqual(20, repo.Store);
            Assert.AreEqual(2, repo.SaveCallCount);
        }
    }

スクリーンショット 2025-07-10 5.49.33.png

Infrastructureも含めてE2Eテスト

最後のE2Eテストはビルド済みプレイヤーを実機相当で動かし、
Infrastructureを含む全レイヤーを本番と同じ依存で通して検証する仕上げのテストです。

テストピラミッドでは最上段に置かれます。

なぜテストピラミッドの最上段か

  • 本番忠実度が最高: 実際の端末・入力・外部APIまで通しで動く
  • コストが最大: ビルド生成から外部通信まで含み、時間・マシン資源を大きく消費

いつ回すか

  • 単体テストや結合テストよりは低頻度
  • リリース前の最終確認時

実装例

LifetimeScopeは本番と同じにする

結合テストではInMemoryScoreRepositoryを使っていましたが、E2Eテストでは本番と同じ実装に戻します。

// QuizLifetimeScope.cs
builder.Register<IScoreRepository, PlayerPrefsScoreRepository>(Lifetime.Singleton);
Unity Test Frameworkの機能を使ってビルドとテスト

Test Frameworkには自動でビルドを行った後に、
PlayModeテストを実行するPlayer機能があります。

Unityバージョンによって名称や方法に揺れがあるようですが、以下で言及されている機能です。

この機能を使って、E2Eテストを行います。
Infrastructure層を交えて、正常に動作しているかを確認します。

        [SetUp]
        public void ClearPlayerPrefs()
        {
            PlayerPrefs.DeleteKey("Score");
            PlayerPrefs.Save();
        }

        [UnityTest]
        public IEnumerator Score_Persists_After_SceneReload()
        {
            // 1回目のシーン起動
            yield return SceneManager.LoadSceneAsync("Main", LoadSceneMode.Single);
            yield return null;

            var view = Object.FindAnyObjectByType<ScoreView>();
            view.OnCorrectAnswerButton();
            yield return null;

            // Sceneを再ロードすることで、PlayerPrefsへの保存が機能しているか確かめる
            yield return SceneManager.LoadSceneAsync("Empty", LoadSceneMode.Single);
            yield return SceneManager.LoadSceneAsync("Main", LoadSceneMode.Single);
            yield return null;

            // 2回目のシーン起動
            var label = Object.FindAnyObjectByType<ScoreView>().GetComponentInChildren<TMP_Text>();
            Assert.AreEqual("10 Point", label.text);
        }

スクリーンショット 2025-07-10 6.10.32.png

モジュール境界の強制

テスト以外にも保守性を強固にできる方法を紹介します。
それがディレクトリ分割とasmdefファイルによるモジュール境界の強制です。

論理設計だけでは、ちょっとした油断で「PresentationがDomainを直接呼ぶ」といった依存逆流が起きてしまいます。

Unityにはasmdef(Assembly Definition)ファイルがあり、これをディレクトリ構成と合わせて使うと

  • レイヤーごとにアセンブリを分ける
  • 外側の層から内側の層だけを参照する

というルールをコンパイル時に強制できます。

結果として、誤った参照は即エラーになり、依存逆流を防ぐことが出来ます。

asmdefファイルとは

  • .asmdefファイルを置いたフォルダー配下のスクリプトは、ひとかたまりのDLL(≒箱)としてUnityがビルドします
  • ラベルが付かないスクリプトは全部「Assembly-CSharp.dll」という大きな箱に入ります
  • .asmdefファイルではReferencesという「この箱はどの箱を開けていいか」の許可リストを設定できます
    • このReferencesを利用することで、依存逆流を防ぐことが出来ます(詳細後述)

ディレクトリ構成例

スクリーンショット 2025-07-10 8.42.32.png

asmdefの設定例

以下はApplicationディレクトリ内のScore.Application.asmdefの設定です。

References欄でDomainディレクトリ内のScore.Domain.asmdefを指定することで、
Applicationレイヤー → Domainレイヤー方向への依存のみ許可しています。

スクリーンショット 2025-07-10 8.53.26.png

この時、Applicationレイヤーより外のレイヤーへの依存があれば、コンパイルエラーが生じます。
このようにクリーンアーキテクチャを維持することが出来ます。

まとめ

  • 設計を守り続けるには、テストやモジュール境界の強制などを行うのが効果的です
  • テストは単体/結合/E2Eから成るピラミッドを意識すると速度や信頼性につながります
    • 前回のようなアーキテクチャの場合はDomain,Applicationで単体テストを、Presentationで結合テストを、最後に全レイヤーでE2Eテストを行うのがおすすめです
  • asmdefとディレクトリ分割によるモジュール境界の強制で、アーキテクチャを維持しやすくすることが出来ます
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?