Unsplash

The ECS concept is very geared for a great unit testing experience since you can literally “instantiate a logic”, pour some data in the world, and tell it to work. But that’s when it’s already working. There are problems down the road to that point.

There is no default active world on a unit test

World.Active will be a null reference exception. I have to subclass from like this to allow [SetUp] and [TearDown] to reach all the tests.

Note that [TearDown] and the dispose on [SetUp] are needed because when running multiple tests at the same time Unity will reset nothing. That includes worlds and entities they are in.

Inheriting from ECSTestsFixtures

Unity provides a class which gives you a good setup and tear down with test world. It also contains protected properties for you to get EntityManager or World .

EmptySystem

This is a cheating system Unity provided in ECSTestsFixtures. (With protected property EmptySystem you can use to get it) The intended purpose is just a system which can “dig” component group out of the world so that you could get data to assert. (Not so “empty” functionally!)

Your manually created world lacks the hybrid injection abilities

If you are touching any system with hybrid injection (injecting a mono component, a trasform, etc) the test will fail.

Imagine unit testing a system A that has system injection declared.

[Inject] SystemB systemB;

But this unit test method will not touch systemB.

The approach for testing is by creating a world and creating only this system A in isolation, then we can try to call its methods or forcing an update after putting some entities in the world. (just like what test scripts in the ECS package are doing)

In the system B, it has[Inject] MyData myData;

MyData contains ComponentArray<T> (not Component*Data*Array) which requires a custom injection hook to work.

On entering unit test, creating a world, andGetOrCreateManager<SystemA> I would get :

Code (CSharp):

[Inject] may only be used on ComponentDataArray<>, ComponentArray<>, TransformAccessArray, EntityArray, and int Length.

This is becauseGetOrCreateManager<SystemA> will GetOrCreateManager<SystemB> by dependency, and then it would encounter the ComponentArray inject which require the hook that is only available at runtime to not throw error. That is, only DefaultWorldInitialization.cs can do it :

Solutions that doesn’t work :

  1. I cannot un-declare the SystemB only at unit test time, no preprocessor can detect whether I am unit testing or in a real gameplay. I cannot use Application.isPlaying because it is not in the code scope. The ECS framework scans according to attribute [Inject]. So it is unavoidable test SystemA in isolation without dragging SystemB along with it if I used system injection.
    *By extension one cannot unit test any system that has any [Inject] ComponentData<T>/TransformAccessArray/GameObjectArray, even if the unit test code is not intended to use those injection. (Obviously, those injects are designed for play mode use/integration test since they are related to game objects and components)
    *By extension one cannot unit test any system that has a system injection to the system that contains those custom hook-enabled injections.
  2. Try to enable the default hooks on my test world : a newly created unit test world could not use any of the built-in hook, they are all `private sealed`.
  3. The next idea is to create a new InjectionHook which has `FieldTypeOfInterest` catching ComponentArray+TransformAccessArray+GameObjectArray, then null all of those forbidden fields. I discovered that a hook requires [CustomInjectionHook], then CustomInjectionHookAttribute is `internal sealed`.

The solution : Reflection

Hack your way through the pesky internal with reflections! Dirty but it works. Your unit test world is now fully functional. Be sure to check DefaultWorldInitialization.cs often if Unity team has added more internal hooks.

My ECS unit test fail with different reasons each time!

I have a bizarre behaviour that the test fails (legit) for the first time, then the next time the test fail with this error until I recompile my code.

The thing is I put a struct with some native allocation in static array (again, evil.) The first time I run test it fails, but after the test ends the static variable does not get cleared. Then the next time my “dispose” logic think that it still got something inside but the native memory inside are already gone.

I have reset the world in between test. I think that dispose the internal native array space somewhat.

I have initialized variable that will be true if I instantiated this struct with my custom constructor (Since it is not a class, a default struct might cause problem everywhere and I can’t have a custom default constructor on struct ) to check that but it is not safe from the static problem.

The last band aid .IsCreated also does nothing to help, since it is not .IsExist . ( .IsCreated are all true here even if the underlying memory are not there to be disposed, because they “had been created” before) So in the end I have to make my custom static cleaner in between tests.

To see the problem with static , they are all reset when you recompile scripts so you can see if something come back to work only once after a recompile. Better to not use static because they make your tests brittle.

NUnit fails bool1 and half

If you use Is.False against bool you will get a stupid sounding error message :

Expected : False
But was :  False

(There is one more extra spacebar after colon for who knows reason, but might be a clue that “False” is actually not “ False”, lol)

So you need to cast to (bool) just to unit test.

Comparing float against half also fails, saying something like 7 is not equal to Unity.Mathematics.half again you cast to (float) and the test should pass.

