Chapter 21: Testing the Rules

The Core project is testable because it is boring

Chapter 1 made a promise. It said that keeping Core free of any MonoGame reference — writing your own Vec2 rather than using Vector2, refusing to take a GraphicsDevice, keeping the drawing layer out of the rules layer — would make the game's logic testable without a graphics device. Twenty chapters later, this is the chapter that collects on it.

The collection is undramatic, which is the point. FixedStepAccumulator takes a float and returns an int. SudokuRules.IsLegal takes a board and returns a bool. SaveSerializer.Deserialize takes a string and returns a record or null. Every one of those is a pure function over plain data, and testing a pure function over plain data is the easiest thing in software.

That is the whole argument, and it is worth stating as a diagnostic rather than as a virtue:

If a rule needs a GraphicsDevice to check, the rule is in the wrong project.

This chapter builds a test runner small enough to ship inside the game, runs a suite on the device, and then deliberately breaks a scoring rule so you can watch the suite go red. A test suite the reader has never seen fail has not taught them anything.

What you will learn in this chapter

  • Why a rules project with no framework dependency is testable almost by accident.
  • How to write a test runner in eighty lines, and when that is the right thing to do.
  • Why a distinct assertion exception matters, and how it separates a failure from a crash.
  • How to write assertion messages that say what was expected and what arrived.
  • Why tests should be named as claims about behaviour rather than after methods.
  • Why running the suite on the device is worth doing even when you have CI.
  • How to demonstrate a failing test on purpose, and why every project should be able to.
  • The caveats: what this runner is not, what cannot be tested this way, and where xUnit belongs.

The code for this chapter

The demonstration is at github.com/nodoid/MonoGameBook/src/Chapter21. It runs a small suite inside the running application, shows each test's name, outcome and duration, and has a button that injects a scoring bug so you can watch two tests turn red.

Chapter 21 running. Every test with its outcome and duration, the pass and fail totals, and the button that injects a deliberate fault into the scoring rule.
Chapter 21 running. Every test with its outcome and duration, the pass and fail totals, and the button that injects a deliberate fault into the scoring rule.

Why Core is testable

Look at what Core has accumulated over twenty chapters: an accumulator, a vector, an entity world, an alien formation, collision maths, a state machine, an audio director, a high score table, a Sudoku board, its rules, a generator, a solver, a save serialiser. Not one of them references MonoGame. Not one needs a window, a graphics device, a touch panel or an audio device.

That was not achieved by writing tests. It was achieved by a project file with no PackageReference in it and a rule that nothing gets added. Testability is a consequence of the architecture rather than a goal pursued separately — which is the useful lesson, because "we will add tests later" almost never survives contact with a class that needs a GraphicsDevice to construct.

The rule generalises past games. A layer with no framework dependency can be tested with no framework; a layer that is tangled with the framework can only be tested by starting the framework. The cost of keeping them apart is a Vec2 you wrote yourself. The benefit is every test in this chapter.

A test runner in eighty lines

The runner exists because the demonstration has to run on a phone, where xUnit does not. It is deliberately small:

public sealed class MiniTestRunner
{
    private readonly List<(string Name, Action Body)> tests = [];

    public IReadOnlyList<TestResult> Results { get; private set; } = [];

    public int Passed => Results.Count(result => result.Passed);
    public int Failed => Results.Count(result => !result.Passed);
    public double TotalMilliseconds => Results.Sum(result => result.Milliseconds);

    public MiniTestRunner Test(string name, Action body)
    {
        tests.Add((name, body));
        return this;
    }
}

A list of named actions. Test returns this so the suite can be written as a chain. There is no attribute scanning, no reflection, no discovery — which means it works under an ahead-of-time compiled iOS build, where reflection-based discovery frequently does not.

