Back to Blog
Game DevelopmentApril 9, 202611 min read

Unit Tests in Game Dev. Yes, I Use Them and It's Worth It

DR

Doug Ribeiro

11 min read

Ask most game developers whether they write unit tests and you will get a laugh, a dismissive wave, or a story about how they tried it once and it was not worth the overhead. The conventional wisdom in game development is that testing does not apply. Games are inherently visual, stateful, and hard to isolate. You just play the game. You just iterate and feel it.

My tactical RPG project runs on Godot 4.5 with C#, and when I counted in February 2026 it had 334+ tests across unit, integration and performance suites. I add new ones with almost every feature. And I am here to tell you that not only is game unit testing possible, it has been one of the best architectural decisions I made on this project.

Coming from over a decade of front-end development, most of it in Angular, where testing culture is more mature, I could not imagine shipping something complex without a test harness. When I started the game project, I brought that discipline with me. What I discovered is that game logic, the parts that actually need to be correct, is extremely well-suited to unit testing. The parts that are not testable are exactly the parts you would not want to test anyway.

Why Game Developers Skip Tests

The objections are real, and I want to take them seriously. The most common one is that games are hard to isolate. A unit test should test one thing in isolation, but in games, everything seems connected. The player's movement depends on the physics engine. The AI depends on pathfinding. The damage calculation depends on the active effects, which depend on the equipment, which depends on the character state.

The second objection is that games change fast. The design iterates constantly. What is the point of writing tests for damage calculations when the designer might completely overhaul the combat system next week? Tests become maintenance burden when the underlying design is unstable.

The third objection is the rendering problem. How do you test that a shader looks right? How do you test that an animation transition feels smooth? How do you test that the camera follows the unit in a satisfying way? You cannot. These things require human judgment and real playback.

All three objections are valid. None of them mean you should not write tests. They mean you should be deliberate about what you test.

What Is Actually Worth Testing in a Game

The answer is everything that is pure logic. If a function takes inputs and produces outputs without rendering anything, without depending on the game loop, without requiring a live scene, it is testable. And in a well-architected game, there is a lot of that.

  • Damage calculations. Does a critical hit do 150% damage after armor reduction?
  • Status effect stacking. Do two poison effects stack additively or take the higher value?
  • Movement range calculation. Does a unit with 3 movement in difficult terrain reach the correct cells?
  • AI decision scoring. Does the AI correctly prefer a flanking position over a direct attack when health is low?
  • State machine transitions. Can a stunned unit not take actions regardless of player input?
  • Save/load serialization. Does a serialized game state deserialize to the exact same values?
  • Pathfinding correctness. Does A* return the optimal path on a known grid?
  • Buff/debuff duration tracking. Does a 2-turn buff correctly expire after 2 turns?
  • Line of sight calculations. Does a unit behind a wall correctly have no line of sight?
Test These (Pure Logic)DamageCalculator: stat formulasBattleSystem: turn order, win c…AiScorer: decision weightsPathFinder: grid traversalLootTable: drop rate distributi…StatusEffect: duration and stac…Skip These (Engine-Dependent)Rendering / shader outputPhysics collision responseAnimation blend treesAudio playback timingPlayer input feel / responsiven…UI layout and visual polishVS

This is not a short list. And every item on it has caused at least one shipped bug in a game I have played that clearly had no test coverage. Damage calculations that do not round correctly. Status effects that never expire. AI that freezes when two equally good options exist. These are all pure logic bugs that a unit test would have caught in seconds.

GdUnit4 and xUnit: Two Frameworks, One Line Between Them

I run two frameworks, and the line between them is the engine. Anything that touches a node, a scene or a Resource has to run under GdUnit4, because it needs a live Godot process. Pure C# logic can run under either, and a good part of mine runs under xUnit with nothing but dotnet test. The examples below are GdUnit4, because that is the one that covers the whole surface. It integrates directly into the Godot editor, runs tests from within the editor or from the command line, and supports both GDScript and C#. For C#, it uses an attribute-based approach that feels familiar to anyone who has used xUnit or NUnit.

Setting it up is straightforward. You install the GdUnit4 plugin through the Godot AssetLib, enable it in project settings, and then create test files following the naming convention. For C#, you also add the gdUnit4 NuGet packages (gdUnit4.api and the test adapter) to your .csproj, and your test classes are plain classes marked with the [TestSuite] attribute.

csharp
using GdUnit4;
using static GdUnit4.Assertions;

