Skip to main content

๐Ÿงช Lesson 8.1: The Unity Test Framework โ€” Edit & Play Mode Tests

For thirty-four lessons you've written code and checked it by pressing Play and looking. That doesn't scale. Every time you refactor an inventory system or tune a Burst job, you'd have to manually re-verify everything it touches โ€” and you won't, so regressions slip in. Automated tests fix that: they encode "this behaves correctly" as code the machine re-runs in seconds. This lesson introduces the Unity Test Framework โ€” Unity's NUnit-based harness for both pure-logic Edit Mode tests and runtime Play Mode tests โ€” and, just as important, how to write game code that's actually testable.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Set up a test assembly with an asmdef that references the Test Framework โ€” tying back to Lesson 1.1
  • Write an Edit Mode [Test] for pure logic and a Play Mode [UnityTest] IEnumerator that spans frames
  • Read the Test Runner window and interpret pass/fail results
  • Structure a test with Arrange-Act-Assert and share fixtures via [SetUp]/[TearDown]
  • Use Assert.AreEqual and the fluent Assert.That constraint model
  • Make code testable with interfaces and dependency injection (Lesson 1.2), and measure with the Code Coverage package

Estimated Time: 70 minutes  ยท  Prerequisite: Lesson 1.1 (assembly definitions) and Lesson 1.2 (dependency injection); confident C#

In This Lesson

Why Automated Tests

A test is a small program that exercises your code and asserts that the result is what you expect. If the assertion holds, the test passes; if not, it fails loudly and tells you exactly where. The payoff is not proving code correct once โ€” it's proving it still correct after every change you make for the rest of the project's life.

That safety net is what makes the advanced work in this course sustainable. When you convert a system to a Burst job (Module 3), extract an interface for DI (Lesson 1.2), or split a monolith into assemblies (Lesson 1.1), tests tell you in seconds whether you preserved behaviour. Without them, "did I break anything?" is answered by hope.

๐Ÿ“– Definition

A regression is a bug introduced into code that used to work. The primary job of an automated test suite is regression prevention: it re-runs your accumulated definition of "correct" on every change, so a break surfaces the moment it happens rather than weeks later in QA โ€” or in a player's hands.

NUnit, Edit Mode & Play Mode

The Unity Test Framework (UTF, package com.unity.test-framework, installed by default) is built on NUnit โ€” the long-standing .NET testing library โ€” with Unity-specific extensions layered on top. If you've written NUnit tests elsewhere, the attributes ([Test], [SetUp], Assert) are exactly the same. What Unity adds is the ability to run tests inside the engine, in one of two modes.

โœ๏ธ Edit Mode

Runs in the Editor without entering Play Mode. Fast, synchronous, ideal for pure logic โ€” scoring math, inventory rules, state machines, save/load, data transforms. Marked with [Test]. This is where the bulk of a healthy suite lives.

โ–ถ๏ธ Play Mode

Enters a real runtime: Awake, Start, physics, coroutines, and frame stepping all run. Slower, but the only way to test MonoBehaviour lifecycle, physics settling, or anything that unfolds over frames. Marked with [UnityTest] and returns IEnumerator.

The rule of thumb: push as much logic as you can into plain C# that Edit Mode can test instantly, and reserve Play Mode for the genuinely runtime-dependent behaviour. A suite that's 90% Edit Mode runs in a blink; one that leans on Play Mode for everything crawls.

The Test Assembly

Back in Lesson 1.1 you learned that an .asmdef file carves your code into a separately compiled assembly. Tests live in their own assembly for two reasons: it keeps test code out of your shipping build, and it lets the assembly reference the Test Framework's engine and editor pieces that game code shouldn't.

Create a folder (say Assets/Tests/EditMode), then in the Project window use Create โ–ธ Testing โ–ธ Test Assembly Folder โ€” or add an asmdef manually. A minimal Edit Mode test assembly definition looks like this:

{
    "name": "Game.Tests.EditMode",
    "references": [ "Game.Runtime" ],
    "includePlatforms": [ "Editor" ],
    "optionalUnityReferences": [ "TestAssemblies" ]
}

The magic entry is "TestAssemblies" under optionalUnityReferences โ€” that flag is what tells Unity to reference UnityEngine.TestRunner and UnityEditor.TestRunner and to surface this assembly in the Test Runner. The references array points at the game assembly under test (here Game.Runtime), so your tests can see the classes they exercise. A Play Mode test assembly is the same minus the Editor-only platform restriction, so it can build into a player.

