Search code examples
c#unit-testingunity-game-engineunity-test-tools

How to set up Unity unit tests with *many* Script directories


I've been reading many articles online about how to set up a unit test, and most of all it seems pretty straight forward: you create a Test directory using Test Running in Unity. According to this post here, if you run into a namespace issue, then you create an assembly definition file in your scripts directly, reference it in your test.asmdef file, and boom you can start running tests successfully.

My problem is I've inherited a project with 34 of these Scripts directories, and the moment I add an assembly definition file to one, it creates a namespace issue with all other namespaces/objects. Logical conclusion is I create an .asmdef in each of these files and create references where they are needed. Unfortunately this program was designed in such a way that this creates a cyclical dependency among the assembly definition files. This circular dependency is not an issue in the general usage of the program. Without restructuring the code base, is there a way to make this code testable?


Solution

  • Simple solution would be to add the asmdef to the top folder of your 34 script folders.

    If they are all across Assets folder then you can create that Script folder and move them all in there. That should not break your project as Unity will update all connections.

    The long term solution you may have to go for is creating abstract/interface in assembly that current code would implement.

    Say you have script Player in player.asmdef and you want to test it. But it has a dependency to Inventory which is not in any asmdef. You could move Inventory but it also has its set of dependencies and so on.

    Instead of moving Inventory, you create a base inventory as abstract and interface in the manager.asmdef and add this one to player.asmdef. Assuming Player.cs uses

    List<Item> Inventory.GetInventory();
    void Inventory.SetItem(Item item);
    

    Your IInventory.cs could look like so

    public abstract class InventoryBase : MonoBehaviour, IInventory
    {
        // if those methods were self contained, meaning they don't use any outside code
        // the implementation could be moved here
        public abstract List<Item> GetInventory();
        public abstract void SetItem(Item item);
    }
    public interface IInventory
    {
        List<Item> GetInventory();
        void SetItem(Item item);
    }
    public class Item
    {
        public string id;
        public int amount;
        public string type;
    }
    

    Then the Inventory class

    public class Inventory : InventoryBase
    {
         // Implementation is already there since it was used 
         // but requires the override on the methods
    }
    

    It may feel like adding extra useless layers but this adds a second advantage of major importance, you can mock the IInventory object in your player test:

    [Test]
    public void TestPlayer()
    {
        // Using Moq framework but NSubstitute does same with different syntax
        Mock<IInventory> mockInventory = new Mock<IInventory>();
        Mock<IPlayer> mockPlayer= new Mock<IPlayer>();
        PlayerLogic player = new PlayerLogic(mockPlayer.Object, mockInventory.Object);
        mock.Setup(m=> m.GetInventory).Returns(new List<Item>());
    }
    

    This assumes the Player class is decoupled between the MonoBehaviour and the logic:

    public class Player : MonoBehaviour ,IPlayer
    {
        [SerializedField] private InventoryBase m_inventory;
        PlayerLogic m_logic;
        void Awake()
        {
            m_logic = new PlayerLogic(this, m_inventory);
        }
    }
    
    public interface IPlayer{}
    
    public class PlayerLogic 
    {
        IPlayer m_player;
        IInventory m_inventory
        public PlayerLogic(IPlayer player, IInventory inventory)
        {
             m_player = player;
             m_inventory = inventory;
        }
        // Do what you need with dependencies
        // Test will use the mock objects as if they were real
    }
    

    Notice that Player uses InventoryBase since it cannot see Inventory not being in an assembly. But as you drop in the Inventory object, the compiler will use the code down there even if Player type is not aware of Inventory type.

    If you were to use another method from Inventory into Player, then you'd need to add the abstract to the base class and the declaration in the interface for testing.

    PlayerLogic uses the interface instead of the base type to make the testing possible.