In the test I only create SystemA yet SystemB is fully usable in both A’s code and outside by GetExistingManager . System C is available from outside as well following the system injection rule but if I remove that [Inject] SystemC and do GetExistingManager<SystemC> -> .Update it will be a null reference exception.

So in the test you don’t have to prep your world with the entire chain, just create whatever system you really want to test and all dependencies are automatically taken care for you. (great!)

Remember that a BarrierSystem needs to be “system injected” to be used, that means CreateManager will also get you a barrier and if you CreateManager the barrier system manually afterwards it would be an error. (Just use GetOrCreateManager when not sure which system cause the barrier to be created or not)

World.Active the enemy of unit testing!

If possible do not use World.Active because when testing you are going to create your test world. You might say you can make it active, but some path in your code might create a world again and do things in that world. In this moment the active world in the secondly created world will be wrong. You might say well, make the world active every time we create a new world. But now you have a problem of having to restore the previous active world after this new world will be disposed… and so on.

To retain the “magical” feature that a system is a bunched up of logic that can be run in isolation by itself or together as a system we better not asking about its world. Let it work on any world. This is by using thing like EntityManager protected property inside the system code scope, which gets the EM of its world. (The correct ones)

Gotchas on manually updating

It’s great with ECS because when we were using MonoBehaviour we could not “run a chunk of logic” like what we have now. So just create a test world, create some systems you interested in, and the run .Update sequentially then check the result from ECS data or what the system state is in now. Clean and nice right?

Don’t forget you have to update the barriers

In normal use the barrier almost feel automatic, like it should run after a system’s job. But in unit test you will have to .Update it too!

Don’t create the barrier to your world

And how to get the barrier? You actually don’t have to add barrier to the world so just use .GetExistingManager after you sure you have .CreateManager the system that inject that barrier. It will get created along with the system according to system injection rule. (Barrier is a system)

But if you mistakenly use .CreateManager to create a new barrier to the world you will end up .Update the barrier that has no connection to the actual one that your system use. (So .GetOrCreateManager is safer here, but don’t use that too often as .GetExistingManager has an advantage that it can throw error on your mistake)

[UpdateBefore] [UpdateAfter] now means nothing

It is a tag for player loop updater to run Update in the correct order. Now you are in charge of this task so you have to do it in the correct order by yourself.

EntityManager.CompleteAllJobs

You might not have touch this method before because job waits for each other automatically. In unit test it seems it also waits correctly. if you run .Update in sequence and one of the system execute some jobs, the next system’s .Update that is expecting a result from a job (like you are going to .Update the barrier and the job earlier should queue up the command for the ECB) then your .Update should have no chance to end up doing nothing because the job wasn’t finished yet.

At least that is the case of my test. But in some case that you need immediate result from a job without any system following it to Update and ask the job to finish automatically, you can use this method to ensure the job finish. (Don’t forget that the job is async, it might be that if you assert the job’s result immediately your test ended up sometimes green and sometimes red)

Testing hybrid ECS

If you new GameObject() and AddComponent<YourComponentDataWrapper>() , it works!

  • GameObjectEntity automatically added due to[RequireComponent]
  • OnEnable also called, causing Entity to appear.
  • Destroying it also cause OnDisable and remove the Entity
  • SetActive(false/true) cause the Entity to be destroyed or appear again too.

In short it is pretty much testable. But one thing : if you run tests sequentially and you left your spawned game objects behind they will go to the next test! It might cause Entity to spill over to the next case if you left GameObjectEntity behind.

To fix this add this [TearDown]

[TearDown]
public void CleanGO() 
{
    foreach (var go in SceneManager.GetSceneByName("").GetRootGameObjects())
    {
        GameObject.DestroyImmediate(go);
    }
}

The “Untitled” scene that flashed a bit when you run unit tests is actually named "" like that.

Testing hybrid ECS : Instantiate version

What if you have GameObject in the real code waiting to be GameObject.Instantiate , and they contains GameObjectEntity and friends? That GameObject would be connected in the inspector to some prefab in the project.

In unit test you have no such place to connect, so how to “make” this GameObject for the test?

In unit test you can new GameObject with GOE, wrappers, etc types. INSTANTLY at that line GOE already do its OnEnable , so even if you intended that new GameObject to be a prefab to be cloned, you have already got an Entity in your test world.

To combat this, after GameObject.Instantiate from that prefab object be sure to SetActive(false) the original object. GOE will activate its OnDisable and destroy its Entity .

Note that GameObject.Instantiate also copy active status, if you are thinking about SetActive(false) the prefab objects BEFORE instantiating all of your instantiated things will starts inactive. (And you get no entities since GOE is not enabled yet) So if you go that way, remember to set active those instantiated objects.