Practical

Unit And Integration Tests

How clean architecture unlocks testability - something nearly nonexistent in game dev, and transformative in practice.

The Uncomfortable Truth

Unit testing is standard practice in web development, mobile development, backend services, and virtually every other software domain. In game development, it’s nearly nonexistent.

This isn’t because game developers are lazy. It’s because most game codebases are untestable. When your game logic lives inside MonoBehaviours, depends on the engine’s runtime, accesses singletons, and is coupled to the rendering pipeline - there’s nothing to test in isolation. You’d need to boot the entire engine to verify that picking up a health potion adds 25 HP.

Clean Game Architecture changes this completely. When your game logic lives in the Core layer with zero engine dependencies, testing becomes not just possible but trivial.

What Becomes Testable

Domain Logic

The rules of your game - the pure calculations, state transitions, and validations - live in the Core layer. They have no engine dependencies. They operate on plain interfaces and value objects.

// Core/PlayerHealth.cs - pure domain logic
public class PlayerHealth : IPlayerHealth {
    private int _current;
    private int _max;

    public int CurrentHealth => _current;
    public int MaxHealth => _max;
    public bool IsAlive => _current > 0;

    public void TakeDamage(int amount) {
        _current = Math.Max(0, _current - amount);
    }

    public void Heal(int amount) {
        _current = Math.Min(_max, _current + amount);
    }
}

Testing this requires no engine, no framework, no setup:

[Test]
public void When_TakingDamage_Then_HealthDecreases() {
    var health = new PlayerHealth(currentHealth: 100, maxHealth: 100);
    health.TakeDamage(25);
    Assert.That(health.CurrentHealth, Is.EqualTo(75));
}

[Test]
public void When_HealingBeyondMax_Then_HealthCapsAtMax() {
    var health = new PlayerHealth(currentHealth: 80, maxHealth: 100);
    health.Heal(50);
    Assert.That(health.CurrentHealth, Is.EqualTo(100));
}

These tests run in milliseconds. They need no engine. They describe the game’s rules in executable form.

Use Case Orchestration

Use cases coordinate domain operations - “when the player completes a quest, grant rewards and update progress.” These involve multiple domain objects and ports, but because everything is behind interfaces, test doubles substitute cleanly.

[Test]
public async Task When_QuestCompleted_Then_RewardsAreGrantedAndProgressUpdated() {
    // Arrange - all dependencies are test doubles
    var questLog = new TestQuestLog();
    questLog.AddQuest("quest_01", reward: 100);

    var playerStats = new TestPlayerStats(xp: 0);
    var useCase = new CompleteQuestUseCase(questLog, playerStats);

    // Act
    await useCase.Execute("quest_01");

    // Assert
    Assert.That(questLog.IsCompleted("quest_01"), Is.True);
    Assert.That(playerStats.XP, Is.EqualTo(100));
}

No engine. No UI. No network. Just the business logic executing in a test harness.

Integration Between Features

Because features interact through Use Case interfaces, you can test feature integration without the full application:

[Test]
public async Task When_QuestRewardClaimedWithBonusActive_Then_DoubleRewardsGranted() {
    // Setup: register test doubles for UI components
    setup.RegisterComponent(typeof(QuestLogTestComponent));
    setup.RegisterComponent(typeof(RewardDialogTestComponent));

    // Precondition: player has a completed quest and an active bonus
    var questLog = setup.GetDependency<IQuestLog>();
    questLog.AddQuest("quest_01", reward: 100);
    questLog.MarkCompleted("quest_01");

    var bonusSystem = setup.GetDependency<IBonusSystem>();
    bonusSystem.ActivateDoubleRewards();

    // Simulate: user opens quest log, selects quest, claims reward
    var questLogTest = setup.FindComponent<IQuestLogTestComponent>();
    questLogTest.ClickOnQuest("quest_01");

    var rewardDialog = await setup.FindComponent<IRewardDialogTestComponent>();
    rewardDialog.TestController.ClickClaimReward();

    // Verify: domain state reflects doubled reward
    var playerStats = setup.GetDependency<IPlayerStats>();
    Assert.That(playerStats.XP, Is.EqualTo(200));   // 100 base x 2 bonus
    Assert.That(questLog.IsRewardClaimed("quest_01"), Is.True);
}

