Unit Testing in Unity with the Test Framework

Illustration of a test checklist with check marks

Most Unity developers test by pressing Play and poking around. That works until a change to the damage formula silently breaks the shop, or a refactor breaks saving and nobody notices for a week. Automated tests catch those regressions in seconds. This guide covers unit testing in Unity with the Unity Test Framework: setting up test assemblies, writing Edit Mode tests with NUnit, Play Mode tests with [UnityTest], structuring code so it’s testable, and running tests automatically in CI.

What’s Worth Testing in a Game

You don’t need to test everything. “Does the jump feel good?” isn’t a unit test. Focus on logic with right answers:

  • Damage, stats, economy and crafting formulas
  • Inventory operations: stacking, removing, full-bag behaviour (like the system in our inventory tutorial)
  • Save/load round trips: save, load, compare
  • State machine transitions (see our enemy AI state machine)
  • Procedural generation determinism: the same seed gives the same output
  • Bugs you’ve fixed before. Write a test so they never come back.

Setting Up the Test Framework and Assemblies

The Test Framework package is included in most templates. Open Window > General > Test Runner. It has EditMode and PlayMode tabs.

Tests live in their own assembly, and they can only see your game code if that code is in an assembly too:

  1. In your scripts folder, create Create > Scripting > Assembly Definition named Game. All scripts under that folder now compile into Game.dll.
  2. In the Test Runner, click Create EditMode Test Assembly Folder. This creates a Tests folder with a test assembly definition referencing NUnit.
  3. Select the test assembly definition and add Game to its Assembly Definition References.

This last step is the one everyone misses. Without it, tests can’t find your classes and you get CS0246: type or namespace not found. Assembly definitions also speed up compile times, a nice bonus.

Your First Edit Mode Test

Edit Mode tests run instantly without entering Play mode. They’re perfect for pure logic. Say you have a damage calculator:

// In the Game assembly
public static class DamageCalculator
{
    public static float Calculate(float baseDamage, float armor, bool critical)
    {
        float reduced = baseDamage * (100f / (100f + armor));
        return critical ? reduced * 2f : reduced;
    }
}

A test class in the Tests folder:

using NUnit.Framework;

public class DamageCalculatorTests
{
    [Test]
    public void NoArmor_DealsFullDamage()
    {
        Assert.AreEqual(50f, DamageCalculator.Calculate(50f, 0f, false), 0.001f);
    }

    [Test]
    public void HundredArmor_HalvesDamage()
    {
        Assert.AreEqual(25f, DamageCalculator.Calculate(50f, 100f, false), 0.001f);
    }

    [Test]
    public void Critical_DoublesDamage()
    {
        Assert.AreEqual(100f, DamageCalculator.Calculate(50f, 0f, true), 0.001f);
    }
}

Click Run All in the Test Runner. Green ticks mean passing. Change the formula, re-run, and red tells you immediately what broke. Name tests as Situation_ExpectedResult so a failure explains itself.

NUnit Essentials

// Parameterized tests: one method, many cases
[TestCase(50f, 0f, 50f)]
[TestCase(50f, 100f, 25f)]
[TestCase(100f, 300f, 25f)]
public void ArmorReducesDamage(float dmg, float armor, float expected)
{
    Assert.That(DamageCalculator.Calculate(dmg, armor, false), Is.EqualTo(expected).Within(0.001f));
}

// Setup and teardown run around every test
Inventory inventory;
[SetUp] public void SetUp() => inventory = new Inventory(size: 4);
[TearDown] public void TearDown() { /* clean up */ }

// Expecting exceptions
[Test]
public void NegativeAmount_Throws()
{
    Assert.Throws<System.ArgumentException>(() => inventory.Add(apple, -1));
}

// Common constraints
Assert.That(list, Has.Count.EqualTo(3));
Assert.That(value, Is.InRange(0f, 1f));
Assert.That(name, Does.StartWith("Enemy"));
Assert.IsTrue(result); Assert.IsNull(obj);

To test that something logs an error, use LogAssert.Expect(LogType.Error, "message"). Otherwise, any unexpected error log fails the test, which is a useful safety net.

Play Mode Tests With UnityTest

Play Mode tests run in a real play session, so MonoBehaviour lifecycles, physics and coroutines all work. Create a PlayMode test assembly the same way. Use [UnityTest], which returns IEnumerator so you can wait frames:

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

