Automated Game Testing with Unity Test Framework: A Complete Guide
Game testing is often manual — play through levels, look for bugs. But most game logic is testable code: inventory systems, physics interactions, AI decisions, save/load functionality. The Unity Test Framework brings standard unit and integration testing to game development.
This guide covers how to write meaningful automated tests for Unity games, from pure logic unit tests to full gameplay integration tests.
Unity Test Framework Overview
Unity Test Framework (UTF) uses NUnit under the hood and provides two test modes:
- EditMode tests — run in the Editor, no game loop, fast. Best for pure C# logic.
- PlayMode tests — run with a full game loop (Update, FixedUpdate, coroutines). Best for gameplay behavior.
Set up test assemblies:
Assets/
Scripts/
Player/
PlayerController.cs
InventorySystem.cs
Tests/
EditMode/
Tests.asmdef ← EditMode test assembly definition
InventoryTests.cs
PlayMode/
Tests.asmdef ← PlayMode test assembly definition
PlayerMovementTests.csThe Tests.asmdef (assembly definition) file:
{
"name": "Tests.EditMode",
"references": ["UnityEngine.TestRunner", "UnityEditor.TestRunner"],
"optionalUnityReferences": ["TestAssemblies"],
"includePlatforms": ["Editor"],
"testPlatforms": ["EditMode"]
}EditMode Tests: Pure Logic
EditMode tests don't need a scene — ideal for business logic:
using NUnit.Framework;
using UnityEngine;
[TestFixture]
public class InventorySystemTests
{
private InventorySystem inventory;
[SetUp]
public void SetUp()
{
inventory = new InventorySystem(maxSlots: 10);
}
[Test]
public void AddItem_WhenInventoryHasSpace_ReturnsTrue()
{
var sword = new Item("Iron Sword", ItemType.Weapon, weight: 5.0f);
bool added = inventory.AddItem(sword);
Assert.IsTrue(added);
Assert.AreEqual(1, inventory.ItemCount);
}
[Test]
public void AddItem_WhenInventoryFull_ReturnsFalse()
{
// Fill inventory
for (int i = 0; i < 10; i++)
{
inventory.AddItem(new Item($"Item {i}", ItemType.Misc, weight: 1.0f));
}
var overflow = new Item("Overflow Item", ItemType.Misc, weight: 1.0f);
bool added = inventory.AddItem(overflow);
Assert.IsFalse(added);
Assert.AreEqual(10, inventory.ItemCount);
}
[Test]
public void RemoveItem_WhenItemExists_RemovesFromInventory()
{
var potion = new Item("Health Potion", ItemType.Consumable, weight: 0.5f);
inventory.AddItem(potion);
bool removed = inventory.RemoveItem(potion.Id);
Assert.IsTrue(removed);
Assert.AreEqual(0, inventory.ItemCount);
}
[Test]
[TestCase(100, 50, 50)]
[TestCase(100, 150, 0)]
[TestCase(0, 10, 0)]
public void TakeDamage_ReducesHealthByAmount(int startHealth, int damage, int expectedHealth)
{
var stats = new CharacterStats(health: startHealth);
stats.TakeDamage(damage);
Assert.AreEqual(expectedHealth, stats.CurrentHealth);
}
[Test]
public void SaveGame_ThenLoadGame_RestoresInventoryState()
{
inventory.AddItem(new Item("Sword", ItemType.Weapon, weight: 5.0f));
inventory.AddItem(new Item("Shield", ItemType.Armor, weight: 8.0f));
string saveData = inventory.Serialize();
var loaded = InventorySystem.Deserialize(saveData);
Assert.AreEqual(inventory.ItemCount, loaded.ItemCount);
Assert.AreEqual("Sword", loaded.GetItemAt(0).Name);
}
}PlayMode Tests: Gameplay Behavior
PlayMode tests run with a real game loop — essential for physics, animations, and coroutines:
using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
public class PlayerMovementTests
{
private GameObject playerGO;
private PlayerController player;
[UnitySetUp]
public IEnumerator SetUp()
{
// Create minimal scene
playerGO = new GameObject("Player");
playerGO.AddComponent<Rigidbody2D>();
playerGO.AddComponent<BoxCollider2D>();
player = playerGO.AddComponent<PlayerController>();
player.moveSpeed = 5f;
yield return null; // Wait one frame for initialization
}
[UnityTearDown]
public IEnumerator TearDown()
{
Object.Destroy(playerGO);
yield return null;
}
[UnityTest]
public IEnumerator Player_MovesRight_WhenRightInputPressed()
{
Vector3 startPos = playerGO.transform.position;
// Simulate right input for 0.5 seconds
player.SimulateInput(new Vector2(1, 0));
yield return new WaitForSeconds(0.5f);
player.SimulateInput(Vector2.zero);
Assert.Greater(playerGO.transform.position.x, startPos.x,
"Player should have moved right");
}
[UnityTest]
public IEnumerator Player_CannotMoveThrough_SolidWall()
{
// Place wall to the right
var wall = GameObject.CreatePrimitive(PrimitiveType.Cube);
wall.transform.position = new Vector3(2, 0, 0);
Vector3 startPos = playerGO.transform.position;
// Try to move into wall for 2 seconds
player.SimulateInput(new Vector2(1, 0));
yield return new WaitForSeconds(2f);
player.SimulateInput(Vector2.zero);
// Player should be stopped by wall
Assert.Less(playerGO.transform.position.x, wall.transform.position.x,
"Player should not pass through wall");
Object.Destroy(wall);
}
[UnityTest]
public IEnumerator Player_PicksUpItem_WhenWalkingOver()
{
int initialGold = player.GoldAmount;
// Create gold coin item at player position
var coin = new GameObject("Gold Coin");
coin.AddComponent<CircleCollider2D>().isTrigger = true;
var goldItem = coin.AddComponent<CollectibleItem>();
goldItem.goldValue = 50;
coin.transform.position = playerGO.transform.position;
yield return new WaitForFixedUpdate();
Assert.AreEqual(initialGold + 50, player.GoldAmount,
"Player should have collected the gold coin");
Assert.IsTrue(coin == null || !coin.activeSelf,
"Coin should be destroyed after collection");
}
}Testing Game Systems
Test complex game systems like AI and economy:
[TestFixture]
public class EnemyAITests
{
[Test]
public void EnemyAI_AttacksPlayer_WhenInRange()
{
var ai = new EnemyAI(attackRange: 2.0f, detectionRange: 10.0f);
var state = ai.DecideAction(
enemyPosition: Vector3.zero,
playerPosition: new Vector3(1.5f, 0, 0), // Within attack range
playerHealth: 100,
enemyHealth: 100
);
Assert.AreEqual(AIAction.Attack, state);
}
[Test]
public void EnemyAI_Retreats_WhenLowHealth()
{
var ai = new EnemyAI(retreatHealthThreshold: 20);
var state = ai.DecideAction(
enemyPosition: Vector3.zero,
playerPosition: new Vector3(1f, 0, 0),
playerHealth: 100,
enemyHealth: 10 // Low health
);
Assert.AreEqual(AIAction.Retreat, state);
}
[Test]
public void WaveSpawner_SpawnsCorrectEnemyCount()
{
var spawner = new WaveSpawner();
spawner.Configure(new WaveConfig {
Wave1 = new[] { EnemyType.Goblin, EnemyType.Goblin, EnemyType.Orc },
Wave2 = new[] { EnemyType.Orc, EnemyType.Troll }
});
var wave1Enemies = spawner.GetWaveEnemies(waveNumber: 1);
Assert.AreEqual(3, wave1Enemies.Count);
Assert.AreEqual(2, wave1Enemies.Count(e => e == EnemyType.Goblin));
Assert.AreEqual(1, wave1Enemies.Count(e => e == EnemyType.Orc));
}
}Testing Save/Load Systems
Save/load bugs are common and test-worthy:
[TestFixture]
public class SaveSystemTests
{
[Test]
public void SaveAndLoad_PreservesPlayerPosition()
{
var gameState = new GameState();
gameState.PlayerPosition = new Vector3(15.5f, 2.3f, -8.1f);
gameState.PlayerLevel = 12;
gameState.GoldAmount = 1547;
string json = SaveSystem.Serialize(gameState);
var loaded = SaveSystem.Deserialize(json);
Assert.AreEqual(gameState.PlayerPosition, loaded.PlayerPosition);
Assert.AreEqual(gameState.PlayerLevel, loaded.PlayerLevel);
Assert.AreEqual(gameState.GoldAmount, loaded.GoldAmount);
}
[Test]
public void LoadGame_WithCorruptedSave_ReturnsDefaultState()
{
string corruptedJson = "{ invalid json {{{{";
var state = SaveSystem.Deserialize(corruptedJson);
Assert.IsNotNull(state, "Should return default state, not null");
Assert.AreEqual(Vector3.zero, state.PlayerPosition);
Assert.AreEqual(1, state.PlayerLevel);
}
}Running Tests in CI
name: Unity Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Unity Tests
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
with:
projectPath: .
testMode: all
coverageOptions: 'generateAdditionalMetrics;generateHtmlReport;generateBadgeReport'
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: Test Results
path: artifacts/Summary
Unity Test Framework makes game logic testable without clicking through your game manually. The pattern is:
- EditMode tests for data systems (inventory, stats, save/load, AI decisions)
- PlayMode tests for physics, input, and gameplay loops
- CI/CD with game-ci/unity-test-runner for automated runs on every commit
The biggest payoff is in systems that are painful to test manually: save/load edge cases, AI state machines, economy balance, and collision detection. Automate those first.