Unity ECS Unit Testing
DOTS ECS Entities Testability Testing

Unit Testing Made Easy: Unity ECS Best Practices

I know games written in ECS that have more than 1,000 systems running at the same time. And I am pretty sure there are complex games that have even more systems. The complexity of managing dependencies between thousands of systems is a significant topic, but today let’s start from the bottom and learn how to make sure that each small block of our game is working correctly and doesn’t break when other systems change and modify the data that comes into our particular system under test.
In my professional career, I personally see more and more games use automated testing. A wide range of materials and references are available, shedding light on the benefits of automated testing, even in AAA games like Sea Of Thieves. So I won’t dive deep into all these benefits here, I will just show you how I write unit tests for the most critical systems in my own game.


For my game and this tutorial, I am using Unity 2022.3.9f1 and Unity.Entities 1.0.
I have created a minimal repository for this post. We will extend it in future posts about automated testing and Unity ECS.

The series:


Setup

To run a unit test we need to create an ECS world, add required systems, and create entities with the set of components that match the system’s under test filter. Then we run the world Update() so our system performs the work and after that, we assert that data is modified correctly.


To achieve this, I started by checking what tests are provided in Unity.Entities package and how they do this. Luckily I have found an ECSTestsFixture.cs they use for their unit tests.
We have two ways to use it in our tests.

Reference testables

Add the following property to manifest.json

{
  "dependencies": {
     ...
  },
  "testables" : [
    "com.unity.entities"
  ]
}

Now you can reference test assemblies from the entities package. Without this, if you referenced any assembly, you would still have compilation errors due to missing references.

Advantages of this approach:
– if ECSTestFixture changes with new package updates, you will have a new version without any effort.

Disadvantages:
– Tests from com.unity.entities now are listed in the Test Runner window, which means that these tests will run too when you choose to run all tests and these tests take a lot of time. So the total time to run the whole test suit increases unless you write your tools to launch the test runner with custom parameters.

Make a copy of ECSTestFixture.cs

Another way is to make your copy of ECSTestFixture and place it into the tests folder.

Advantages of this approach:
– No tests from 3rd party packages in your test runner.

Disadvantages:
– If API or ECSTestFixture changes with Entities update, you will probably need to make a new copy and reapply any changes you had made to it once again. However, this process can be simplified if you also create a CustomEcsTestFixture.cs that would inherit from one provided by Unity and all the changes you keep there.




The System Under Test

In my game, I am writing a custom kinematic character controller, so I have a system that stops characters from going through walls by restricting their movement. Here is a bit simplified version stripped of unnecessary details:

[UpdateInGroup(typeof(FixedStepSimulationSystemGroup), OrderLast = true)]
[BurstCompile]
public partial struct RestrictMovementSystem : ISystem
{
    private ComponentLookup<LeftWallCollision> _leftWallCollisionLookup;
    private ComponentLookup<RightWallCollision> _rightWallCollisionLookup;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate(SystemAPI
            .QueryBuilder()
            .WithAllRW<MovementDirectionData>()
            .Build());
        _leftWallCollisionLookup = SystemAPI.GetComponentLookup<LeftWallCollision>(true);
        _rightWallCollisionLookup = SystemAPI.GetComponentLookup<RightWallCollision>(true);
    }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        _leftWallCollisionLookup.Update(ref state);
        _rightWallCollisionLookup.Update(ref state);

        new RestrictMovementJob
        {
            LeftWallCollisionLookup = _leftWallCollisionLookup,
            RightWallCollisionLookup = _rightWallCollisionLookup,
        }.Schedule();
    }
}

Here is the RestrictMovementJob that the system uses:

[BurstCompile]
public partial struct RestrictMovementJob : IJobEntity
{
    [ReadOnly] internal ComponentLookup<LeftWallCollision> LeftWallCollisionLookup;
    [ReadOnly] internal ComponentLookup<RightWallCollision> RightWallCollisionLookup;

    [BurstCompile]
    private void Execute(
        Entity entity,
        ref MovementDirectionData movementDirectionData)
    {
        var hasCollisionOnLeft = LeftWallCollisionLookup.HasComponent(entity);
        var hasCollisionOnRight = RightWallCollisionLookup.HasComponent(entity);

        switch (movementDirectionData.Movement.x)
        {
            case > 0 when hasCollisionOnRight:
            case < 0 when hasCollisionOnLeft:
                movementDirectionData.Movement = new float3(
                    0,
                    movementDirectionData.Movement.y,
                    movementDirectionData.Movement.z);
                break;
        }
    }
}



Writing Unit Tests

First of all, I inherited from ECSTestsFixture and added methods to simplify the API usage:

public class CustomEcsTestsFixture : ECSTestsFixture
{
    private const int EntityCount = 1000;

    /// <summary>
    /// A proxy property just for the sake of following my codestyle regarding naming in my tests
    /// </summary>
    protected EntityManager Manager => m_Manager;

    protected void UpdateSystem<T>() where T : unmanaged, ISystem
    {
        World.GetExistingSystem<T>().Update(World.Unmanaged);
    }

