Unit-testing

  10. Advanced No Comments

Testable By Default

Because clients receive all their dependencies from the outside, instead of fetching them from singletons or using methods like GetComponent and FindAnyObjectByType, it is easy to swap out any of them with test doubles during unit tests.

There’s no need to create test scenes or prefabs and configure them using the Inspector. You can create clients directly in code, pass them fake objects, and then verify that they behave as expected.

This works in both Edit Mode and Play Mode tests.

Setting Up A Test Assembly

Unity’s Test Framework requires that unit tests are placed inside their own assembly definition. You can create one using the Test Runner window (Window > General > Test Runner).

Your test assembly should have references to the following assemblies:

  • InitArgs – needed to pass arguments to clients using AddComponent, Instantiate, new GameObject<T...> and the Service API.
  • InitArgs.Editor – needed to use the Testable helper class in Edit Mode tests.
  • The assemblies that contain the code you want to test.

See Referencing Init(args) Assemblies for more information.

Passing Test Doubles With AddComponent

Let’s say you have a component that triggers an event when it is collected:

using Sisus.Init;
using UnityEngine;

public class Collectable : MonoBehaviour<IEventTrigger>
{
    IEventTrigger onCollected;

    protected override void Init(IEventTrigger onCollected) => this.onCollected = onCollected;

    public void Collect()
    {
        onCollected.Trigger();
        gameObject.SetActive(false);
    }
}

To test it, first create a simple fake implementation of IEventTrigger that keeps track of whether it has been triggered:

public class FakeEvent : IEventTrigger
{
    public bool HasBeenTriggered { get; private set; }
    public void Trigger() => HasBeenTriggered = true;
}

Then pass it to the client using AddComponent:

using NUnit.Framework;
using Sisus.Init;
using UnityEngine;

public class TestCollectable
{
    [Test]
    public void Collect_Triggers_Event()
    {
        var gameObject = new GameObject();
        var onCollected = new FakeEvent();
        var collectable = gameObject.AddComponent<Collectable, IEventTrigger>(onCollected);

        collectable.Collect();

        Assert.IsTrue(onCollected.HasBeenTriggered);

        Object.DestroyImmediate(gameObject);
    }
}

The Init method of the client gets executed before AddComponent returns, so the client is immediately ready to be tested. This is the case even if the GameObject is inactive, and even in Edit Mode tests.

Prefabs can be initialized in the same way using Instantiate:

var collectable = collectablePrefab.Instantiate(onCollected);

Creating Clients With new GameObject<T…>

You can also use new GameObject<T…> to create a new GameObject and initialize a client in a single line:

var collectable = new GameObject<Collectable>().Init(onCollected);

This also makes it easy to create a GameObject with multiple components. For example, here a Killable component is added together with a BoxCollider, and initialized using Init1 (the 1 indicating that the arguments are for the first component):

var killable = new GameObject<Killable, BoxCollider>().Init1((IEventTrigger)onKilled);

If a client depends on another component on the same GameObject, you can use the Second.Component token to pass it the second added component:

var player = new GameObject<Player, Camera>().Init1(inputManager, Second.Component);

Executing Event Functions In Edit Mode

In Edit Mode tests Unity doesn’t execute event functions like Awake, Start and Update automatically. You can use the Testable class from the Sisus.Init.Testing namespace to execute them manually.

Simply wrap the GameObject containing your client inside a Testable, and then call the event functions you want to execute on it:

using NUnit.Framework;
using Sisus.Init;
using Sisus.Init.Testing;
using UnityEngine;

public class TestMovable
{
    [Test]
    public void Can_Move_Right()
    {
        var settings = new FakeMoveSettings { MoveSpeed = 1f };
        var level = new FakeLevel { Bounds = new RectInt(0, 0, 3, 1) };
        var timeProvider = new TestTimeProvider { DeltaTime = 1f };
        var movable = new GameObject<Movable>().Init(settings, level, timeProvider);
        using var testable = new Testable(movable.gameObject);

        movable.MoveDirection = Vector2.right;
        testable.Update();

        Assert.AreEqual(new Vector2(1f, 0f), movable.Position);
    }
}

