疑似Singletonな実装をしたMonoBehaviourクラスのPlayModeテスト後、後続のPlayModeテストが失敗する問題の対策 ( 対症療法 )

個人ブログに書いてたけどこっちにも投稿


今回のようなケースはUnity側の不具合ではなくそもそもの設計が悪いので自戒として残しておく  
 ( テスト終了時にDontDestroyOnLoad設定したオブジェクトを削除してくれない )

 
# 概要 : プロジェクトの構成

* DontDestroyOnLoad を行った疑似シングルトン実装のクラスのテスト  
  * このクラスは外部の別クラスを参照して何かしらの処理を行うものとする
* 通常のMonoBehaviour継承クラスのPlayModeテスト

以下はプロジェクトの構成、DontDestroyOnLoadが設定された疑似SingletonのA_ManagerクラスとA_Managerが利用するHogeクラス  
それとは関係ないNanikanoKinouクラスの2種類のPlayModeテストを用意     

![20241018200822.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212999/93682a3d-5e21-76c1-e464-ec2bc99a0bf5.png)

**疑似的なSingleton実装のMonoBehaviour継承クラス** 
```cs
using UnityEngine;
using UnityEngine.SceneManagement;


public class A_Manager : MonoBehaviour
{
    private static object _instance = null;
    private Hoge _hoge = null;
    

    private void Awake()
    {
        if (_instance != null)
        {
            DestroyImmediate(gameObject);
            return;
        }

        _instance = this;
        SceneManager.sceneLoaded += OnSceneLoaded;
        DontDestroyOnLoad(this);
    }


    private void OnDestroy()
    {
        SceneManager.sceneLoaded -= OnSceneLoaded;
    }

    
    private void OnSceneLoaded(Scene scene, LoadSceneMode sceneMode)
    {
        _hoge = FindObjectOfType<Hoge>();
        _hoge.Fuga();
    }


    public bool Check() => true;

}
```

**上記A_Managerのテスト**  
サンプルの為必ず通過するテストで良い

```cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;

public class A_ManagerTests
{


    private AsyncOperation LoadScene()
        => EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/Scripts/A_Manager/Tests/PlayMode/A_ManagerTestScene.unity", new LoadSceneParameters(LoadSceneMode.Single));
    
    
    [UnityTest]
    public IEnumerator Test_Fuga_Passes()
    {
        yield return LoadScene();
        yield return new WaitForSeconds(1f);

        var manager = GameObject.FindObjectOfType<A_Manager>();
        
        /*
         本来は何かしらのテストが記述される.
         */
        Assert.IsTrue(manager.Check());
    }
}
```

**A_Managerとは関係のないNanikanoKinouとそのテスト**   
確認用なので中身はほぼ空

```cs
using UnityEngine;

public class NanikanoKinou : MonoBehaviour
{
    public bool KinouA()
    {
        return true;
    }
}
```

```cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using UnityEngine.SceneManagement;
using UnityEditor.SceneManagement;


public class NanikanoKinouTests
{

    private AsyncOperation LoadScene()
        => EditorSceneManager.LoadSceneAsyncInPlayMode("Assets/Scripts/NanikanoKinou/Tests/PlayMode/NanikanoKinouTestScene.unity", new LoadSceneParameters(LoadSceneMode.Single));    
    
    
    [UnityTest]
    public IEnumerator Test_KinouA_True()
    {
        yield return LoadScene();
        yield return new WaitForSeconds(1f);

        var nanikanoKinou = GameObject.FindObjectOfType<NanikanoKinou>();
        bool returnValue = nanikanoKinou.KinouA();

        Assert.IsTrue(returnValue);
    }
}
```


# 起きた不具合

それぞれのテストを都度実行していった際は全てのテストが通過するが、Run All で実行した際にSingletonクラスのテストの後続のテストが必ず失敗する

それぞれのテストを個別に実行した場合
![20241018202628.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212999/5b387226-0621-7f46-d87b-e52614c9c729.png)

Run All でテストを実行した時の結果
![20241018202759.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/212999/e2906880-8664-f6d7-0729-c85a18453301.png)


## 原因の解説   

Singletonクラス側でシーンがロードされた際に外部のインスタンスを取得し、何かしらの処理を走らせているが、NanikanoKinou側のテストではこのHogeコンポーネントがアタッチされているオブジェクトは存在しないのでnullとなる   
その為、NanikanoKinou側のPlayModeテストシーンがロードされた時にシーン上に残っているA_ManagerのOnSceneLoaded()が呼び出され、NullReferenceExceptionが送出されてテストが失敗する   
```cs
    private void OnSceneLoaded(Scene scene, LoadSceneMode sceneMode)
    {
        _hoge = FindObjectOfType<Hoge>();
        _hoge.Fuga();
    }
```

# 対症療法

対症療法として、A_Managerのテストが完了した時にGameObject.DestroyImmediate()を実行し、Singletonクラスを削除することで後続のテストシーンがロードされた時にNullReferenceExceptionが送出されなくなる   
```cs
    [UnityTest]
    public IEnumerator Test_Fuga_Passes()
    {
        yield return LoadScene();
        yield return new WaitForSeconds(1f);

        var manager = GameObject.FindObjectOfType<A_Manager>();
        
        /*
         本来は何かしらのテストが記述される.
         */
        Assert.IsTrue(manager.Check());
        GameObject.DestroyImmediate(manager);
    }
```

# 本来為すべき設計について
今回の解決方法は所謂臭いものに蓋の対症療法となる    

Singleton実装のクラスはテストを組みにくくするのでDIツールを用いての実装等、そもそもSingletonの実装をしないようにしたいところ   

Singletonクラスの実装が必要なのであればSingletonクラス単体で完結するように設計するのが好ましいと思う     
今回の場合であれば   

* Hoge をシーンに配置するべきなのか     
* 都度インスタンスを生成することで対応できないか( シーンに配置されたGameObjectからの参照の取得からシーンのロード時にInstantiateすることはできないか )     
* A_Manager内で処理できないかを考慮( そもそもHogeクラスを利用しない )        

等を考慮するとマシな形になるかも      

SingletonなMonoBehaviour継承クラス単体で完結する場合であっても念のため、テストを行う際は最後にDestroyImmediate()でインスタンスを削除するのが無難