    protected SystemHandle CreateSystem<T>() where T : unmanaged, ISystem => World.CreateSystem<T>();

    protected Entity CreateEntity(params ComponentType[] types) => Manager.CreateEntity(types);

    protected void CreateEntities(ComponentType[] types, int entityCount = EntityCount)
    {
        for (var i = 0; i < entityCount; i++)
        {
            Manager.CreateEntity(types);
        }
    }

    protected void CreateEntityCommandBufferSystem()
    {
        World.CreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
}

The current version of RestrictMovementSystem.cs can be covered with the following cases:

  • The character has no collisions so the movement vector is not changed during the update
  • The character has collisions on one side and moves to the other side so the movement vector is not changed during the update
  • The character has collisions on one side and moves to the same side so the movement vector is reset

So all we have to do now is to create a new class in the test assembly, inherit it from CustomEcsTestsFixture, and add the Setup() method with the [SetUp] attribute in which you call the base.Setup() and create all the needed systems.

    [TestFixture]
    public class RestrictMovementSystemTests : CustomEcsTestsFixture
    {
        [SetUp]
        public override void Setup()
        {
            base.Setup();
            CreateSystem<RestrictMovementSystem>();
        }

    ...
  
    }


Now you are ready to add the first test case:

     [Test]
     public void Update_HasCollisionsOnRightAndMovingRight_HorizontalMovementIsReset()
     {
		 // Create an entity with default components
         var entity = CreateEntity(typeof(RightWallCollision));
		 // Add components with custom data
         var movement = new MovementDirectionData
         {
             Movement = new float3(1)
         };
         Manager.AddComponentData(entity, movement);

		 // Update the system
         UpdateSystem<RestrictMovementSystem>();

	     // Assert that data is modified according to the test case
         var movementData = Manager.GetComponentData<MovementDirectionData>(entity);
         Assert.AreEqual(0, movementData.Movement.x);
     }


And here are the rest of our cases:

        [Test]
        public void Update_HasCollisionsOnLeftAndMovingLeft_HorizontalMovementIsReset()
        {
            var entity = CreateEntity(typeof(LeftWallCollision));
            var movement = new MovementDirectionData
            {
                Movement = new float3(-1)
            };
            Manager.AddComponentData(entity, movement);

            UpdateSystem<RestrictMovementSystem>();

            var movementData = Manager.GetComponentData<MovementDirectionData>(entity);
            Assert.AreEqual(0, movementData.Movement.x);
        }

        [Test]
        public void Update_HasCollisionsOnRightAndMovingLeft_HorizontalMovementIsNotChanged()
        {
            const int directionX = -1;
            var entity = CreateEntity(typeof(RightWallCollision));
            var movement = new MovementDirectionData
            {
                Movement = new float3(directionX)
            };
            Manager.AddComponentData(entity, movement);

            UpdateSystem<RestrictMovementSystem>();

            var movementData = Manager.GetComponentData<MovementDirectionData>(entity);
            Assert.AreEqual(directionX, movementData.Movement.x);
        }

        [Test]
        public void Update_NoCollisions_HorizontalMovementIsNotChanged()
        {
            const int directionX = 1;
            var entity = CreateEntity();
            var movement = new MovementDirectionData
            {
                Movement = new float3(directionX)
            };
            Manager.AddComponentData(entity, movement);

            UpdateSystem<RestrictMovementSystem>();

            var movementData = Manager.GetComponentData<MovementDirectionData>(entity);
            Assert.AreEqual(directionX, movementData.Movement.x);
        }




Sidenote

As you can see in the sample repo I created a tests infrastructure folder and asmdef, so I can reuse common code for play mode and edit mode tests. For such test infrastructural assemblies make sure to define the constraint "UNITY_INCLUDE_TESTS":

    "defineConstraints": [
        "UNITY_INCLUDE_TESTS"
    ],

Otherwise, your tests will work in the editor, but the build will fail (so play mode tests on the target device would not work also).

Conclusion

These are real unit tests from my game which is built with Unity ECS. As you can see, with a little setup, you can easily cover your game logic with automated tests in both edit and play modes.
The game is going to be an action roguelite, but it’s still in the very early stages of prototyping to share any significant details at that point. I am going to elaborate on the topic of automated tests in future posts about the game as we are going to look at performance testing and how to measure the performance of your systems to make sound decisions when optimizing it. Given the broad amount of approaches to do the exact same thing with Unity.Entities, performance testing helped me a lot in choosing the right methods that run the fastest in my particular case. These benchmarks can be even run on a target device. If you’d like to hear news about the game, or not miss future posts follow my Telegram channel or X.com.

The sample repo is available on GitHub. You can take it as is to have all the setup out of the box. Performance testing will be added there later too.

AlexeyMerzlikin-TechLead-Gamedeveloper-Unity


Alexey Merzlikin

Experienced game developer and tech lead with a passion for writing educational content about game development and programming. I have over 10 years of industry experience focused on performance optimization, clean code practices, and robust game architecture. I share regular posts on my Telegram channel about applying software engineering best practices to build high-quality games. My goal is to help other game developers level up their skills.




You might also consider subscribing to receive updates about new blog posts via email:




Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.