Things to know about Testable:

  • When a Testable is created, it executes Awake, OnEnable and Start on all components on the GameObject. You can turn this off by passing false as the second constructor argument.
  • Update and FixedUpdate optionally accept the number of times to execute them, e.g. testable.Update(3).
  • Physics messages can be simulated as well, e.g. testable.OnTriggerEnter(collider).
  • Events can be raised using RaiseEvent(nameof(MyClient.EventName)).
  • Calling Destroy executes OnDisable and OnDestroy and then destroys the GameObject. A using statement can be used to do this automatically at the end of the test.

Here’s an example of a test that simulates two objects colliding:

[Test]
public void OnTriggerEnter_With_Deadly_Object_Sets_GameObject_Inactive()
{
    var killable = new GameObject<Killable, BoxCollider>().Init1((IEventTrigger)new FakeEvent());
    using var testable = new Testable(killable.gameObject);
    (FakeDeadly deadly, BoxCollider collider) deadly = new GameObject<FakeDeadly, BoxCollider>();

    testable.OnTriggerEnter(deadly.collider);

    Assert.IsFalse(killable.gameObject.activeSelf);

    Object.DestroyImmediate(deadly.collider.gameObject);
}

Testing Plain Old C# Objects

If a plain old C# object is attached to a GameObject using a Wrapper, you can test it without creating any GameObjects at all. Just pass all its dependencies to it through its constructor:

var spawner = new Spawner(settings, new FakeTrackable(), new FakeLevel(), timeProvider, gameObjectBuilder);

Replacing Global Services

Sometimes it is not practical to pass every dependency to clients individually. For example:

  • A Play Mode test loads a scene or instantiates a prefab containing many clients that receive their services automatically.
  • A client acquires a dependency using a loose Any<TValue> field or inside a method body using the Service API instead of receiving it as an Init argument.

In such cases you can replace global services with test doubles for the duration of the test using the Service API:

[SetUp]
public void SetUp() => Service.Set<ILogger>(testLogger);

[TearDown]
public void TearDown() => Service.Unset<ILogger>(testLogger);

Note that Service.Set replaces the existing global service of the same type, and Service.Unset does not bring it back. If you want to restore the original service after the test, you can store a reference to it and set it back afterwards:

public readonly struct ScopedService<T> : IDisposable
{
    readonly T previous;

    public ScopedService(T service)
    {
        previous = Service<T>.Instance;
        Service.Set(service);
    }

    public void Dispose()
    {
        Service.Unset<T>();
        if(previous is not null)
        {
            Service.Set(previous);
        }
    }
}

One pattern that can sometimes be useful is to create a base class that replaces commonly used services with test doubles before each test, and restores the originals after it:

public abstract class UnitTests
{
    ScopedService<ILogger> logger;
    ScopedService<ITimeProvider> timeProvider;

    protected TestLogger Logger { get; } = new();
    protected TestTimeProvider TimeProvider { get; } = new();

    [SetUp]
    public void Setup()
    {
        logger = new(Logger);
        timeProvider = new(TimeProvider);
        OnSetup();
    }

    [TearDown]
    public void TearDown()
    {
        OnTearDown();
        logger.Dispose();
        timeProvider.Dispose();
    }

    protected virtual void OnSetup() { }
    protected virtual void OnTearDown() { }
}

Tips

  • Depend on interfaces. Clients that depend on interfaces like ILogger, ITimeProvider or IInputManager instead of concrete types can be easier to test, because a simple fake class can be passed to them instead of the real implementation.
  • Wrap Unity’s static APIs. Static members like Time.deltaTime and Input.GetAxis can’t be replaced in tests. If you access them through a service instead (e.g. ITimeProvider), you can fully control them during tests.
  • Test-only Init arguments. If a client’s dependencies are only ever passed to it in unit tests, you can add [Init(Enabled = false)] to it. This hides the Init section in the Inspector and disables warnings about missing Init arguments in your scenes.
  • Ambiguous Is. Both NUnit.Framework and Sisus.Init contain a type named Is. If you get an ambiguity error when using Assert.That(x, Is.EqualTo(y)), you can add using Is = NUnit.Framework.Is; to the top of the file.

More Examples

The Demo Game sample contains a full suite of Edit Mode unit tests that make use of all the techniques described on this page. After importing the sample, you can find them in the Samples/Init(args) – A next-gen DI framework/<VersionNumber>/Demo Game/Tests folder.

Leave a Reply

Your email address will not be published. Required fields are marked *