Unity Game Testing: Unit Tests, Play Mode, and Integration Testing
Game testing in Unity isn't just clicking through levels — it's a discipline with proper unit tests, integration tests, and automated CI pipelines. The Unity Test Framework (UTF) gives you a test runner inside the Unity Editor that integrates with standard test tooling.
The Unity Test Framework
UTF supports two test modes:
Edit Mode tests run without entering Play Mode. They're fast (no scene loading overhead) and best for testing pure logic — damage calculations, inventory math, pathfinding algorithms.
Play Mode tests run in an actual Unity scene. They're slower but can test MonoBehaviour lifecycle, physics, coroutines, and rendering.
Setting Up UTF
Window → General → Test Runner → Enable Play Mode TestsAdd the test package via Package Manager:
com.unity.test-frameworkYour test assembly definition (Tests.asmdef) should reference UnityEngine.TestRunner and UnityEditor.TestRunner.
Edit Mode Tests
// Tests/EditMode/PlayerStatTests.cs
using NUnit.Framework;
using YourGame.Stats;
[TestFixture]
public class PlayerStatTests
{
[Test]
public void DamageReducedByArmor()
{
var stats = new PlayerStats(maxHealth: 100, armor: 10);
stats.TakeDamage(rawDamage: 50);
// 50 - 10 armor = 40 damage
Assert.AreEqual(60, stats.CurrentHealth);
}
[Test]
public void HealthCannotGoBelowZero()
{
var stats = new PlayerStats(maxHealth: 100, armor: 0);
stats.TakeDamage(rawDamage: 999);
Assert.AreEqual(0, stats.CurrentHealth);
Assert.IsFalse(stats.IsAlive);
}
[Test]
public void HealingCannotExceedMaxHealth()
{
var stats = new PlayerStats(maxHealth: 100, armor: 0);
stats.TakeDamage(rawDamage: 30);
stats.Heal(amount: 999);
Assert.AreEqual(100, stats.CurrentHealth);
}
// Parameterized test
[TestCase(10, 5, 95)] // 10 damage, 5 armor → 5 net → 95 HP
[TestCase(100, 0, 0)] // 100 damage, 0 armor → 100 net → 0 HP
[TestCase(5, 10, 100)] // 5 damage, 10 armor → 0 net (no negative damage)
public void DamageCalculation(int rawDamage, int armor, int expectedHealth)
{
var stats = new PlayerStats(maxHealth: 100, armor: armor);
stats.TakeDamage(rawDamage);
Assert.AreEqual(expectedHealth, stats.CurrentHealth);
}
}Testing Without MonoBehaviour Dependencies
Separate your game logic from Unity components to make it testable in Edit Mode:
// Logic class (no MonoBehaviour — testable in Edit Mode)
public class InventorySystem
{
private readonly int _maxSlots;
private readonly Dictionary<string, int> _items = new();
public InventorySystem(int maxSlots = 20)
{
_maxSlots = maxSlots;
}
public bool TryAddItem(string itemId, int quantity)
{
if (_items.Count >= _maxSlots && !_items.ContainsKey(itemId))
return false;
_items[itemId] = _items.GetValueOrDefault(itemId, 0) + quantity;
return true;
}
public int GetQuantity(string itemId) => _items.GetValueOrDefault(itemId, 0);
public int SlotCount => _items.Count;
}// Test
[Test]
public void InventoryRejectsItemsWhenFull()
{
var inventory = new InventorySystem(maxSlots: 2);
Assert.IsTrue(inventory.TryAddItem("sword", 1));
Assert.IsTrue(inventory.TryAddItem("shield", 1));
Assert.IsFalse(inventory.TryAddItem("potion", 1)); // Full
Assert.AreEqual(2, inventory.SlotCount);
}Play Mode Tests
Play Mode tests run with the full Unity lifecycle — Start(), Update(), physics, coroutines, and all.
// Tests/PlayMode/PlayerMovementTests.cs
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
public class PlayerMovementTests
{
private GameObject _playerGO;
private PlayerController _player;
[SetUp]
public void SetUp()
{
// Create a minimal player for testing
_playerGO = new GameObject("TestPlayer");
_playerGO.AddComponent<CharacterController>();
_player = _playerGO.AddComponent<PlayerController>();
_player.MoveSpeed = 5f;
}
[TearDown]
public void TearDown()
{
Object.DestroyImmediate(_playerGO);
}
[UnityTest]
public IEnumerator PlayerMovesForwardOverTime()
{
Vector3 startPos = _playerGO.transform.position;
// Simulate pressing forward for 1 second
_player.SimulateInput(new Vector2(0, 1));
yield return new WaitForSeconds(1f);
_player.SimulateInput(Vector2.zero);
Vector3 endPos = _playerGO.transform.position;
float distance = Vector3.Distance(startPos, endPos);
Assert.Greater(distance, 4f, "Player moved less than expected in 1 second");
Assert.Less(distance, 6f, "Player moved more than expected in 1 second");
}
[UnityTest]
public IEnumerator PlayerCannotWalkThroughWalls()
{
// Create a wall
var wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.position = new Vector3(2f, 0f, 0f);
_player.SimulateInput(new Vector2(1, 0)); // Move right (toward wall)
yield return new WaitForSeconds(2f);
// Player should not have passed through the wall
Assert.Less(_playerGO.transform.position.x, 1.5f,
"Player passed through wall");
Object.DestroyImmediate(wall);
}
}Mocking with NSubstitute
Pure C# interfaces can be mocked in Edit Mode tests:
// Install NSubstitute via NuGet or Unity Package Manager
using NSubstitute;
using NUnit.Framework;
public interface IQuestService
{
bool CompleteQuest(string questId);
int GetQuestReward(string questId);
}
[Test]
public void PlayerReceivesRewardOnQuestCompletion()
{
var questService = Substitute.For<IQuestService>();
questService.CompleteQuest("dragon-slayer").Returns(true);
questService.GetQuestReward("dragon-slayer").Returns(500);
var player = new PlayerCharacter(questService: questService);
player.CompleteQuest("dragon-slayer");
Assert.AreEqual(500, player.Gold);
questService.Received(1).CompleteQuest("dragon-slayer");
}Testing Game Systems
Save System Tests
[Test]
public void SaveAndLoadPreservesPlayerState()
{
var saveSystem = new SaveSystem(new InMemorySaveStorage());
var originalPlayer = new PlayerState
{
Level = 15,
Experience = 3200,
CurrentHealth = 75,
Gold = 1250,
Position = new Vector3(10, 0, -5)
};
saveSystem.Save(originalPlayer);
var loadedPlayer = saveSystem.Load();
Assert.AreEqual(originalPlayer.Level, loadedPlayer.Level);
Assert.AreEqual(originalPlayer.Gold, loadedPlayer.Gold);
Assert.AreEqual(originalPlayer.Position, loadedPlayer.Position);
}AI Behavior Tests
[UnityTest]
public IEnumerator EnemyAIDetectsPlayerWithinRange()
{
var enemy = SetupEnemy(detectionRange: 10f);
var player = SetupPlayer();
// Place player outside detection range
player.transform.position = new Vector3(15f, 0f, 0f);
yield return new WaitForSeconds(0.5f);
Assert.AreEqual(EnemyState.Idle, enemy.CurrentState);
// Move player within range
player.transform.position = new Vector3(5f, 0f, 0f);
yield return new WaitForSeconds(0.5f);
Assert.AreEqual(EnemyState.Chasing, enemy.CurrentState);
}CI Integration
# .github/workflows/unity-tests.yml
name: Unity Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true # Required for Unity assets
- uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
projectPath: .
testMode: editmode
artifactsPath: test-results
githubToken: ${{ secrets.GITHUB_TOKEN }}
- uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
testMode: playmode
artifactsPath: test-results/playmode
- uses: actions/upload-artifact@v4
if: always()
with:
name: Test results
path: test-resultsUnity testing requires discipline to keep logic separated from MonoBehaviours. The payoff: a fast Edit Mode test suite you can run in seconds and a Play Mode suite that validates scene behavior. Together, they catch regressions before they reach players.