public void Run()
{
    var results = new List<TestResult>(tests.Count);

    foreach ((string name, Action body) in tests)
    {
        var stopwatch = Stopwatch.StartNew();
        string detail = string.Empty;
        bool passed = true;

        try
        {
            body();
        }
        catch (AssertionException failure)
        {
            passed = false;
            detail = failure.Message;
        }
        catch (Exception error)
        {
            passed = false;
            detail = $"{error.GetType().Name}: {error.Message}";
        }

        stopwatch.Stop();
        results.Add(new TestResult(name, passed, detail, stopwatch.Elapsed.TotalMilliseconds));
    }

    Results = results;
}

Four things in that loop are worth copying even if you never write your own runner.

Each test is isolated by its own `try`. One failing test does not stop the rest. A runner that aborts on the first failure tells you about one problem per run, which triples the number of runs you need.

The two `catch` blocks are different on purpose. An AssertionException is a test that failed — the code ran and the claim was false. Any other exception is a test that crashed — the code threw where it should not have. The detail message distinguishes them, and the distinction matters: a failure points at the assertion, a crash points at the code.

Every test is timed. Milliseconds on each result surfaces the slow ones. A suite where one test takes two seconds and the rest take microseconds is a suite with an accidental integration test in it.

Results are a value, not a side effect. Run fills a list; nothing is printed. That is what lets the same runner drive a screen on a phone, a console writer in CI, and an assertion in another test.

Assertions

/// <summary>Thrown by <see cref="Assert"/> so the runner can tell a failure from a crash.</summary>
public sealed class AssertionException(string message) : Exception(message);

public static class Assert
{
    public static void True(bool condition, string because)
    {
        if (!condition)
            throw new AssertionException(because);
    }

    public static void Equal<T>(T expected, T actual, string because)
    {
        if (!EqualityComparer<T>.Default.Equals(expected, actual))
            throw new AssertionException($"{because}: EXPECTED {expected}, GOT {actual}");
    }

    public static void False(bool condition, string because) => True(!condition, because);
}

Three assertions. The class comment calls them "the three assertions this book needs", and that is not false modesty — True, False and Equal cover the overwhelming majority of what a test asserts. Collection and exception assertions are the usual next additions, and neither is needed here.

Two details are worth taking seriously.

Every assertion requires a reason. because is not optional. A failing test that says Assert.Equal failed is a puzzle; one that says TEN ALIENS ON WAVE THREE: EXPECTED 1500, GOT 1503 is an answer. Making the parameter mandatory is what guarantees the message exists, and it is worth the small friction.

`Equal` prints both values. Expected and actual, in that order, in the message. This is the single highest-value line in any assertion library, and writing it yourself is a good way to appreciate why.

EqualityComparer<T>.Default is what makes Equal work for records, structs and strings alike — it picks up IEquatable<T> where a type implements it, which every record in this book does.

Tests as claims

The suite reads as a list of statements about the game:

runner.Test("WAVE 1 BONUS IS 50 A HEAD", () =>
    Assert.Equal(500, Scoring.WaveBonus(1, 10), "TEN ALIENS ON WAVE ONE"));

runner.Test("BONUS SCALES WITH THE WAVE", () =>
    Assert.Equal(1500, Scoring.WaveBonus(3, 10), "TEN ALIENS ON WAVE THREE"));

runner.Test("BONUS IS ZERO WITH NO KILLS", () =>
    Assert.Equal(0, Scoring.WaveBonus(4, 0), "NO ALIENS CLEARED"));

runner.Test("SCORE NEVER GOES NEGATIVE", () =>
    Assert.Equal(0, Scoring.ApplyPenalty(30, 100), "A PENALTY LARGER THAN THE SCORE"));

runner.Test("PENALTY SUBTRACTS NORMALLY", () =>
    Assert.Equal(70, Scoring.ApplyPenalty(100, 30), "AN ORDINARY PENALTY"));

runner.Test("PENALTY OF ZERO CHANGES NOTHING", () =>
    Assert.Equal(100, Scoring.ApplyPenalty(100, 0), "NO PENALTY"));

The names are claims about behaviour — "SCORE NEVER GOES NEGATIVE" — not descriptions of code — ApplyPenalty_WhenPenaltyExceedsScore_ReturnsZero. That is a deliberate choice and it pays off twice: the list of test names is a readable specification of the scoring rules, and a failing name tells you what the game got wrong rather than which method returned the wrong thing.

