Introduction
Testing in Unity is more complicated than testing ordinary C# code.
A damage formula can be covered with a normal NUnit [Test], but a real game also contains MonoBehaviour, ScriptableObject, frame updates, coroutines, scenes, prefabs, physics, and asynchronous asset loading.
The practical questions are where to split EditMode and PlayMode, when [UnityTest] is necessary, how to handle lifecycle and asynchronous code, and which tests belong in CI.
This article uses Unity 6 and Unity Test Framework 1.6 as its baseline. It is written for programmers introducing automated tests or adding them incrementally to an existing project. The samples are intentionally small, so adjust omitted namespaces and project-specific types to your own assemblies.
The basic strategy is to keep game rules in fast pure-C# EditMode tests, test thin Unity adapters only where needed, reserve PlayMode for PlayerLoop or lifecycle behavior, and leave visual quality or device-specific performance to QA and device tests.
Tests do not prove that a game has no bugs. They provide a fast safety net when you refactor code, change specifications, or fix a regression.
What Unity Test Framework provides
Unity Test Framework is Unity's built-in testing system. It is based on NUnit and adds support for Unity-specific concepts such as frames, the application loop, domain reloads, and player execution.
The common ways to run tests are:
- Test Runner inside the Unity Editor
- Unity command-line execution for CI
-
TestRunnerApifor custom tooling
Use Test Runner during development, command-line execution in CI, and TestRunnerApi only when you need a custom test workflow.
Unity ships its own NUnit-compatible environment, so APIs from the latest standalone NUnit may not all be available or behave identically. Check the documentation for the Test Framework version bundled with your Editor, especially for asynchronous assertions.
EditMode and PlayMode tests
Unity tests are broadly divided into EditMode and PlayMode tests.
EditMode tests
EditMode tests run in the Editor without normal Play Mode. They suit calculations, save migration, probability rules, data validation, ScriptableObject logic, editor tooling, serialization, and static prefab or scene checks. Pure C# EditMode tests are usually fast enough to run continuously.
PlayMode tests
PlayMode tests execute in Unity's runtime environment. Use them for lifecycle callbacks, frame updates, coroutines, scene loading, runtime prefab wiring, animation transitions, physics, and behavior that appears only in a Player. They are slower and more sensitive to global state, so an all-PlayMode suite becomes expensive to diagnose.
A practical decision process
Ask these questions in order:
- Does the behavior work without Unity APIs?
- Does it need Unity objects but not frame progression?
- Does it depend on the PlayerLoop, lifecycle events, scenes, or physics?
- Does it require an actual target device?
Use pure EditMode tests for case 1, Unity-aware EditMode tests for case 2, PlayMode tests for case 3, and Player/device testing for case 4.
Create test assemblies first
Tests should live in assemblies that reference NUnit and the production assemblies they test.
A practical folder structure is:
Assets/
├── Game/
│ ├── Runtime/
│ │ ├── Game.Runtime.asmdef
│ │ ├── Battle/
│ │ └── Save/
│ └── Editor/
│ ├── Game.Editor.asmdef
│ └── Validation/
└── Tests/
├── EditMode/
│ ├── Game.EditModeTests.asmdef
│ ├── Battle/
│ └── Save/
└── PlayMode/
├── Game.PlayModeTests.asmdef
├── Battle/
└── Scene/
An EditMode test assembly conceptually looks like this:
{
"name": "Game.EditModeTests",
"references": [
"Game.Runtime"
],
"includePlatforms": [
"Editor"
],
"optionalUnityReferences": [
"TestAssemblies"
]
}
A PlayMode test assembly can target runtime platforms:
{
"name": "Game.PlayModeTests",
"references": [
"Game.Runtime"
],
"includePlatforms": [],
"optionalUnityReferences": [
"TestAssemblies"
]
}
Treat these JSON blocks as conceptual examples. Unity versions can generate different fields, so verify the settings in the Inspector instead of blindly replacing an .asmdef file.
A common obstacle in older projects is that production code still belongs to Assembly-CSharp.dll, which a test assembly cannot directly reference. Move one independent class into Game.Runtime.asmdef, reference it from the test assembly, and expand gradually. Keep UnityEditor APIs in a separate editor assembly and preserve a one-way dependency from presentation code toward lower-level logic.
Write the first pure C# test
Start with a class that has no Unity dependency.
namespace Game.Battle
{
public sealed class DamageCalculator
{
public int Calculate(int attack, int defense)
{
var damage = attack - defense;
return damage < 1 ? 1 : damage;
}
}
}
The test is ordinary NUnit code:
using Game.Battle;
using NUnit.Framework;
namespace Game.Tests.EditMode.Battle
{
public sealed class DamageCalculatorTests
{
[Test]
public void Calculate_AttackIsGreaterThanDefense_ReturnsDifference()
{
// Arrange
var calculator = new DamageCalculator();
// Act
var actual = calculator.Calculate(attack: 100, defense: 30);
// Assert
Assert.That(actual, Is.EqualTo(70));
}
[Test]
public void Calculate_DefenseIsGreaterThanAttack_ReturnsMinimumDamage()
{
var calculator = new DamageCalculator();
var actual = calculator.Calculate(attack: 30, defense: 100);
Assert.That(actual, Is.EqualTo(1));
}
}
}
Arrange, Act, and Assert are useful as a mental model:
- Arrange the subject and inputs.
- Act by executing one behavior.
- Assert the result.
Comments are not mandatory in every short test. Clear spacing is often enough.
Test names should explain a failure. A useful convention is:
Method_Condition_ExpectedResult
For example:
Calculate_DefenseIsGreaterThanAttack_ReturnsMinimumDamage
The exact convention matters less than being able to infer what broke from the failed test name in CI.
Parameterize boundary cases
Use [TestCase] when the same rule must be checked with multiple inputs.
[TestCase(100, 30, 70)]
[TestCase(30, 100, 1)]
[TestCase(50, 50, 1)]
[TestCase(1, 0, 1)]
public void Calculate_WithVariousParameters_ReturnsExpectedDamage(
int attack,
int defense,
int expected)
{
var calculator = new DamageCalculator();
var actual = calculator.Calculate(attack, defense);
Assert.That(actual, Is.EqualTo(expected));
}
Parameterization is especially useful for:
- minimum and maximum values
- zero, one, and negative values
- just below, exactly at, and just above a threshold
- level and inventory caps
- date boundaries
- probability-table edges
- rounding rules
For more complex cases, use [TestCaseSource]. TestCaseData allows readable names in CI output.
private static readonly TestCaseData[] Cases =
{
new TestCaseData(100, 30, 70)
.SetName("AttackGreaterThanDefense"),
new TestCaseData(30, 100, 1)
.SetName("DefenseGreaterThanAttack"),
};
[TestCaseSource(nameof(Cases))]
public void Calculate_WithCases_ReturnsExpected(
int attack,
int defense,
int expected)
{
var calculator = new DamageCalculator();
Assert.That(calculator.Calculate(attack, defense), Is.EqualTo(expected));
}
Do not combine unrelated rules merely because the inputs fit into one parameter list. Parameterize data variations of the same behavior.
Assert behavior, not every field
A test becomes difficult to understand when it verifies many unrelated values. The goal is not "one assertion per test" but "one behavior per test."
Multiple assertions are appropriate when they describe one atomic result:
[Test]
public void ConsumePotion_WhenStockExists_HealsAndDecreasesStock()
{
var inventory = new Inventory(potionCount: 2);
var player = new Player(currentHp: 50, maxHp: 100);
inventory.ConsumePotion(player);
Assert.That(player.CurrentHp, Is.EqualTo(80));
Assert.That(inventory.PotionCount, Is.EqualTo(1));
}
Healing the player and consuming one item are two parts of the same operation. Splitting them would duplicate setup without improving clarity.
Test ScriptableObject rules in EditMode
A ScriptableObject can be created without saving an asset.
using NUnit.Framework;
using UnityEngine;
public sealed class EnemyParameterTests
{
private EnemyParameter _parameter;
[SetUp]
public void SetUp()
{
_parameter = ScriptableObject.CreateInstance<EnemyParameter>();
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_parameter);
}
[Test]
public void IsBoss_HpIsAtLeast10000_ReturnsTrue()
{
_parameter.Hp = 10000;
Assert.That(_parameter.IsBoss, Is.True);
}
}
The production type might be:
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Enemy Parameter")]
public sealed class EnemyParameter : ScriptableObject
{
[field: SerializeField]
public int Hp { get; set; }
public bool IsBoss => Hp >= 10000;
}
Destroy temporary EditMode objects with DestroyImmediate when appropriate. Put cleanup in [TearDown] so it still runs after an assertion failure.
Separate two concerns:
- Test class rules with temporary instances.
- Validate all real project assets in a dedicated project-validation test.
Tests that depend on exact asset paths or GUIDs are more fragile than tests against temporary instances.
Validate prefabs and assets
Automated tests are also effective at finding authoring mistakes.
Suppose every enemy prefab under Assets/Game/Enemies must have an EnemyController on its root:
using System.Linq;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
public sealed class EnemyPrefabValidationTests
{
[Test]
public void EnemyPrefabs_AllHaveEnemyController()
{
var guids = AssetDatabase.FindAssets(
"t:Prefab",
new[] { "Assets/Game/Enemies" });
var invalidPaths = guids
.Select(AssetDatabase.GUIDToAssetPath)
.OrderBy(path => path)
.Where(path =>
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path);
return prefab == null ||
prefab.GetComponent<EnemyController>() == null;
})
.ToArray();
Assert.That(
invalidPaths,
Is.Empty,
$"Prefabs without EnemyController:\n{string.Join("\n", invalidPaths)}");
}
}
This is closer to project validation than a unit test, but it is highly valuable. The same approach can detect missing components or scripts, broken references, duplicate IDs, invalid Addressables labels, missing localization keys, incorrect Layers or Tags, and assets that violate project rules.
The sample above only checks for EnemyController. Missing scripts require a separate validation, for example with GameObjectUtility.GetMonoBehavioursWithMissingScriptCount while traversing the prefab hierarchy.
A full project scan may be slow. Categorize it separately and run it in CI or at night instead of blocking every local edit.
[Category("AssetValidation")]
[Test]
public void EnemyPrefabs_AllHaveEnemyController()
{
// Validation logic
}
Test MonoBehaviour correctly
Never instantiate a MonoBehaviour with new. Create a GameObject and use AddComponent<T>().
A method that does not require frame progression can still be tested in EditMode:
using NUnit.Framework;
using UnityEngine;
public sealed class HealthViewTests
{
private GameObject _gameObject;
[SetUp]
public void SetUp()
{
_gameObject = new GameObject("HealthViewTest");
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_gameObject);
}
[Test]
public void SetHealth_WithHalfValue_StoresRatio()
{
var view = _gameObject.AddComponent<HealthView>();
view.SetHealth(current: 50, max: 100);
Assert.That(view.Ratio, Is.EqualTo(0.5f).Within(0.0001f));
}
}
This does not test Awake or Start. It only verifies a public method on a component. Move to PlayMode when Unity lifecycle behavior is part of the specification.
Cross frames with UnityTest
When a PlayMode test must cross frames, use [UnityTest] and return IEnumerator. The attribute is also available in EditMode, but this example focuses on runtime lifecycle behavior.
using UnityEngine;
public sealed class PlayerInitializer : MonoBehaviour
{
public bool IsInitialized { get; private set; }
private void Start()
{
IsInitialized = true;
}
}
The PlayMode test waits one frame before checking the result:
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
public sealed class PlayerInitializerTests
{
[UnityTest]
public IEnumerator Start_AfterOneFrame_InitializesPlayer()
{
var gameObject = new GameObject("Player");
try
{
var initializer = gameObject.AddComponent<PlayerInitializer>();
yield return null;
Assert.That(initializer.IsInitialized, Is.True);
}
finally
{
Object.Destroy(gameObject);
}
}
}
yield return null waits until the next frame.
Object.Destroy is delayed until the end of the frame. try/finally ensures that destruction is requested even when an assertion fails. For fixtures that create multiple objects, or when destruction must complete before the next test, centralize cleanup in [UnityTearDown] and wait one frame.
Avoid arbitrary frame waits:
// Avoid this when there is no reason for exactly ten frames.
for (var i = 0; i < 10; i++)
{
yield return null;
}
Prefer a completion condition with an upper bound:
[UnityTest]
public IEnumerator Load_WhenStarted_CompletesWithinFrames()
{
var gameObject = new GameObject("Loader");
try
{
var loader = gameObject.AddComponent<TestLoader>();
loader.Begin();
const int maxFrames = 120;
for (var frame = 0; frame < maxFrames && !loader.IsCompleted; frame++)
{
yield return null;
}
Assert.That(
loader.IsCompleted,
Is.True,
"Loading did not complete within 120 frames.");
}
finally
{
Object.Destroy(gameObject);
}
}
Do not use a frame limit for work whose duration depends heavily on machine performance or real I/O. For those cases, inject the dependency or expose an explicit completion signal.
Choose Test or UnityTest by behavior
Use [Test] when the operation is synchronous and does not need frame progression:
[Test]
public void Calculate_WhenCalled_ReturnsExpected()
{
// Synchronous behavior
}
Use [UnityTest] when you need:
yield return null- coroutine progression
WaitForFixedUpdate- lifecycle changes across frames
- Unity-specific yield instructions
A test in a PlayMode assembly does not automatically require [UnityTest]. A normal [Test] is valid there when it completes synchronously.
Keep physics tests broad and deterministic
A physics test may wait for fixed updates:
[UnityTest]
public IEnumerator Rigidbody_AfterFixedUpdate_FallsDown()
{
var gameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
try
{
gameObject.AddComponent<Rigidbody>();
var initialY = gameObject.transform.position.y;
yield return new WaitForFixedUpdate();
yield return new WaitForFixedUpdate();
Assert.That(
gameObject.transform.position.y,
Is.LessThan(initialY));
}
finally
{
Object.Destroy(gameObject);
}
}
This verifies only the broad behavior: gravity moves the object downward. Exact coordinates after a fixed number of frames are fragile because they depend on Unity version, physics settings, fixed delta time, and floating-point behavior.
Prefer these strategies:
- use tolerances instead of exact floating-point equality
- verify direction or range rather than a precise final coordinate
- extract your own decision logic into pure C# tests
- set and restore physics settings explicitly
- isolate high-precision simulation in a dedicated suite
You do not need to retest Unity's physics engine. Test that your code configures it correctly and interprets its results correctly.
Test asynchronous code without real waiting
Unity Test Framework supports tests that return .NET Task.
using System.Threading.Tasks;
using NUnit.Framework;
public sealed class UserRepositoryTests
{
[Test]
public async Task LoadAsync_ExistingUser_ReturnsUser()
{
var repository = new FakeUserRepository();
repository.Add(new User(id: 10, name: "Alice"));
var user = await repository.LoadAsync(10);
Assert.That(user.Name, Is.EqualTo("Alice"));
}
}
Avoid real delays:
// Avoid this in an ordinary unit test.
await Task.Delay(5000);
Twenty five-second tests already add 100 seconds. Real network access also makes results depend on authentication, server data, and connectivity.
Most asynchronous tests should verify the result, error conversion, cancellation, state update, and duplicate-execution rules rather than elapsed time.
Inject external work behind an interface:
public interface IUserApi
{
Task<UserDto> GetAsync(int userId);
}
public sealed class UserService
{
private readonly IUserApi _api;
public UserService(IUserApi api)
{
_api = api;
}
public async Task<User> LoadAsync(int userId)
{
var dto = await _api.GetAsync(userId);
return new User(dto.Id, dto.Name);
}
}
Use a fake that completes immediately:
public sealed class FakeUserApi : IUserApi
{
private readonly UserDto _response;
public FakeUserApi(UserDto response)
{
_response = response;
}
public Task<UserDto> GetAsync(int userId)
{
return Task.FromResult(_response);
}
}
Then test conversion without network access:
[Test]
public async Task LoadAsync_ApiSucceeds_ConvertsDto()
{
var api = new FakeUserApi(new UserDto(10, "Alice"));
var service = new UserService(api);
var user = await service.LoadAsync(10);
Assert.That(user.Id, Is.EqualTo(10));
Assert.That(user.Name, Is.EqualTo("Alice"));
}
Keep real connectivity checks in a small integration suite with controlled credentials and environments.
Awaitable and UniTask require extra care
Unity 6 projects increasingly use Awaitable, while many existing projects use UniTask.
Separate two questions:
- What return type does the test method use?
- What asynchronous type does production code return?
For Unity Test Framework 1.6, prefer supported async Task test methods. Use [UnityTest] and IEnumerator for coroutine-style frame tests. Production code may return Awaitable or UniTask, but tests must be able to observe completion.
Being awaitable does not mean a workflow is safe in an EditMode test. Pure C# transformations and cancellation rules belong in EditMode. Operations based on Awaitable.NextFrameAsync, UniTask Yield or DelayFrame, scene loading, Addressables, and Unity object lifecycles should be tested in PlayMode or in a built Player.
Important constraints are that Unity Awaitable instances are pooled and must not be awaited twice; UniTask should also be treated as single-consumption unless its documented preservation mechanisms are used. PlayerLoop-dependent APIs can differ across EditMode, PlayMode, Player, and BatchMode. Return to the main thread before touching Unity APIs, avoid async void, UniTaskVoid, and .Forget() in testable inner APIs, and provide cancellation for operations that may not complete.
A production event handler may need fire-and-forget behavior. Keep the inner operation awaitable and test that inner method directly. Otherwise an exception or state change can occur after the test has already finished and leak into another test.
Also run PlayerLoop-dependent asynchronous tests in CI with -batchmode. Do not terminate the Editor before the test result XML and exit code are produced.
Inject time and randomness
Current time and randomness are common sources of flaky tests.
Avoid reading local time inside business logic:
public bool CanReceiveDailyBonus()
{
return DateTime.Now.Date > _lastReceivedAt.Date;
}
The result changes at a date boundary and may differ across CI time zones.
Make the clock explicit:
public interface IClock
{
DateTimeOffset UtcNow { get; }
}
public sealed class DailyBonusService
{
private readonly IClock _clock;
public DailyBonusService(IClock clock)
{
_clock = clock;
}
public bool CanReceive(DateTimeOffset lastReceivedAt)
{
return _clock.UtcNow.Date >
lastReceivedAt.ToUniversalTime().Date;
}
}
Use a fixed clock in tests:
public sealed class FixedClock : IClock
{
public FixedClock(DateTimeOffset utcNow)
{
UtcNow = utcNow.ToUniversalTime();
}
public DateTimeOffset UtcNow { get; }
}
[Test]
public void CanReceive_WhenUtcDateChanged_ReturnsTrue()
{
var clock = new FixedClock(
new DateTimeOffset(2026, 7, 26, 0, 0, 0, TimeSpan.Zero));
var service = new DailyBonusService(clock);
var result = service.CanReceive(
new DateTimeOffset(2026, 7, 25, 23, 59, 59, TimeSpan.Zero));
Assert.That(result, Is.True);
}
The actual product specification must also define whether the source of truth is UTC, server time, or a region-specific reset time.
Randomness should be injected in the same way:
public interface IRandom
{
int Range(int minInclusive, int maxExclusive);
}
A fake can return a chosen value so rare drops and critical-hit branches are deterministic. This also helps replay systems, debugging, and synchronization with a server.
A mock library is optional
Do not introduce a mocking framework merely because an article about testing mentions mocks. A handwritten fake is often clearer in Unity projects.
public sealed class RecordingAnalytics : IAnalytics
{
public List<string> Events { get; } = new();
public void Send(string eventName)
{
Events.Add(eventName);
}
}
[Test]
public void Purchase_WhenSucceeded_SendsAnalyticsEvent()
{
var analytics = new RecordingAnalytics();
var service = new PurchaseService(analytics);
service.Complete();
Assert.That(analytics.Events, Does.Contain("purchase_completed"));
}
Handwritten fakes are easy to inspect, debug, and keep compatible across Unity and .NET versions. A mocking library becomes useful when many dependencies require extensive call and argument verification.
If mock setup dominates the test, the production class may have too many responsibilities.
Test exceptions and logs
For synchronous exceptions, use Assert.Throws:
[Test]
public void Constructor_MaxHpIsZero_ThrowsArgumentOutOfRangeException()
{
Assert.Throws<ArgumentOutOfRangeException>(
() => new Player(maxHp: 0));
}
Be careful with Assert.ThrowsAsync in Unity. Unity's documentation warns that blocking the caller while an operation needs the main thread can freeze the Editor. A safe pattern is to await in an async Task test and inspect the exception with try/catch:
[Test]
public async Task LoadAsync_WhenApiFails_ThrowsUserLoadException()
{
try
{
await _service.LoadAsync(userId: 10);
Assert.Fail("UserLoadException was not thrown.");
}
catch (UserLoadException exception)
{
Assert.That(exception.ErrorCode, Is.EqualTo("network_error"));
}
}
For failures that are part of normal control flow, a result type can be clearer than exceptions.
Unity normally fails a test when an unexpected error or exception is logged. Register expected logs explicitly:
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
[Test]
public void Load_InvalidId_LogsError()
{
LogAssert.Expect(LogType.Error, "Invalid user id: -1");
var loader = new UserLoader();
loader.Load(-1);
}
Use a regular expression for dynamic values:
using System.Text.RegularExpressions;
LogAssert.Expect(
LogType.Error,
new Regex(@"Invalid user id: -?\d+"));
Do not use logs as the only failure signal when callers must react. Return a result, throw a meaningful exception, or expose a state that can be asserted.
Isolate state with SetUp and TearDown
Every test must behave the same alone and as part of the full suite.
public sealed class PlayerControllerTests
{
private GameObject _gameObject;
private PlayerController _controller;
[SetUp]
public void SetUp()
{
_gameObject = new GameObject("Player");
_controller = _gameObject.AddComponent<PlayerController>();
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_gameObject);
}
[Test]
public void SetSpeed_PositiveValue_UpdatesSpeed()
{
_controller.SetSpeed(5f);
Assert.That(_controller.Speed, Is.EqualTo(5f));
}
}
In PlayMode, destruction is delayed. Use [UnityTearDown] when you must destroy multiple objects and wait for completion:
private readonly List<GameObject> _createdObjects = new();
[UnityTearDown]
public IEnumerator TearDown()
{
foreach (var gameObject in _createdObjects)
{
if (gameObject != null)
{
Object.Destroy(gameObject);
}
}
_createdObjects.Clear();
yield return null;
}
Record each created object immediately after creation so cleanup still occurs after an assertion failure.
Common leaks include static fields, singletons, PlayerPrefs, time and physics settings, Random.state, scenes, temporary assets, event subscriptions, cancellation sources, Addressables handles, and DontDestroyOnLoad objects.
Save global settings before changing them and restore them in teardown:
private float _originalTimeScale;
[SetUp]
public void SetUp()
{
_originalTimeScale = Time.timeScale;
Time.timeScale = 2f;
}
[TearDown]
public void TearDown()
{
Time.timeScale = _originalTimeScale;
}
Never depend on test order
A suite that assumes this order is fragile:
1. CreateUser
2. UpdateUser
3. DeleteUser
UpdateUser fails when run by itself. Each test should create and remove its own state:
[Test]
public void UpdateUser_ExistingUser_ChangesName()
{
var repository = new InMemoryUserRepository();
repository.Add(new User(10, "Before"));
repository.Update(new User(10, "After"));
Assert.That(repository.Find(10).Name, Is.EqualTo("After"));
}
Long end-to-end flows can be useful, but treat them as a separate scenario layer rather than chaining unit tests.
Write tests that survive refactoring
Test observable behavior instead of implementation details.
Private methods are implementation details; extract a separate responsibility when it deserves direct testing. Call counts alone do not prove player-visible behavior, so combine interactions with output or state assertions. Avoid hard-coding Transform hierarchy details: a UI test tied to a path such as this breaks during harmless cleanup:
Canvas/Root/Window/Content/Panel/Buttons/Button_01
Assert identifiers, components, and visible states that are part of the specification, not child indices that exist only for implementation convenience.
Wait for completion, not elapsed time
Prefer a completion flag, event, or task over:
yield return new WaitForSeconds(3f);
Keep whole-scene tests rare
Scene tests are valuable but expensive to prepare and diagnose. Build a pyramid of pure logic tests, prefab-level tests, and a small number of scene-level scenarios.
Testable design does not mean removing MonoBehaviour
MonoBehaviour is the correct place to receive Unity events, hold Inspector references, and control GameObjects. The problem is placing game rules, storage, networking, time, randomness, and presentation logic in the same component.
A useful division is:
PlayerPresenter : MonoBehaviour
├── receives input and lifecycle events
├── updates the View
└── calls PlayerUseCase
PlayerUseCase : Pure C#
├── damage and item rules
├── state transitions
└── calls repositories
Cover PlayerUseCase with many fast EditMode tests. Give PlayerPresenter a small number of tests that verify wiring between Unity events, the use case, and the view.
A dependency-injection container is not required. Constructors, initialization methods, serialized references, or factories are sufficient when they make dependencies explicit.
Introduce tests into an existing project incrementally
Do not begin by redesigning the entire codebase.
1. Select frequently changed, high-impact rules
Good first targets include:
- paid-item grants
- save-data migration
- daily reset logic
- stamina recovery
- damage formulas
- reward selection
- API response conversion
- master-data validation
2. Add characterization tests
When the intended specification is unclear, record the current input and output before refactoring. A characterization test does not claim that the existing implementation is ideal. It prevents accidental behavior changes while you improve it.
3. Cut one Unity-dependent boundary
Extract the dependency that blocks testing most: time, randomness, networking, or file I/O. There is no need to convert the entire project to a new architecture at once.
4. Add a regression test with each bug fix
When possible:
- reproduce the bug with a failing test
- fix the code
- confirm that the test passes
5. Standardize new and modified code first
A rule that new features and changed high-risk code receive tests is more sustainable than trying to cover every legacy class immediately.
Decide what not to test
More tests do not automatically mean higher quality.
Do not retest Unity's own APIs or trivial properties without custom rules. Pixel-perfect visuals, animation appeal, input feel, thermal behavior, and device memory need QA, screenshots, performance tests, or device runs. Avoid detailed tests for short-lived prototypes, but still cover complex calculations. Derive expectations from specification examples rather than copying the production formula into the test.
Run tests in CI
Once tests matter, manual execution is not enough.
A conceptual Windows command for EditMode tests is shown below. The example assumes that CI provides the path to the project-pinned Unity Editor through an environment variable named UNITY_EDITOR_PATH.
$UnityPath = $env:UNITY_EDITOR_PATH
if ([string]::IsNullOrWhiteSpace($UnityPath) -or
-not (Test-Path $UnityPath))
{
throw "UNITY_EDITOR_PATH is not configured correctly."
}
& $UnityPath `
-runTests `
-batchmode `
-projectPath "D:\Projects\MyGame" `
-testPlatform EditMode `
-testResults "D:\TestResults\editmode-results.xml" `
-logFile "D:\TestResults\editmode-editor.log"
Run PlayMode separately:
& $UnityPath `
-runTests `
-batchmode `
-projectPath "D:\Projects\MyGame" `
-testPlatform PlayMode `
-testResults "D:\TestResults\playmode-results.xml" `
-logFile "D:\TestResults\playmode-editor.log"
UNITY_EDITOR_PATH is not a Unity-reserved name. It is only an example of how to provide the exact Editor executable chosen by your project.
At minimum, retain:
- test-result XML
- Editor log
- Unity process exit code
- Unity Editor version
- branch and commit hash
Layer the pipeline: pull requests run compilation, fast EditMode tests, critical validation, and a small PlayMode smoke suite; main-branch builds run the full Editor suite and asset checks; nightly or release jobs run built-Player, target-platform, long-scenario, and performance tests. Do not put every slow test on every pull request.
Also avoid adding -nographics blindly when tests require rendering or a GPU-dependent path.
Use categories as CI execution units
Categories are useful only when they are connected to actual workflows.
[Category("Fast")]
[Test]
public void Calculate_ReturnsExpected()
{
}
[Category("AssetValidation")]
[Test]
public void ValidateAllAssets()
{
}
[Category("Integration")]
[UnityTest]
public IEnumerator LoginFlow_Completes()
{
yield return null;
}
Run the Fast EditMode category in CI:
& $UnityPath `
-runTests `
-batchmode `
-projectPath "D:\Projects\MyGame" `
-testPlatform EditMode `
-testCategory "Fast" `
-testResults "D:\TestResults\fast-editmode-results.xml" `
-logFile "D:\TestResults\fast-editmode-editor.log"
Unity Test Framework also supports filtering by test assembly with -assemblyNames. Check the command-line reference for the exact syntax supported by your Unity version, including multiple categories and exclusions.
Keep the category list small and aligned with pipeline stages. Examples include Fast, Integration, Scene, AssetValidation, RequiresGraphics, RequiresDevice, and Performance.
Do not leave [Ignore] without a reason. Record the issue, reason, and condition for re-enabling the test.
Do not optimize for 100% coverage
Coverage shows which lines ran, not whether important behavior was asserted. Prioritize rules such as preventing duplicate paid-item grants, preserving save data during migration, enforcing daily-reward and currency caps, and avoiding duplicate requests during retries. Use coverage to find suspicious gaps, not as the sole performance target.
Monitor test duration
Developers stop running slow suites. Watch for EditMode tests that take seconds, repeated full AssetDatabase scans, unnecessary scene loads, real services, arbitrary waits, and expensive setup. Separate fast and slow suites, record duration in CI, and never improve speed by sharing mutable state between tests.
Common failure patterns
Avoid five recurring mistakes:
- Do not write every test in PlayMode; keep game rules in pure C# where possible.
- Do not add test-only branches to production code; inject alternate dependencies instead.
- Do not depend on the scene that happened to be open when Test Runner started.
- Do not hide unexpected errors with broad
LogAssert.ignoreFailingMessagesor retries. - Review test code for weak assertions, arbitrary waits, and cleanup leaks.
A minimal team policy
A small rule set is enough to begin:
- Prefer EditMode for game rules and use
[UnityTest]only for frame progression. - Make tests independent and clean up objects, assets, events, and global state.
- Replace real time, real networks, current time, and uncontrolled randomness with explicit dependencies.
- Add regression tests for important bug fixes.
- Run a fast suite on pull requests and record why any test is ignored.
- Explicitly decide which concerns remain in QA, device, or performance testing.
Measure whether tests reduce regression risk and verification time, not merely how many tests exist.
Recommended adoption priority
Start with save migration, paid-item grants, response conversion, date rules, reward and currency calculations, caps, duplicate IDs, and recurring bugs. Next, add prefab, scene, Addressables, localization, presenter, state-machine, and analytics validation. Visual appeal, input feel, GPU behavior, device heat, memory pressure, long sessions, and multi-device communication need QA, performance, screenshot, or scenario testing rather than ordinary unit tests.
Unreal Engine uses a different stack—Automation Framework, Automation Spec, Functional Testing, and Gauntlet—so it is better treated in a separate article.
Conclusion
Unity testing is not about reproducing the entire game in every test. Cover game rules and data transformations with fast EditMode tests, validate assets with focused editor checks, and use PlayMode only where lifecycle events, frames, scenes, or physics are part of the behavior.
For asynchronous work, inject external services, clocks, randomness, storage, and cancellation so tests can observe completion without real waiting. Existing projects can start with high-impact rules, recurring bugs, save migration, and static asset validation instead of an immediate rewrite.
The useful metric is whether the team can change the game and verify critical behavior quickly. Unity Test Framework is a programmer's safety net, not a replacement for QA.
References
- Unity Manual: Testing your code
- Unity Manual: Edit mode and Play mode tests
- Unity Manual: Create a test assembly
- Unity Manual: Create a test
- Unity Manual: Asynchronous tests
- Unity Manual: Introduction to asynchronous programming with Awaitable
- Unity Manual: Awaitable completion and continuation
- Unity Scripting API: GameObjectUtility.GetMonoBehavioursWithMissingScriptCount
- Unity Test Framework: UnityTearDownAttribute
- UniTask
- Unity Manual: Running tests
- Unity Manual: Run tests from the command line
- Unity Manual: Command-line reference
- Unity Test Framework: LogAssert
- Unity Test Framework 1.6 changelog
Top comments (0)