This test exercises a complete user journey - from UI action through use case orchestration to domain state change - without launching the game. The test doubles simulate the UI behavior while the real domain logic executes. Note how two features (quests and bonus system) interact through their Use Case interfaces, verified in a single integration test.

Why Games Haven’t Done This

”Testing games is different”

The argument is that games are creative, visual, feel-based - you can’t test whether a jump “feels right” with a unit test. This is true. But the vast majority of game logic is not about feel. It’s about rules, state, and correctness:

These are objective, verifiable behaviors. The “feel” argument is a red herring that has been used to excuse the absence of testing for logic that is completely testable.

Untestable Architecture

The real reason is architectural. When game logic is embedded in engine objects, testing requires the engine runtime. Setting up a test that verifies quest completion means instantiating GameObjects, initializing MonoBehaviours, simulating frames, and dealing with Unity’s execution order. It’s so painful that nobody does it.

Clean architecture removes this obstacle entirely. The Core layer has no engine dependency. It’s just classes and interfaces. Standard test frameworks work out of the box.

No Culture of Testing

Game teams don’t test because game teams have never tested. There are no established patterns, no widely-used test harnesses, no shared knowledge of how to test game logic. Each team would need to figure it out from scratch.

Clean Game Architecture provides the patterns: how to structure test doubles, how to simulate async UI flows, how to verify domain state changes. Once the architecture is clean, the testing patterns are straightforward.

The Dual-Interface Test Pattern

One pattern that emerges naturally from clean architecture is the dual-interface test double. A test component implements two interfaces:

  1. The production interface - what the production code sees
  2. The test interface - what the test code uses to simulate actions and inspect state
// Production code sees only this
interface IConfirmDialog {
    UniTask<bool> RequestConfirmation(string message);
}

// Test code can also use this
interface IConfirmDialogTest {
    void Confirm(bool result);
}

// Test double implements both
class ConfirmDialogTestDouble : IConfirmDialog, IConfirmDialogTest {
    private UniTaskCompletionSource<bool> _pending;

    public UniTask<bool> RequestConfirmation(string message) {
        _pending = new UniTaskCompletionSource<bool>();
        return _pending.Task;
    }

    public void Confirm(bool result) {
        _pending.SetResult(result);
    }
}

Production code calls RequestConfirmation() and awaits the result. Test code calls Confirm(true) or Confirm(false) to simulate the user’s choice. The production code never knows it’s running in a test. The test has full control over timing and outcomes.

This pattern is especially powerful for testing async UI flows - dialogs, popups, confirmation screens - without any engine dependency.

The Test Naming Convention

Tests should read as executable documentation. The When_Then pattern makes test intent clear without reading the test body:

When_TakingLethalDamage_Then_PlayerDies
When_HealingAtFullHealth_Then_HealthDoesNotExceedMax
When_AddingDuplicateFriend_Then_FriendsListDoesNotChange
When_QuestCompletedWithBonusActive_Then_DoubleRewardsGranted
When_SaveCorrupted_Then_DefaultStateIsLoaded

Each test name describes a scenario and its expected outcome. The test suite becomes a specification of how the game behaves - readable by developers, designers, and QA without looking at code.

What Not to Unit Test

Not everything should be unit tested. Specifically:

Focus testing on domain logic, use case orchestration, and feature integration - the areas where correctness matters and where bugs are most impactful.

The Compound Value

The value of unit tests compounds over time:

For game projects that will be maintained over months or years - live service games, games with DLC, games with seasonal content - the testing infrastructure pays for itself many times over.

Getting Started

If your project uses Clean Game Architecture, testing is straightforward:

  1. Write tests for Core logic first - pure domain rules with no dependencies
  2. Add use case tests - inject test doubles for ports, verify orchestration
  3. Add integration tests - use the dual-interface pattern to simulate UI flows
  4. Automate - run tests on every commit via CI/CD

The Architecture Model makes this possible. The Technical Toolkit provides the mechanisms. Testing is the proof that the architecture works.