The six tests also show the shape a good small suite has. Two are ordinary cases. Two are boundaries — zero aliens, zero penalty. One is a property that must hold universally: the score never goes negative. And one checks that a parameter actually has the effect it claims, by varying only the wave.

It is worth noticing what these tests do not need: no setup, no fixtures, no mocks, no dependency injection. Scoring is a static class of pure functions, so a test is one line. That simplicity is downstream of the architecture, not of the test framework.

What else in this book is testable this way

The same style covers most of Core:

  • Chapter 4 — feed FixedStepAccumulator a synthetic sequence of frame times including a one-second stall, and assert on TotalSteps and DroppedSteps. This is a timing bug you can test without waiting.
  • Chapter 5VirtualResolution.Fit(1179, 2556) should give a scale of 2.456 and an offset of 295. Every device preset is a test case.
  • Chapter 9 — kill the outer column and assert that the formation now marches further before reversing.
  • Chapter 11 — assert that every GameState appears as a From in the transition table, which catches soft locks before a player does.
  • Chapter 16Peers(40).Length is 20, and so is Peers(0).Length.
  • Chapter 17 — generate with a fixed seed and assert that CountSolutions on the result is exactly 1.
  • Chapter 20 — round-trip a SaveData and assert equality; parse a version-1 file and assert the migration ran.

None of those needs a device, a window or a file — except the last, which needs a temporary directory and is therefore the one test in the list that is arguably an integration test.

Seeing it fail

Scoring carries a switch that breaks it:

/// <summary>When true, the multiplier is applied to the wrong operand.</summary>
public static bool InjectBug { get; set; }

public static int WaveBonus(int wave, int aliensCleared) =>
    InjectBug
        ? AlienPoints * aliensCleared + wave
        : AlienPoints * aliensCleared * wave;

The class comment explains why the switch exists:

The chapter needs to show a red test, and a test suite that has never failed in front of the reader has not taught them anything.

The injected fault is + wave instead of * wave — a single character, and exactly the kind of thing a tired person types. Turn it on and watch which tests fail:

  • WAVE 1 BONUS IS 50 A HEAD passes, because on wave 1 the correct answer is 500 and the buggy answer is 501... no, it fails by one. Look closely at that.
  • BONUS SCALES WITH THE WAVE fails loudly: expected 1500, got 503.
  • BONUS IS ZERO WITH NO KILLS fails: expected 0, got 4.

Two lessons come out of this small demonstration.

A test that fails by a small amount is as informative as one that fails by a large one, provided the message prints both numbers. EXPECTED 500, GOT 501 immediately suggests an off-by-one in an operator; EXPECTED 1500, GOT 503 immediately suggests the multiply became an add. The messages are doing diagnostic work, not just reporting.

Boundary tests catch what ordinary tests miss. BONUS IS ZERO WITH NO KILLS fails hard under the injected bug — 0 versus 4 — precisely because zero aliens makes the multiplication term vanish and leaves the fault exposed. Tests at zero, at one, and at the maximum are worth more than three tests in the middle of the range.

Running on the device

This runner ships inside the game, which is unusual, and it is worth being clear about why.

It is not a replacement for CI. Real projects run xUnit or NUnit against Core on every commit, with a proper runner, parallelism, filtering and a report. That is the primary line of defence and nothing here changes it.

What running on the device adds is a small set of answers CI cannot give. It proves the code behaves the same under the iOS ahead-of-time compiler as under the desktop JIT — which is not guaranteed, particularly around generics and anything reflective. It exercises the actual float behaviour of the device's processor. And it is available in the field: a tester with a build in their hand can run the suite and screenshot the result, which is a far better bug report than a description.

Ship it behind a gesture

A test screen reachable from a debug menu costs nothing in a release build if it is compiled out, and costs almost nothing if it is not. Being able to ask a tester "open the debug menu, run the tests, send me the screenshot" is worth a surprising amount.

Caveats

This is not a test framework