// StatBlock is a plain C# DTO, not the UnitStats Resource the game ships.
// Nothing here is a Godot type, so these tests need no [RequireGodotRuntime].
[TestSuite]
public class DamageCalculatorTests
{
    private DamageCalculator _calculator;

    [BeforeTest]
    public void Setup()
    {
        _calculator = new DamageCalculator();
    }

    [TestCase]
    public void BaseDamage_NoModifiers_ReturnsBaseValue()
    {
        var attacker = new StatBlock { Attack = 10 };
        var defender = new StatBlock { Defense = 0 };

        int damage = _calculator.Calculate(attacker, defender);

        AssertInt(damage).IsEqual(10);
    }

    [TestCase]
    public void Defense_ReducesDamage_ByFlatAmount()
    {
        var attacker = new StatBlock { Attack = 10 };
        var defender = new StatBlock { Defense = 3 };

        int damage = _calculator.Calculate(attacker, defender);

        AssertInt(damage).IsEqual(7);
    }

    [TestCase]
    public void CriticalHit_MultipliesDamage_By150Percent()
    {
        var attacker = new StatBlock { Attack = 10, CriticalRate = 1.0f };
        var defender = new StatBlock { Defense = 0 };

        int damage = _calculator.Calculate(attacker, defender, forceCrit: true);

        AssertInt(damage).IsEqual(15);
    }

    [TestCase]
    public void Damage_NeverGoesBelow_One()
    {
        var attacker = new StatBlock { Attack = 1 };
        var defender = new StatBlock { Defense = 100 };

        int damage = _calculator.Calculate(attacker, defender);

        AssertInt(damage).IsGreaterEqual(1);
    }
}

Testing State Machines Without the Scene Tree

The hardest part of Godot testing is that many game objects are Nodes. They expect to live in a scene tree. If you try to instantiate a Node subclass in a unit test without a tree, you get null reference errors everywhere. The solution is to design your core logic as plain C# classes that have no Godot dependencies, and keep the Node subclasses as thin wrappers that delegate to those plain classes.

This is actually great architecture advice regardless of testing. If your BattleManager node contains 800 lines of game logic, it is a God Node and it is untestable. If your BattleManager node is a thin Godot wrapper over a pure C# BattleSystem class, you can test the BattleSystem directly without ever touching Godot.

csharp
// Pure C# class, no Godot dependencies, fully testable
public class BattleSystem
{
    private readonly List<Unit> _units;
    private BattlePhase _phase;
    private int _currentUnitIndex;

    public BattleSystem(List<Unit> units)
    {
        _units = units;
        _phase = BattlePhase.PlayerSelection;
        _currentUnitIndex = 0;
    }

    public Unit CurrentUnit => _units[_currentUnitIndex];
    public BattlePhase Phase => _phase;

    public Result<Unit> TrySelectUnit(string unitId)
    {
        if (_phase != BattlePhase.PlayerSelection)
            return Result<Unit>.Failure("Not in selection phase");

        var unit = _units.FirstOrDefault(u => u.Id == unitId);
        if (unit == null)
            return Result<Unit>.Failure("Unit not found");

        if (!unit.IsPlayerControlled)
            return Result<Unit>.Failure("Cannot select enemy unit");

        _currentUnitIndex = _units.IndexOf(unit);
        _phase = BattlePhase.PlayerMovement;
        return Result<Unit>.Success(unit);
    }
}

// Godot Node, thin wrapper, no testable logic here
public partial class BattleManagerNode : Node
{
    private BattleSystem _battleSystem;

    public override void _Ready()
    {
        var units = GetChildren()
            .OfType<UnitNode>()
            .Select(n => n.UnitData)
            .ToList();
        _battleSystem = new BattleSystem(units);
    }

    public void OnUnitClicked(string unitId)
    {
        var result = _battleSystem.TrySelectUnit(unitId);
        if (result.IsSuccess)
            EmitSignal(SignalName.UnitSelected, unitId);
        else
            GD.Print(result.Error);
    }
}

Testing AI Decision Making

One of the most valuable test suites I have built is for the enemy AI system. The AI evaluates potential actions using a scoring system. Each possible move and attack is scored, and the AI picks the highest scorer. Pure logic. Pure math. Completely testable.

Without tests, AI bugs are the worst to debug. The AI does something unexpected, you watch it happen once, and then you spend 30 minutes trying to reproduce the exact game state that caused it. With tests, I can write a test case with the exact scenario: this enemy unit, at this position, with these health values, with these enemy positions, should score the flanking move highest. If it does not, the test fails immediately and I can fix the scoring function.

