Unity Game Testing: Unit Tests, Play Mode, and Integration Testing

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 Tests

Add the test package via Package Manager:

com.unity.test-framework

Your 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-results

Unity 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.

Read more