No parallelism, no filtering, no setup or teardown, no parameterised tests, no async support, no output capture. It is eighty lines that demonstrate a point. Use xUnit for the real suite against Core — the tests themselves will barely change, because they are just calls to pure functions.

Not everything belongs here

Anything touching the file system, the clock or the network is an integration test and does not belong in a runner that ships inside a game. SaveStore is the boundary: SaveSerializer is pure and testable here, SaveStore writes files and is not.

Static mutable state is a hazard

Scoring.InjectBug is a static setter, which means test order could matter if a test left it set. Here nothing does, and the demonstration toggles it deliberately. In a real suite, static mutable state is the most common source of tests that pass alone and fail together — prefer instance state, and if you must have a static switch, reset it in a finally.

Floating point equality

Assert.Equal(0.1f + 0.2f, 0.3f, ...) fails, and rightly. Anything comparing floats needs a tolerance — Assert.True(MathF.Abs(a - b) < 0.0001f, ...) — and adding a Close assertion is the first extension most suites need. Chapter 4's accumulator tests need exactly this.

A green suite is not a correct game

These six tests cover two functions. Nothing here checks that the ship feels right, that the audio is balanced, that the layout works one-handed, or that the game is fun. Chapter 2's list of what only a device can tell you is still the list. Tests protect the part of your game that is arithmetic, which is a large part and not all of it.

Building and running this chapter

The solution is src/Chapter21/Chapter21.sln in the book's repository at github.com/nodoid/MonoGameBook/src/Chapter21.

cd src/Chapter21
dotnet build Android/Chapter.Android.csproj
dotnet build iOS/Chapter.iOS.csproj

To deploy to a connected Android device or a running emulator:

dotnet build Android/Chapter.Android.csproj -t:Run

To run on the iOS Simulator:

dotnet build iOS/Chapter.iOS.csproj -p:RuntimeIdentifier=iossimulator-arm64
xcrun simctl install booted \
  iOS/bin/Debug/net10.0-ios/iossimulator-arm64/Chapter21.iOS.app
xcrun simctl launch booted com.monogamebook.chapter21

Run the suite, then inject the bug and run it again. The second run is the useful one.

Try it yourself

  1. Run the suite, inject the bug, and run again. Read the failure messages and work out the fault from them alone before looking at the code.
  2. Add a test asserting that Scoring.ApplyPenalty(0, 0) is zero. It passes immediately, which is what a boundary test that finds nothing looks like.
  3. Add a Close assertion with a tolerance, and use it to test VirtualResolution.Fit(1179, 2556).Scale against 2.456.
  4. Add a test that walks GameStateMachine's transition table and asserts every GameState appears at least once as a From. Then delete a row and watch it go red.
  5. Throw an ordinary InvalidOperationException from inside a test body. Note that the runner reports it differently from an assertion failure.

Summary

Core is testable because it is boring: no MonoGame reference, no graphics device, no touch panel, no file system. That property was created by a project file with nothing in it and a rule that nothing gets added, and every test in this chapter is a consequence of it. The diagnostic is worth remembering — if a rule needs a GraphicsDevice to check, the rule is in the wrong project.

A runner small enough to ship is a list of named actions, each run inside its own try, each timed, with results as a value rather than as printed output. Two catch blocks separate a failed assertion from a crash, which are different problems needing different attention. No reflection, so it works under ahead-of-time compilation.

Three assertions cover almost everything, and both of their details matter: a mandatory reason, so no failure is ever mute, and printing expected against actual, so the message diagnoses rather than merely reports.

Name tests as claims about the game — "SCORE NEVER GOES NEGATIVE" — and the list of names becomes a readable specification. Cover ordinary cases, boundaries, and universal properties, and remember that boundary tests are where injected faults show up most clearly.

Finally, make your suite fail on purpose at least once. A test suite nobody has seen go red is a test suite nobody has verified.

Chapter 22 measures instead of asserting. It builds a frame profiler, puts the budget on screen as a line the frame graph can cross, and goes looking for the allocations that turn a smooth game into a stuttering one.