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:
- Does the damage formula calculate correctly?
- Does completing a quest grant the right rewards?
- Does removing a friend update the friends list?
- Does the save system persist all state changes?
- Does the inventory respect capacity limits?
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:
- The production interface - what the production code sees
- 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:
- Visual appearance - whether the health bar looks right, whether animations play smoothly. These require visual inspection or screenshot testing.
- Engine behavior - whether Unity’s physics system works correctly. That’s the engine’s job.
- Feel and tuning - whether a jump feels good, whether the camera movement is smooth. These are subjective and need playtesting.
- Glue code - the thin adapter layer that bridges your architecture to the engine (e.g., a MonoBehaviour that forwards button clicks to a controller event). If it’s truly thin and mechanical, testing it adds little value.
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:
- Confidence in refactoring - change a system’s internals, run the tests, know immediately if behavior changed
- Regression prevention - bugs caught by tests don’t recur
- Living documentation - tests describe how the system works, and they’re always up to date (unlike documents)
- Faster development - catching bugs in tests (milliseconds) is faster than catching them in playtesting (minutes to hours)
- Onboarding - new team members read tests to understand expected behavior
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:
- Write tests for Core logic first - pure domain rules with no dependencies
- Add use case tests - inject test doubles for ports, verify orchestration
- Add integration tests - use the dual-interface pattern to simulate UI flows
- 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.