๐งช 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
asmdefthat references the Test Framework โ tying back to Lesson 1.1 - Write an Edit Mode
[Test]for pure logic and a Play Mode[UnityTest] IEnumeratorthat 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.AreEqualand the fluentAssert.Thatconstraint 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:
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 returnIEnumerator, so you canyieldwhile preparing (e.g. load a test scene withSceneManager.LoadSceneAsyncand 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
newoutside 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
asmdefwith theTestAssembliesoptional 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 withAssert.AreEqualor the fluentAssert.Thatconstraint 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.