โš ๏ธ Tests can only see public (or friend) code

Because the test assembly is separate, it can only reach public members of the assembly under test. Either test through the public surface (usually the right call), or grant access with [assembly: InternalsVisibleTo("Game.Tests.EditMode")] in the runtime assembly to expose internal members without making them public to the whole game.

An Edit Mode Test

Here's a class under test โ€” a pure scoring rule with no Unity dependencies at all, exactly the kind of logic Edit Mode tests love:

namespace Game.Runtime
{
    public static class Scoring
    {
        // Base points times a combo multiplier, clamped so a broken
        // combo can never subtract points.
        public static int ComboScore(int basePoints, int comboCount)
        {
            int multiplier = System.Math.Max(1, comboCount);
            return basePoints * multiplier;
        }
    }
}

And the test. Notice the structure: Arrange the inputs, Act by calling the method, Assert the result. That three-beat rhythm โ€” AAA โ€” keeps every test readable at a glance.

using NUnit.Framework;
using Game.Runtime;

public class ScoringTests
{
    [Test]
    public void ComboScore_MultipliesByComboCount()
    {
        // Arrange
        int basePoints = 100;
        int combo = 3;

        // Act
        int result = Scoring.ComboScore(basePoints, combo);

        // Assert
        Assert.AreEqual(300, result);
    }

    [Test]
    public void ComboScore_TreatsZeroComboAsSingle()
    {
        // A broken combo should still award the base, never zero.
        Assert.AreEqual(100, Scoring.ComboScore(100, 0));
    }
}

Two assertion styles exist and you'll see both. The classic Assert.AreEqual(expected, actual) is direct. NUnit also offers a fluent constraint model via Assert.That, which reads like a sentence and gives richer failure messages:

Assert.That(result, Is.EqualTo(300));
Assert.That(result, Is.GreaterThan(0).And.LessThanOrEqualTo(1000));
Assert.That(names, Does.Contain("boss").And.Not.Empty);

When a fixture needs shared setup, [SetUp] runs before each test and [TearDown] after โ€” Unity re-creates the fixture per test so they never bleed state into one another:

public class InventoryTests
{
    private Inventory _inv;

    [SetUp]
    public void SetUp() => _inv = new Inventory(capacity: 4);

    [Test]
    public void Add_IncreasesCount()
    {
        _inv.Add(new Item("potion"));
        Assert.That(_inv.Count, Is.EqualTo(1));
    }

    [TearDown]
    public void TearDown() => _inv = null;
}

The Test Runner Window

Open Window โ–ธ General โ–ธ Test Runner. It has two tabs โ€” PlayMode and EditMode โ€” each showing a tree of your test assemblies, fixtures (classes), and individual tests. Green checks are passes, red crosses are failures. Select any node and Run Selected, or Run All. Click a failed test and the bottom pane shows the assertion that broke, the expected-vs-actual values, and a stack trace straight to the line. Because the Test Runner is a UI Toolkit window that doesn't screenshot cleanly, here it is reconstructed faithfully:

The Unity Test Runner window A reconstruction of Unity's Test Runner window. A toolbar at the top has PlayMode and EditMode tabs with EditMode selected, plus Run All, Run Selected, and Run Failed buttons and a results summary reading 7 passed, 1 failed. Below is a tree: the Game.Tests.EditMode assembly contains a ScoringTests fixture with two passing tests, an InventoryTests fixture with three passing tests, and a SaveSystemTests fixture with one passing and one failing test. The failing test SaveSystem_RoundTripsGold is highlighted in red. A details pane at the bottom shows the failure message: Expected 250 but was 0, with a short stack trace. Test Runner PlayMode EditMode Run All Run Selected Run Failed 7 passed 1 failed โ–พ ๐Ÿ“ฆ Game.Tests.EditMode โ–พ ๐Ÿ—‚ ScoringTests โœ” ComboScore_MultipliesByComboCount โœ” ComboScore_TreatsZeroComboAsSingle โ–พ ๐Ÿ—‚ InventoryTests โœ” Add_IncreasesCount โœ” Add_RejectsOverCapacity โœ” Remove_DecreasesCount โ–พ ๐Ÿ—‚ SaveSystemTests โœ” SaveSystem_WritesFile โœ– SaveSystem_RoundTripsGold SaveSystem_RoundTripsGold โ€” Failed Expected: 250 But was: 0 at SaveSystemTests.SaveSystem_RoundTripsGold () in Assets/Tests/EditMode/SaveSystemTests.cs:42
Figure 1: The Test Runner (reconstructed). The EditMode tab shows a tree of assemblies โ–ธ fixtures โ–ธ tests; green โœ” passed, red โœ– failed. Selecting the failed SaveSystem_RoundTripsGold reveals the assertion (Expected 250, but was 0) and a stack trace straight to the offending line. This is your day-to-day feedback loop.

