unity-testing
Unity Test Framework
Test Framework Overview
The Unity Test Framework (UTF) is Unity's built-in testing solution, integrating a custom version of NUnit (based on NUnit 3.5) adapted for Unity. It enables testing C# code in Edit Mode, Play Mode, and on target platforms (Standalone, Android, iOS).
UTF extends NUnit with Unity-specific attributes ([UnityTest], [UnitySetUp], [UnityTearDown]) that support coroutines, yield instructions, and frame-based execution.
Key capabilities:
- Edit Mode tests for editor extensions and pure logic
- Play Mode tests for gameplay, physics, and coroutine-based code
- Standalone player test builds for platform-specific validation
- Command-line execution for CI/CD pipelines
- NUnit XML result output
Source: Unity Test Framework 2.0 Manual
Setup and Configuration
Installing the Test Framework
UTF ships with Unity by default. Open the Test Runner via Window > General > Test Runner.
Creating a Test Assembly
Test code must live in its own assembly definition (.asmdef) that references NUnit.
Create via Window > General > Test Runner (click Create a new Test Assembly Folder) or Assets > Create > Testing > Test Assembly Folder.
This creates a Tests folder with an .asmdef file preconfigured with references to:
nunit.framework.dllUnityEngine.TestRunnerUnityEditor.TestRunner(Edit Mode only)
Edit Mode Assembly Definition (.asmdef)
{
"name": "Tests",
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": ["Editor"],
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"autoReferenced": false
}
Play Mode Assembly Definition (.asmdef)
{
"name": "Tests.PlayMode",
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"MyGameAssembly"
],
"includePlatforms": [],
"optionalUnityReferences": ["TestAssemblies"],
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"autoReferenced": false
}
Important: To test your game code, add a reference to the assembly containing the code under test in the references array.
Source: Creating Test Assemblies
Edit Mode vs Play Mode Tests
| Aspect | Edit Mode | Play Mode |
|---|---|---|
| Execution context | EditorApplication.update callback loop |
Coroutine on a MonoBehaviour |
| Editor access | Full Editor + game code | Game code only (unless in Editor) |
| Coroutine support | yield return null skips one update |
Full yield instructions (WaitForSeconds, etc.) |
| Platform targets | Editor only | Editor Play Mode + standalone players |
| Use cases | Editor extensions, pure logic, serialization | Physics, gameplay, MonoBehaviour lifecycle |
| Assembly platform | "includePlatforms": ["Editor"] |
"includePlatforms": [] |
When to use Edit Mode:
- Testing editor tools, custom inspectors, ScriptableObject logic
- Pure C# logic that does not depend on runtime systems
- Tests that need access to
UnityEditorAPIs
When to use Play Mode:
- Testing MonoBehaviour lifecycle (Awake, Start, Update)
- Physics interactions requiring
WaitForFixedUpdate - Coroutine and async workflows
- Scene loading and object instantiation at runtime
Best practice: Use NUnit [Test] instead of [UnityTest] unless you need yield instructions. [Test] runs faster since it does not require coroutine overhead.
Source: Edit Mode vs Play Mode Tests
Writing Tests
Create test scripts via Assets > Create > Testing > C# Test Script or using the Test Runner's Create a new Test Script button.
Basic Test Structure
using NUnit.Framework;
[TestFixture]
public class HealthSystemTests
{
private HealthSystem _health;
[SetUp]
public void SetUp() => _health = new HealthSystem(maxHealth: 100);
[TearDown]
public void TearDown() => _health = null;
[Test]
public void TakeDamage_ReducesHealth()
{
_health.TakeDamage(30);
Assert.AreEqual(70, _health.CurrentHealth);
}
[Test]
public void TakeDamage_CannotGoBelowZero()
{
_health.TakeDamage(150);
Assert.AreEqual(0, _health.CurrentHealth);
}
[Test]
public void Heal_IncreasesHealth()
{
_health.TakeDamage(50);
_health.Heal(20);
Assert.AreEqual(70, _health.CurrentHealth);
}
}
NUnit Attributes Reference
| Attribute | Purpose |
|---|---|
[Test] |
Marks a method as a synchronous test |
[TestFixture] |
Marks a class as containing tests |
[SetUp] |
Runs before each test method |
[TearDown] |
Runs after each test method |
[OneTimeSetUp] |
Runs once before all tests in the fixture |
[OneTimeTearDown] |
Runs once after all tests in the fixture |
[TestCase(args)] |
Parameterized test with inline data |
[Values(args)] |
Provides values for a test parameter |
[Category("name")] |
Categorizes tests for filtering |
[Ignore("reason")] |
Skips a test with a reason |
[Timeout(ms)] |
Sets a timeout for the test |
Unity-Specific Attributes
| Attribute | Purpose |
|---|---|
[UnityTest] |
Coroutine-based test supporting yield instructions |
[UnitySetUp] |
Coroutine-based setup (supports yield) |
[UnityTearDown] |
Coroutine-based teardown (supports yield) |
[RequiresPlayMode] |
Forces test to run in Play Mode (true) or Edit Mode (false) |
[UnityPlatform] |
Restricts test to specific platforms |
[TestMustExpectAllLogs] |
Requires all log messages to be expected |
[PrebuildSetup] |
Runs setup before player build |
[PostBuildCleanup] |
Runs cleanup after player build |
[ConditionalIgnore] |
Conditionally ignores tests |
[TestRunCallback] |
Subscribes to test progress updates |
Common Assertions
Assert.AreEqual(expected, actual); // Equality
Assert.IsTrue(condition); // Boolean
Assert.IsNotNull(obj); // Null check
Assert.Throws<ArgumentException>(() => F()); // Exception
Assert.AreEqual(1.0f, actual, 0.01f); // Float tolerance
StringAssert.Contains("sub", actualString); // String
CollectionAssert.Contains(list, item); // Collection
See references/test-framework.md for the full assertion API reference.
Source: Creating Tests
Testing MonoBehaviours
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using System.Collections;
[TestFixture]
public class PlayerControllerTests
{
private GameObject _playerObject;
private PlayerController _player;
[UnitySetUp]
public IEnumerator SetUp()
{
_playerObject = new GameObject("Player");
_player = _playerObject.AddComponent<PlayerController>();
yield return null; // Wait one frame for Awake/Start
}
[UnityTearDown]
public IEnumerator TearDown()
{
Object.Destroy(_playerObject);
yield return null; // Wait for destruction
}
[UnityTest]
public IEnumerator Player_MovesForward_WhenInputApplied()
{
var startPos = _player.transform.position;
_player.Move(Vector3.forward);
yield return new WaitForFixedUpdate();
Assert.Greater(_player.transform.position.z, startPos.z);
}
}
Testing Coroutines and Async
Edit Mode UnityTest (yields in EditorApplication.update)
[UnityTest]
public IEnumerator EditorUtility_WhenExecuted_ReturnsSuccess()
{
var utility = RunEditorUtilityInTheBackground();
while (utility.isRunning) { yield return null; }
Assert.IsTrue(utility.isSuccess);
}
Play Mode UnityTest (coroutine-based)
[UnityTest]
public IEnumerator GameObject_WithRigidBody_WillBeAffectedByPhysics()
{
var go = new GameObject();
go.AddComponent<Rigidbody>();
var originalPosition = go.transform.position.y;
yield return new WaitForFixedUpdate();
Assert.AreNotEqual(originalPosition, go.transform.position.y);
}
Available Yield Instructions (Play Mode)
| Yield Instruction | Effect |
|---|---|
yield return null |
Skip one frame |
yield return new WaitForSeconds(t) |
Wait for t seconds (scaled time) |
yield return new WaitForSecondsRealtime(t) |
Wait for t real-time seconds |
yield return new WaitForFixedUpdate() |
Wait for next physics update |
yield return new WaitForEndOfFrame() |
Wait until end of frame |
yield return new WaitUntil(() => cond) |
Wait until condition is true |
yield return new WaitWhile(() => cond) |
Wait while condition is true |
Testing with LogAssert
[Test]
public void LogWarning_IsExpected()
{
LogAssert.Expect(LogType.Warning, "Expected warning message");
Debug.LogWarning("Expected warning message");
}
Use LogAssert.NoUnexpectedReceived() to assert no unexpected log messages were emitted. Debug.LogError and Debug.LogException cause automatic test failure unless expected.
Common Test Patterns
Arrange-Act-Assert (AAA)
[Test]
public void Inventory_AddItem_IncreasesCount()
{
// Arrange
var inventory = new Inventory(maxSlots: 10);
var item = new Item("Sword");
// Act
inventory.Add(item);
// Assert
Assert.AreEqual(1, inventory.Count);
}
Parameterized Tests
[TestCase(100, 30, 70)]
[TestCase(100, 100, 0)]
[TestCase(100, 150, 0)]
[TestCase(50, 10, 40)]
public void TakeDamage_CalculatesCorrectly(int maxHp, int damage, int expectedHp)
{
var health = new HealthSystem(maxHp);
health.TakeDamage(damage);
Assert.AreEqual(expectedHp, health.CurrentHealth);
}
Testing Exceptions
[Test]
public void Inventory_AddNull_ThrowsException()
{
var inventory = new Inventory(maxSlots: 10);
Assert.Throws<ArgumentNullException>(() =>
{
inventory.Add(null);
});
}
Scene-Based Testing
[UnityTest]
public IEnumerator Scene_Loads_Successfully()
{
SceneManager.LoadScene("GameScene", LoadSceneMode.Single);
yield return new WaitUntil(() => SceneManager.GetActiveScene().name == "GameScene");
Assert.IsTrue(SceneManager.GetActiveScene().isLoaded);
}
Testing with Fakes (Dependency Injection)
public class FakeAudioService : IAudioService
{
public bool PlaySoundCalled { get; private set; }
public void PlaySound(string name) => PlaySoundCalled = true;
}
[Test]
public void Attack_PlaysSound()
{
var fakeAudio = new FakeAudioService();
var weapon = new Weapon(fakeAudio);
weapon.Attack();
Assert.IsTrue(fakeAudio.PlaySoundCalled);
}
Running Tests
From the Test Runner Window
Open Window > General > Test Runner. Toggle EditMode / PlayMode checkboxes. Run tests via Run All, Run Selected, double-click, or right-click context menu. Filter using the search box or result icon buttons.
From the Command Line (CI/CD)
# Run Edit Mode tests
Unity -runTests -batchmode -projectPath /path/to/project \
-testPlatform EditMode -testResults /path/to/results.xml
# Run Play Mode tests
Unity -runTests -batchmode -projectPath /path/to/project \
-testPlatform PlayMode -testResults /path/to/results.xml
# Run filtered tests by name and category
Unity -runTests -batchmode -projectPath /path/to/project \
-testFilter "HealthSystemTests" -testCategory "Unit;Integration" \
-testResults /path/to/results.xml
Command-Line Arguments Reference
| Argument | Description |
|---|---|
-runTests |
Required flag to run tests |
-testPlatform |
EditMode, PlayMode, or any BuildTarget value |
-testFilter |
Semicolon-separated test names or regex pattern; prefix with ! to negate |
-testCategory |
Semicolon-separated category names; prefix with ! to negate |
-testResults |
Output path for NUnit XML results (default: project root) |
-testNames |
Full test names in FixtureName.TestName format |
-assemblyNames |
Filter by specific test assembly names |
-batchmode |
Run without UI (required for CI/CD) |
-runSynchronously |
Execute all tests in one editor update (EditMode only) |
-playerHeartbeatTimeout |
Seconds to wait for player heartbeat (default: 600) |
IDE Integration
JetBrains Rider supports running Unity tests directly from the IDE with full debugging support.
Source: Running Tests, Command Line Reference
Anti-Patterns
1. Using [UnityTest] when [Test] suffices
[UnityTest] adds coroutine overhead. Use [Test] for synchronous logic.
// BAD - unnecessary coroutine overhead
[UnityTest]
public IEnumerator Add_ReturnsSum()
{
var result = Calculator.Add(2, 3);
yield return null;
Assert.AreEqual(5, result);
}
// GOOD - simple synchronous test
[Test]
public void Add_ReturnsSum()
{
var result = Calculator.Add(2, 3);
Assert.AreEqual(5, result);
}
2. Not cleaning up GameObjects
Leaked objects contaminate other tests. Always destroy GameObjects in [UnityTearDown] or [TearDown]. Create objects in setup, destroy in teardown.
3. Testing private methods directly
Test through public interfaces. If you must, use [assembly: InternalsVisibleTo("Tests")].
4. Hardcoding time-based waits
Use WaitUntil or WaitForFixedUpdate instead of arbitrary WaitForSeconds.
// BAD - brittle, slow
yield return new WaitForSeconds(5f);
Assert.IsTrue(enemy.IsDead);
// GOOD - deterministic
yield return new WaitUntil(() => enemy.IsDead);
Assert.IsTrue(enemy.IsDead);
5. Tests depending on execution order
Each test must be independent. Use [SetUp] / [TearDown] to establish clean state.
6. Ignoring log errors
Unexpected Debug.LogError calls cause test failures by default. Use LogAssert.Expect() for expected errors or LogAssert.ignoreFailingMessages = true only when necessary.
7. Placing tests in production assemblies
Test assemblies use UNITY_INCLUDE_TESTS define constraint and are excluded from production builds. Never mix test code with game code assemblies.
Related Skills
- unity-scripting -- C# scripting fundamentals, MonoBehaviour lifecycle, coroutines
- unity-editor-tools -- Custom editors, inspectors, and editor extensions