public class HealthPlayModeTests
{
    [UnityTest]
    public IEnumerator Health_ReachesZero_FiresDied()
    {
        var go = new GameObject("Target");
        var health = go.AddComponent<Health>();
        bool died = false;
        health.Died += () => died = true;

        yield return null;   // let Awake/Start run

        health.TakeDamage(1000f);
        Assert.IsTrue(died);
        Object.Destroy(go);
    }

    [UnityTest]
    public IEnumerator Ball_FallsUnderGravity()
    {
        var ball = GameObject.CreatePrimitive(PrimitiveType.Sphere);
        ball.AddComponent<Rigidbody>();
        float startY = ball.transform.position.y;

        yield return new WaitForSeconds(0.5f);

        Assert.Less(ball.transform.position.y, startY);
        Object.Destroy(ball);
    }
}

Keep Play Mode tests few and focused: they’re slower than Edit Mode tests. To test a real scene, load it with SceneManager.LoadSceneAsync in a [UnitySetUp] method (scenes must be in the build list or loaded via EditorSceneManager in the Editor). See async scene loading for the loading API.

📘 Want cleaner, testable code?

Testing gets easy once code is well structured. A couple of software design classics on our reading list are just as useful for game developers.

See our programming book picks →

Writing Testable Code

The biggest obstacle to testing in Unity isn’t the framework. It’s code tangled with MonoBehaviours, singletons and scene references. A few habits help:

  • Put logic in plain C# classes. A WalletLogic class with TrySpend(int) is trivially testable. The MonoBehaviour just forwards calls and updates UI.
  • Pass dependencies in instead of reaching for GameManager.Instance or FindAnyObjectByType. Constructor parameters or serialized references can be replaced by fakes in tests. Our singleton patterns guide covers alternatives.
  • Abstract time and randomness. Pass a System.Random with a seed, or a time value, instead of calling Random.value and Time.time inside logic.
  • Use events for outputs, so tests can subscribe and check what happened.
// Testable: no Unity dependencies at all
public class Wallet
{
    public int Coins { get; private set; }
    public event System.Action<int> Changed;
    public Wallet(int start) => Coins = start;

    public bool TrySpend(int amount)
    {
        if (amount <= 0 || amount > Coins) return false;
        Coins -= amount; Changed?.Invoke(Coins);
        return true;
    }
}

[Test] public void CannotSpendMoreThanYouHave() => Assert.IsFalse(new Wallet(10).TrySpend(11));

Running Tests From the Command Line and CI

Tests are most valuable when they run automatically on every push. Unity runs them headless from the command line:

Unity.exe -batchmode -projectPath "C:/Projects/MyGame" -runTests -testPlatform EditMode -testResults results-editmode.xml -logFile -

Use -testPlatform PlayMode for Play Mode tests. The exit code is non-zero on failure, which CI systems detect. On GitHub Actions, the community GameCI actions (unity-test-runner) handle licensing and caching for you. Pair this with the Git setup from our Git for Unity guide.

Troubleshooting

Tests can’t find my game classes (CS0246)

Your game scripts aren’t in an assembly definition, or the test assembly doesn’t reference it. Create an asmdef for game code and add it to the test asmdef’s references.

Test Runner shows no tests

The test files aren’t inside a test assembly (an asmdef with the NUnit references and “Test Assemblies” constraint), or there are compile errors. Check the Console.

Play Mode test fails with an unexpected error log

Any logged error fails the test. Fix the error, or use LogAssert.Expect if it’s expected. LogAssert.ignoreFailingMessages = true is a last resort.

Tests pass alone but fail together

Tests share state: static fields, singletons, or objects not destroyed in teardown. Reset statics in [SetUp] and destroy created objects.

“Assembly with name ‘Game’ already exists”

Two assembly definitions share a name. Give each a unique name.

FAQ

What’s the difference between Edit Mode and Play Mode tests?

Edit Mode tests run instantly in the Editor without entering Play mode, which is ideal for pure logic. Play Mode tests run in a play session, so physics, coroutines and MonoBehaviour lifecycles work.

Do I need assembly definitions for testing?

Yes. Test assemblies can only reference code that lives in an assembly definition, not the default Assembly-CSharp.

Can I test MonoBehaviours?

Yes. Create GameObjects and add components in Play Mode tests. It’s easier to test logic extracted into plain C# classes.

Are unit tests worth it for indie games?

For core systems like saving, inventory, economy and formulas, absolutely. They catch regressions that manual play-testing misses.

Can tests run automatically on GitHub?

Yes. Run Unity in batch mode with -runTests, or use the GameCI GitHub Actions.

Comments

Leave a Reply

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