A Play Mode Test

Some behaviour only makes sense at runtime โ€” a projectile that must travel for a few frames, a coroutine that resolves next frame, physics that needs to settle. For these you write a Play Mode test: mark it [UnityTest] and return IEnumerator, so you can yield to advance frames just like a coroutine.

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class ProjectileTests
{
    [UnityTest]
    public IEnumerator Projectile_MovesForwardOverTime()
    {
        // Arrange: spawn a fresh GameObject with the component under test.
        var go = new GameObject("proj");
        var proj = go.AddComponent<Projectile>();
        proj.Speed = 5f;
        Vector3 start = go.transform.position;

        // Act: let the engine run real frames.
        yield return null;               // one frame
        yield return new WaitForSeconds(0.2f);

        // Assert: it moved along +Z.
        Assert.That(go.transform.position.z, Is.GreaterThan(start.z));

        Object.Destroy(go);
    }
}

The key differences from Edit Mode: yield return null advances exactly one frame (running every Update), and yield return new WaitForSeconds(...) lets real time pass. Anything you spawn, you clean up โ€” a Play Mode test shares the scene with the others in its run, so leaked GameObjects cause spooky failures downstream.

๐Ÿ’ก [UnitySetUp] and [UnityTearDown]. Play Mode has coroutine-aware setup/teardown variants that also return IEnumerator, so you can yield while preparing (e.g. load a test scene with SceneManager.LoadSceneAsync and wait for it). Use them when plain synchronous [SetUp] isn't enough.

Writing Testable Code

The single biggest factor in whether tests are easy or agonising is the design of the code under test. Logic tangled directly into a MonoBehaviour that reaches for Time.deltaTime, Input, the network, and the file system is nearly impossible to test in isolation. Logic behind an interface, with its dependencies injected (Lesson 1.2), is trivial.

Consider a chest that grants a random reward. If it calls Random.Range directly, the test can't predict the result. Extract the randomness behind an interface and inject it:

public interface IRandomSource { int Range(int minInclusive, int maxExclusive); }

public class LootChest
{
    private readonly IRandomSource _rng;
    public LootChest(IRandomSource rng) => _rng = rng;   // injected

    public string Open(string[] table) => table[_rng.Range(0, table.Length)];
}

Now the test supplies a fake (a "stub") that returns a known value, making the outcome deterministic:

private class FixedRandom : IRandomSource
{
    private readonly int _value;
    public FixedRandom(int value) => _value = value;
    public int Range(int min, int max) => _value;
}

[Test]
public void Open_PicksEntryFromRandomIndex()
{
    var chest = new LootChest(new FixedRandom(2));
    string[] table = { "coin", "gem", "crown", "dust" };

    Assert.That(chest.Open(table), Is.EqualTo("crown"));   // index 2
}

This is why the architecture module comes first in the course and testing comes last: the DI, interfaces, and assembly boundaries you learned early are precisely what make the code you write testable now. The lesson generalises: separate decision-making logic from engine plumbing. Pure logic goes in plain C# classes (fast Edit Mode tests); the thin MonoBehaviour just wires inputs to that logic and needs only a light Play Mode test, if any.

โœ… A quick testability checklist

  • Can I construct the class with new outside Play Mode? If not, it's doing too much.
  • Are its external dependencies (time, random, IO, network) behind interfaces I can fake?
  • Does each method have a clear input โ†’ output I can assert on, rather than only side effects?

Code Coverage

Once you have tests, a natural question is how much of your code they actually exercise. Unity's Code Coverage package (install via the Package Manager) instruments your assemblies and reports the percentage of lines and branches your tests touched, with an HTML report highlighting the untested lines in red.

Enable it (Window โ–ธ Analysis โ–ธ Code Coverage), tick "Generate HTML report", run your tests, and open the result. It's excellent for spotting whole systems that no test ever reaches. But treat the number as a flashlight, not a target: chasing 100% coverage rewards writing shallow tests for trivial getters while a single well-designed test of critical logic is worth more. Aim to cover the code where a bug would hurt โ€” scoring, saves, economy, matchmaking โ€” and don't sweat coverage on thin glue.