csharp
[TestSuite]
public class AiScorerTests
{
    [TestCase]
    public void FlankingPosition_ScoresHigher_ThanDirectAttack()
    {
        var scorer = new AiActionScorer();
        var enemy = new Unit { Health = 50, MaxHealth = 100, Attack = 8 };
        var target = new Unit { Health = 30, MaxHealth = 50, Defense = 4 };

        var directAttack = new ActionOption
        {
            Type = ActionType.Attack,
            IsFlank = false,
            Position = new Vector2I(3, 3)
        };

        var flankAttack = new ActionOption
        {
            Type = ActionType.Attack,
            IsFlank = true,
            Position = new Vector2I(4, 3)
        };

        float directScore = scorer.Score(enemy, target, directAttack);
        float flankScore = scorer.Score(enemy, target, flankAttack);

        AssertFloat(flankScore).IsGreater(directScore);
    }

    [TestCase]
    public void LowHealthEnemy_PrefersRetreat_OverAttack()
    {
        var scorer = new AiActionScorer();
        var enemy = new Unit { Health = 5, MaxHealth = 100, Attack = 8 };
        var target = new Unit { Health = 60, MaxHealth = 60, Defense = 2 };

        var attackOption = new ActionOption { Type = ActionType.Attack };
        var retreatOption = new ActionOption { Type = ActionType.Move, IsRetreat = true };

        float attackScore = scorer.Score(enemy, target, attackOption);
        float retreatScore = scorer.Score(enemy, target, retreatOption);

        AssertFloat(retreatScore).IsGreater(attackScore);
    }
}

How 334+ Tests Changed My Development Speed

Here is the counterintuitive truth about writing tests in a game project: it did not slow me down. It sped me up, but only after the first few months.

The first two months were slower. Writing tests takes time. Designing code so it is testable takes discipline. I had to refactor my architecture to separate pure logic from Godot node code, which added work I had not planned for. The test suite itself became something I had to maintain.

Then something shifted. Around the 100-test mark, I started noticing that I was spending almost no time debugging regressions. When I changed the damage formula to add a new weapon type, I ran the tests, saw a few failures, fixed those, and ran the tests again. Green, in minutes. Without tests, that same change would have meant careful manual playtesting to verify nothing broke.

The biggest time savings came from refactoring. I completely overhauled my status effect system halfway through development. It was a major architectural change. With the test suite watching my back, I was able to make that change in a weekend. Each test failure was a specific thing I had not updated. I worked through them systematically and finished with confidence that the new system was equivalent to the old one in every tested scenario.

Running Tests in CI. Yes, Even for a Solo Game Project

GdUnit4 supports headless test execution through the command line, which means you can run your game tests in a CI pipeline. I have a GitHub Actions workflow that runs the full test suite on every push to the main branch. The part no tutorial covers is that a GitHub runner has no Godot on it. The workflow downloads the Godot .NET build matching the version the project targets, unzips it onto the runner, and points the GODOT_BIN environment variable at that binary, which is what the gdUnit4 test adapter reads before it starts a headless run. GdUnit4 also ships a GitHub Action that does the download and setup for you if you would rather not maintain those steps yourself.

Is this overkill for a solo project? Possibly. But the habit of having CI guard your main branch is so deeply ingrained in me from professional development that working without it feels uncomfortable. More practically: it has caught several regressions that I introduced by pushing a quick fix without manually retesting everything.

What I Still Do Not Test (And Why)

I want to be honest about the limits. I do not test anything that requires the scene tree to be populated and running. I do not test rendering, shaders, animations, or physics. I do not test player feel: whether attacking feels satisfying, whether movement feels responsive. These require human evaluation and real gameplay.

I also do not test design-unstable systems. If the designer (me) has not committed to how a system works, I do not write tests for it yet. Tests against a moving target become maintenance overhead. I wait until I have a clear spec, then write the test first, then implement.

This aligns with how I think about testing in Angular. You do not test your template markup pixel by pixel. You test the logic that drives the template. You test the services that transform data. You test the state machines that govern component behavior. The rendering layer is for integration and end-to-end tests, not unit tests.

The Takeaway

If you are a game developer who has written off unit testing, I would ask you to try one experiment. Pick the most complex calculation in your game. Damage, resource generation, cooldown management, whatever. Write a pure C# or GDScript class that implements just that calculation with no Godot dependencies. Write five test cases for it. Run them.

I am willing to bet you will find at least one bug you did not know you had. And I am willing to bet you will keep writing tests after that.

The test suite is not a burden. It is the only thing that lets you refactor fearlessly. And in game development, you will refactor everything at least twice.

References

Game DevelopmentGodotC#Testing