โš ๏ธ Coverage measures execution, not correctness

A line can be 100% covered by a test that never asserts anything meaningful about it. High coverage with weak assertions is a false sense of security. Coverage tells you what you haven't tested; only good assertions tell you whether what you tested is right.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Test a health system

Objective: Write Edit Mode tests for a small pure-logic class.

Given this class in your Game.Runtime assembly:

public class Health
{
    public int Current { get; private set; }
    public int Max { get; }
    public bool IsDead => Current <= 0;
    public Health(int max) { Max = max; Current = max; }
    public void Damage(int amount) => Current = System.Math.Max(0, Current - amount);
    public void Heal(int amount) => Current = System.Math.Min(Max, Current + amount);
}

Create a test assembly and write tests that verify: (1) damage reduces Current; (2) damage can't go below zero and IsDead becomes true; (3) healing never exceeds Max. Use Arrange-Act-Assert and a [SetUp] that builds a fresh Health(100).

โœ… Solution
using NUnit.Framework;
using Game.Runtime;

public class HealthTests
{
    private Health _hp;

    [SetUp]
    public void SetUp() => _hp = new Health(100);

    [Test]
    public void Damage_ReducesCurrent()
    {
        _hp.Damage(30);
        Assert.That(_hp.Current, Is.EqualTo(70));
    }

    [Test]
    public void Damage_ClampsAtZeroAndReportsDead()
    {
        _hp.Damage(999);
        Assert.That(_hp.Current, Is.EqualTo(0));
        Assert.That(_hp.IsDead, Is.True);
    }

    [Test]
    public void Heal_NeverExceedsMax()
    {
        _hp.Damage(10);
        _hp.Heal(50);
        Assert.That(_hp.Current, Is.EqualTo(100));
    }
}

๐Ÿ‹๏ธ Exercise 2: Make an untestable class testable

A DailyReward class computes a bonus using System.DateTime.Now directly, so its result depends on the wall clock and can't be tested. Sketch how you'd refactor it so a test can pin "today" to a fixed date.

โœ… Approach

Introduce an IClock interface with a DateTime Now { get; } and inject it into DailyReward (constructor injection, Lesson 1.2). Production wires a SystemClock that returns DateTime.Now; the test passes a FakeClock returning a fixed date, so the bonus calculation becomes deterministic and assertable. Same pattern as the IRandomSource example โ€” hide the non-deterministic dependency behind an interface you can substitute.

๐ŸŽฏ Quick Quiz

Question 1: You need to test pure scoring math with no runtime behaviour. Which test type fits best?

Question 2: What makes an assembly show up in the Test Runner and gain access to the NUnit/UTF APIs?

Question 3: Why return IEnumerator from a [UnityTest]?

Question 4: A class calls Random.Range directly and its result can't be predicted in a test. The cleanest fix is toโ€ฆ

Summary

๐ŸŽ‰ Key Takeaways

  • The Unity Test Framework is NUnit inside the engine, with two modes: fast synchronous Edit Mode ([Test]) for pure logic and runtime Play Mode ([UnityTest] IEnumerator) for frame-dependent behaviour.
  • Tests live in their own assembly: an asmdef with the TestAssemblies optional reference, pointing at the assembly under test โ€” a direct application of Lesson 1.1.
  • Structure every test as Arrange-Act-Assert; share fixtures with [SetUp]/[TearDown]; assert with Assert.AreEqual or the fluent Assert.That constraint model.
  • The Test Runner window shows assemblies โ–ธ fixtures โ–ธ tests with pass/fail and jumps you to the failing line.
  • Testability is a design property: hide time/random/IO behind interfaces and inject them (Lesson 1.2) so tests can substitute deterministic fakes.
  • Code Coverage shows what you haven't tested โ€” a flashlight, not a target; strong assertions, not high percentages, prove correctness.

๐Ÿš€ What's Next?

Tests are only powerful if they run automatically, on every commit, not just when you remember. In Lesson 8.2: Automated & Cloud Builds (CI/CD) you'll drive Unity from the command line in batch mode, write a C# BuildScript that calls BuildPipeline.BuildPlayer, and wire a GitHub Actions pipeline that runs your new test suite and produces a build artifact โ€” turning "I tested it on my machine" into "the server proves it on every push."

๐Ÿงช You have a safety net

A green Test Runner is permission to refactor fearlessly. Every system you built in this course becomes safer to change the moment it's under test.