17.4 Testing & Debugging

Phase 17 · Software Engineering & Production · Study time: 20–35 h

Unit and integration tests, deterministic simulation tests, sanitizers, and driving a debugger fluently instead of guessing.

You already know what "it works on my machine but not in the build" feels like, and you already have two tools from the C chapters for finding out why: gdb for pausing a running program and looking inside it, and AddressSanitizer for catching memory bugs the moment they happen instead of hours later as a mysterious crash. This chapter is about the skill that sits above any one tool — how to actually find a bug on purpose instead of by luck — and about the other half of the same problem: how to stop a fixed bug from ever coming back, using tests. Debugging finds today's bug. Testing keeps yesterday's bug dead.

1. The Debugging Mindset: A Bug Is a Gap Between Belief and Reality

Here is the single most useful sentence in this whole chapter: a bug is not a mystery, it is a place where what you believe about your code and what the code actually does have come apart. You believe the fall-damage system only triggers when the player actually falls. The code, right now, disagrees with you. One of you is wrong, and it is not the code — code does exactly what it says, every single time. So the bug is not "somewhere in there, good luck." The bug is a specific, findable disagreement between a belief you are holding and a fact that is already true.

That reframing changes how you should spend your next ten minutes. Beginners tend to debug by changing things and re-running the game, hoping something looks different: tweak a number, add a null check "just in case," swap the order of two lines. This finds bugs sometimes, the way trying every key on a keyring sometimes opens the door. It also frequently makes things worse, because a change made without understanding why can hide a symptom while leaving the actual cause in place. Working programmers debug by forming a hypothesis and testing it — the exact same loop as the scientific method, just aimed at a small piece of software instead of the universe.

observe the symptom ("player took 40 fall damage right after loading a checkpoint") | v form ONE specific, falsifiable belief ("I believe: fallStartY is not reset when LoadCheckpoint() runs") | v predict a fact that MUST be true if that belief is correct ("if true, fallStartY should still equal the OLD pre-fall height right after LoadCheckpoint() finishes, not the new checkpoint height") | v run the smallest possible test of exactly that fact (a breakpoint, a log line, or a one-line unit test -- not a fix yet) | v did the prediction come true? yes -> belief confirmed. the bug is narrowed down. go around again on the smaller remaining mystery. no -> belief was wrong. throw it out, form a new belief, go back to the top with what you just learned.

Applied to a real example: testers report "sometimes I take fall damage right after loading a checkpoint, even when I clearly landed on solid ground." The wrong way to start is opening PlayerHealth.cs and staring at it until something looks suspicious, or adding an if (justLoadedCheckpoint) return; guard because it "should fix it." That might work, or it might just hide the real bug somewhere it will resurface later, in a different form. The right way is to write down, in one sentence, what you currently believe: "I believe fall damage is calculated from how far the player fell, tracked since they left the ground." Then ask: what would have to be true, right now, for that belief to explain the symptom? If fall damage fires immediately after loading a checkpoint, the fall-distance tracking variable must already hold some leftover, nonzero value the instant the checkpoint loads — before the player has fallen at all this time. That is a specific, checkable claim. Section 4 shows exactly how to check it with a debugger; sections 2 and 3 show two more ways to narrow down where to even look before you check anything.

Tip Write your hypothesis down, literally, in a comment, a scratch file, or a chat message to yourself: "I believe X. If X is true, Y must also be true." A hypothesis that lives only in your head is easy to quietly abandon the moment you see something confusing, and then you are back to random changes without noticing you switched strategies.

One more habit worth naming: change one thing at a time. If you adjust three things and the symptom goes away, you now know one of those three fixed it — and you do not know which one, or whether the other two just introduced two new, quieter bugs. Every step in the loop above tests exactly one belief. That is what makes the loop reliable instead of lucky.

2. Binary Search Debugging: Cutting the Code in Half

Sometimes you do not even have a hypothesis yet — you just have a big pile of code that runs every frame, and something in it is wrong. Reading all of it top to bottom, in order, is slow, and it means checking suspects the bug probably is not even in. Binary search debugging (the same idea as binary search on a sorted array, applied to your own code) fixes that: cut the suspect code roughly in half, check which half still shows the bug, throw the other half out of suspicion, and repeat.

Say the game hitches (drops several frames, visibly stutters) once every few seconds, and you suspect it is one of six systems running in Update(), but you do not know which:

void Update()
{
    UpdateMovement();
    UpdateAnimationBlending();
    UpdateInventoryUI();
    UpdateEnemyAI();
    UpdateWeatherSystem();
    UpdateMinimapIcons();
}

Instead of reading all six functions, comment out the bottom half and play for a minute:

void Update()
{
    UpdateMovement();
    UpdateAnimationBlending();
    UpdateInventoryUI();

    // UpdateEnemyAI();
    // UpdateWeatherSystem();
    // UpdateMinimapIcons();
}

Two possible outcomes, and both are useful:

Either way, you just eliminated half the suspects with one test. Repeat on the remaining half — comment out one of the remaining functions, or split a single large function's body in half with an early return — and each round cuts the search space in half again:

6 suspect functions, bug is in exactly one of them round 1: disable last 3 -> hitch gone -> bug is in the first 3 round 2: disable 1 of 3 -> hitch persists -> bug is in the other 2 round 3: disable 1 of 2 -> hitch gone -> bug is in the last 1 3 rounds instead of checking up to 6 functions one at a time, and instead of reading all 6 start to finish hoping something jumps out.

The same trick works inside one long function, not just across several: comment out the second half of the function's body (or add an early return; partway through) and see if the symptom survives with only the first half running. It also works on a whole scene: if you are not sure whether a bug depends on some object in the level, delete half the objects in a copy of the scene and see if it still happens.

Common mistake Commenting out code and then forgetting to test the opposite half too. If disabling the bottom three functions makes the hitch disappear, that only proves the bug is in one of those three — it does not by itself prove the top three are innocent, especially if two systems interact (for example, the weather system feeding fog values into a shader the minimap also reads). When in doubt, swap which half is disabled and confirm the result flips.

Binary search debugging turns "the bug is somewhere in 2,000 lines" into a small, fixed number of yes/no tests — roughly log2(n) rounds for n suspects, the exact same math that makes binary search fast on a sorted array. Six functions takes about three rounds; sixty-four would still only take about six.

3. Bisecting History: git bisect

Binary search does not just work on code you can see right now — it works on time, too. If a bug was not there last month and is there today, somewhere between those two points a specific commit introduced it. Reading every commit's diff in order is exactly the "check every suspect one by one" problem from section 2, and Git has a built-in binary search for it: git bisect.

Say movement felt fine in the v1.4.0 release, but today, on the current main branch, movement speed changes depending on frame rate — a bug, since speed should be frame-rate independent (from the physics chapters). You do not know which of the many commits since v1.4.0 caused it. Start a bisect:

git bisect start
git bisect bad HEAD          # HEAD (current main) has the bug
git bisect good v1.4.0       # v1.4.0 did not have the bug

Git immediately checks out a commit roughly halfway between the two, and waits for you to test it and report back:

12 commits between v1.4.0 (good) and HEAD (bad) good [1][2][3][4][5][6][7][8][9][10][11][12] bad ^ git checks out commit 6 and stops here you build and play-test commit 6: the bug IS present -> git bisect bad good [1][2][3][4][5][6] bad ........................... Git now only searches the first half: good [1][2][3][4][5] bad ^ git checks out commit 3 next you test commit 3: the bug is NOT present -> git bisect good good [1][2][3] [4][5] bad Git narrows again, between commit 3 (good) and commit 6 (bad). ... after a few more rounds, git prints: a1b2c3d is the first bad commit That single commit's diff is small enough to read end to end and find the exact line that introduced the bug.

Each round eliminates half the remaining commits, the same log2(n) shrink as section 2 — twelve commits takes about four tests instead of up to twelve. When you are done, run git bisect reset to return to the branch you started on; the bisect session does not rewrite your history, it only checks out commits temporarily while you search.

Testing each commit by hand (build it, play it, decide good or bad) works, but it is slow, and it is easy to misjudge an intermittent bug as "good" just because it did not happen to appear during that one play-test. If you can write a script that returns exit code 0 for "good" and non-zero for "bad" — for example, a headless check that measures movement speed at two different simulated frame rates and fails if they differ — git bisect run automates the entire search:

git bisect start
git bisect bad HEAD
git bisect good v1.4.0
git bisect run ./check_frame_rate_independence.sh

Git checks out each candidate commit, runs the script, reads its exit code, and keeps narrowing automatically without you rebuilding and testing by hand every time — which is exactly why section 12's automated smoke tests and regression tests are worth having even before you hit a bug like this: a good automated test is also a ready-made oracle for git bisect run the next time history needs searching.

Tip git bisect assumes the bug's presence changes monotonically with history — once it appears, it stays present in every later commit, and it was absent in every earlier one. If a bug was introduced, then accidentally fixed, then reintroduced, a plain bisect can report a confusing or wrong answer. Skip a commit you cannot test (say, one that does not build) with git bisect skip instead of guessing good or bad.

4. Breakpoints and Stepping: the Debugger, in Unity/Visual Studio and in gdb

Logging and binary search narrow a bug down to a small area of code. A debugger is how you look directly inside that area while the program is actually running, instead of guessing from printed text. You already used one: gdb, on plain C programs. Everything you learned there — breakpoints, stepping, inspecting variables — is the exact same idea in Visual Studio (or Rider) attached to Unity. Only the buttons change.

A breakpoint is a line you mark in advance; when execution reaches it, the program pauses before running that line, with every variable still holding its real, current value, so you can look around. In Visual Studio, click the thin gray margin to the left of a line number to set one (a red dot appears); in the Unity Editor, that only pauses code once the Visual Studio (or Rider) debugger is attached to the Unity process first — usually a menu item like Debug > Attach Unity Debugger, then press Play in Unity as normal.

void TakeFallDamage()
{
    float fallDistance = fallStartY - transform.position.y; // <- breakpoint here

    if (fallDistance > safeFallDistance)
    {
        int damage = (int)((fallDistance - safeFallDistance) * damagePerMeter);
        currentHealth -= damage;
    }
}

Once execution is paused on that line, the debugger UI shows you everything at once: hover any variable to see its value in a tooltip, or open the Locals window to see all local variables and fields at a glance. Here is where the stepping controls matter — they decide how far the program runs before pausing again:

Stepping controls (same four ideas, different names per tool) Step Over -- run this line, but treat any function call on it as a black box (run the whole call, land on the next line) Step Into -- if this line calls a function, follow the debugger INSIDE that function, one line at a time Step Out -- finish running the REST of the current function, then pause as soon as it returns to whoever called it Continue -- stop stepping, run normally until the next breakpoint

These map directly onto gdb commands you already know the shape of, plus the Visual Studio keys shown here for reference:

Concept Visual Studio / Rider gdb (from the C chapters) ------------------------------------------------------------------------------- set a breakpoint click the margin break PlayerHealth.cs:42 break TakeFallDamage run until a breakpoint F5 (attach, then Play) run step over a line F10 next step into a function F11 step step out of a function Shift+F11 finish continue to the next stop F5 continue inspect a variable hover it, or Locals print fallDistance

If TakeFallDamage calls a helper function like ApplyArmorReduction(damage) and you do not care how armor reduction works right now, Step Over it — you get the return value without stepping through every line inside. If that helper is exactly what you are suspicious of, Step Into it instead. Getting this choice right is most of what makes debugging with breakpoints fast instead of tedious: step into only the function you actually suspect, step over everything else.

Common mistake Stepping into everything, including engine internals, library code, or a loop over 500 items one iteration at a time. That is not debugging, that is a very slow way to watch code run. Step over anything you already trust, and use a conditional breakpoint (section 5) to skip straight past the first 499 boring iterations of a loop to the one that actually matters.

5. Conditional Breakpoints, Watches, and the Call Stack

A plain breakpoint pauses every single time execution reaches that line. If TakeFallDamage runs constantly (once per frame, say) but you only care about the one call right after a checkpoint load, a plain breakpoint makes you hit Continue dozens of times before the interesting call shows up. A conditional breakpoint only pauses when an expression you write is true.

In Visual Studio, right-click an existing breakpoint's red dot and choose Condition, then enter a boolean expression using the same variables visible at that line:

fallDistance > 50 && justLoadedCheckpoint

Now the debugger silently runs past every ordinary call and only actually pauses on the one that matches — exactly the call you are hunting. The same idea exists in gdb, just written on the command line:

break TakeFallDamage if fallDistance > 50 && justLoadedCheckpoint

A watch (or watchpoint) is a related but different tool: instead of pausing at a line, it pauses the moment a specific value changes, no matter which line changes it. That matters when you know what is going wrong (a variable ends up with the wrong value) but not where it goes wrong, because several different places in the code touch it. In Visual Studio, right-click a variable while paused and choose Add Watch, or type the expression directly into the Watch window; gdb has the same idea:

watch fallStartY

With that set, gdb pauses the instant fallStartY is written to anywhere in the program, and tells you the old and new value — which, for a bug like "this value is stale," is often the fastest way straight to the exact assignment (or the exact missing assignment) responsible.

The Call Stack: How Did Execution Get Here?

Once paused, one more question matters as much as "what is this variable's value right now": who called this function, and who called them? The call stack (introduced conceptually back in the C chapters, when you learned about the stack itself) is the literal list of functions currently waiting for something they called to return, from the outermost down to the exact line you are paused on.

Call Stack window, paused inside TakeFallDamage() TakeFallDamage() <- you are here, line with the breakpoint LateUpdate() called TakeFallDamage (Unity's internal loop) called LateUpdate this frame Reading it bottom to top tells the whole story of how you got here: this frame, Unity's loop called LateUpdate, which called TakeFallDamage.

This is exactly how you would confirm or reject the belief from section 1: if you expected TakeFallDamage to only ever be called right after checking whether the player actually fell, and the call stack instead shows it being called from LateUpdate directly, every single frame, unconditionally — that is new information your original belief did not account for, and it points straight at the real shape of the bug: fall damage is being checked every frame using whatever fallStartY currently holds, including the one frame right after a checkpoint load where nothing has reset it yet. Double-clicking any frame in the Call Stack window jumps the debugger's view to that function's own local variables, exactly as if you had paused there instead — useful for checking what the caller believed was true when it made the call. gdb's equivalent is bt (backtrace, print the whole stack) and frame N to jump to frame number N.

6. Logging That Helps: Context, Not "here1" / "here2"

A debugger is great when you can pause execution and look around by hand. It is a poor fit for anything where pausing changes the outcome — physics that keeps simulating, a network connection that times out while you are stepping through it, or a bug that only shows up after ten minutes of normal play and thousands of frames you cannot step through one at a time. For those, you need a log: a running, timestamped record of what happened, written while the game keeps running at full speed, that you read back afterward.

The trouble is that most beginner logging looks like this:

Debug.Log("here1");
// ... fifty lines later ...
Debug.Log("here2");
// ... in a different file entirely ...
Debug.Log("got here");

Six hours later, staring at a console full of here1, here2, and got here repeated hundreds of times, none of it tells you anything: not which object logged it, not what the values were at that moment, not which frame, not why you should care. A log line is only useful if it answers a question you will actually ask later. The fix is to always log context: what object, what state, what frame, and any values relevant to the bug you are chasing.

Debug.Log($"[FallDamage] player={playerId} frame={Time.frameCount} " +
          $"fallStartY={fallStartY:F2} currentY={transform.position.y:F2} " +
          $"justLoadedCheckpoint={justLoadedCheckpoint}");

Console output from two consecutive frames, right around a checkpoint load:

[FallDamage] player=1 frame=812 fallStartY=48.30 currentY=6.10 justLoadedCheckpoint=false
[FallDamage] player=1 frame=813 fallStartY=48.30 currentY=1.00 justLoadedCheckpoint=true

Now the log tells the whole story by itself, no debugger required: on frame 813, the instant after the checkpoint loads, fallStartY still holds 48.30 — the height from before the fall that was already in progress — instead of being reset to the new checkpoint height. The [FallDamage] prefix also makes the line searchable: filter the Console window by that tag, or grep a log file for it, and every other system's noise disappears.

Tip A useful log line answers three questions on its own, without needing to open the code: where did this come from (a tag or class name), when (a frame number or timestamp), and what (the actual values involved). If a log line is missing any of the three, it will eventually be useless at 2 AM when you are staring at a wall of console text with no idea which line matters.

Log Levels

Not every log line deserves the same weight. A log level is a label on a log message saying how serious or how noisy it is, so you (or a filtering tool) can turn categories on and off instead of drowning in everything at once:

Trace / Verbose -- every frame, every step; only ever turned on while actively hunting one specific bug, far too noisy to leave on Debug -- useful during development; normally stripped out of, or hidden in, a shipped build Info -- a normal, expected, significant event ("level loaded", "player connected") Warning -- something is off, but the game can keep going ("optional cosmetic asset missing, using default") Error -- something failed to do its job ("save file failed to write") Fatal / Critical -- the game cannot safely continue ("save data is corrupted, state is unrecoverable")

Unity's built-in logging maps onto the middle three directly: Debug.Log for Info/Debug-level messages, Debug.LogWarning for Warning, Debug.LogError for Error (which also makes the message show up in red and, in the Editor, can be configured to pause on error). There is no built-in Trace level; teams that want one usually write a thin wrapper that only compiles Trace-level calls into a build when a specific symbol is defined:

using System.Diagnostics;

public static class Log
{
    [Conditional("VERBOSE_LOGGING")] // the whole call site disappears
    public static void Trace(string message)     // when this symbol is not defined
    {
        UnityEngine.Debug.Log("[TRACE] " + message);
    }
}

[Conditional("VERBOSE_LOGGING")] is a compiler instruction: unless a scripting define symbol named VERBOSE_LOGGING is set for the build, every call site that calls Log.Trace(...) is removed entirely at compile time — not just silenced, physically not present in the compiled code, so it costs nothing at runtime in a normal build. That is how a team can litter performance-critical code with detailed trace logging during development without paying for it once the game ships.

Common mistake Leaving verbose per-frame logging turned on in a shipped or even a QA build. Beyond the noise, Debug.Log is not free — formatting a string and writing it out has a real cost, and doing it every frame for every enemy in a busy scene can itself cause the very frame-rate problems you are trying to debug.

7. Reproducing a Bug Reliably Before You Try to Fix It

Before touching a single line of the fix, you need one thing: a way to make the bug happen on demand, as many times as you want. This is called a reproduction (or repro) — the exact sequence of steps that reliably triggers the bug. Skipping this step is the single most common way debugging time gets wasted: you change something, the bug does not show up for the next five minutes, you assume you fixed it, ship it, and it comes back a day later — because it was never fixed, it just did not happen to trigger during those five minutes.

A good repro is written down as concrete, numbered steps, and it says what result to expect and how often it happens:

Bug: fall damage triggers immediately after loading a checkpoint

Steps to reproduce:
1. Start a new game, let health regenerate to full.
2. Walk off the edge of the bridge in "Docks" (any long fall works).
3. While still falling, open the pause menu and choose
   "Load Last Checkpoint".
4. Watch the health bar the instant the level finishes loading.

Expected: health stays full, the checkpoint is on flat ground.
Actual:   health drops by 35-50 (varies) the instant loading finishes.

Reliability: 10/10 when followed exactly as written above.
Does NOT reproduce if the player dies to an enemy and respawns from
the death screen instead of loading from the pause menu.

That last line — the case that does not reproduce — is often more valuable than the one that does. It tells you the bug is not "fall damage is broken" in general; it is specific to one particular code path that resets state differently than the other. That single observation already narrows a hypothesis: whatever code runs on death-and-respawn correctly resets fallStartY, and whatever code runs on checkpoint-load does not. Section 1's hypothesis loop, section 4's debugger, and section 6's logging all become dramatically faster to use once you have a repro this precise, because you can trigger the exact moment you need to inspect, over and over, instead of hoping it happens again.

Tip Try to shrink the repro to the fewest possible steps and the fewest possible conditions ("does it still happen from any checkpoint, or only this one? does it still happen with a short fall, or only a long one?"). A smaller repro is not just faster to run — every condition you manage to remove and still see the bug is one less thing it depends on, which directly narrows where the cause can be.

If you genuinely cannot make a bug happen reliably, treat that itself as a clue rather than bad luck — which leads directly into the next section.

8. Why an Intermittent Bug Usually Means Uninitialized Data, a Race, or Frame-Rate Dependence

A bug that happens every single time, in the same way, is usually straightforward: the logic is simply wrong, and reading the code carefully (or stepping through it once) finds it. A bug that happens sometimes — three times out of ten, only on one machine, only after playing for a while — is scarier, but it is not random. Something in the program's behavior is depending on a condition you have not identified yet: something that is not always the same between runs. In practice, almost every intermittent gameplay bug falls into one of three categories.

1. Uninitialized or Stale Data

In the C chapters, an uninitialized local variable reads whatever garbage bytes happened to already be sitting on the stack — undefined behavior, and exactly the kind of bug AddressSanitizer and careful gdb inspection catch. C# fields are always initialized to a safe default (0, false, null) by the runtime, so you will not get literal garbage memory — but the same shape of bug still exists, just with a different mechanism: a field that holds a real, valid, but stale value left over from an earlier, unrelated use, because nothing reset it on this particular code path. That is exactly the fall-damage bug from this whole chapter: fallStartY is not garbage, it is a perfectly valid float — it is just the wrong valid float, left over from the fall that was already in progress, because the checkpoint-load path never calls whatever resets it.

public class PlayerHealth : MonoBehaviour
{
    private float fallStartY;
    private bool isFalling;

    // Called when the player starts falling (not shown in full):
    // simply records isFalling = true; fallStartY = transform.position.y;

    // Called by the "die -> respawn from death screen" path.
    // Correctly resets fall tracking.
    public void RespawnAfterDeath(Vector3 spawnPoint)
    {
        transform.position = spawnPoint;
        fallStartY = spawnPoint.y;
        isFalling = false;
    }

    // Called by the "pause menu -> load checkpoint" path.
    // BUG: never touches fallStartY at all.
    public void LoadCheckpoint(Vector3 checkpointPoint)
    {
        transform.position = checkpointPoint;
        // missing: fallStartY = checkpointPoint.y; isFalling = false;
    }
}

This is why the repro in section 7 matters so much: it already proved the bug depends on which reset path runs, which is precisely a "some paths initialize this, some do not" bug, before you had even opened the file.

2. A Race Condition

A race condition is a bug that depends on the order in which two things that can happen independently actually finish, when that order is not guaranteed. Two coroutines, two network messages, or a coroutine and a physics callback can each finish in a different relative order from one play session to the next, even with identical player input, because their exact timing depends on things like frame timing and scheduling that are not perfectly repeatable.

Player mashes the reload key twice quickly. Two reload coroutines start almost together. Only ONE should end up owning the final ammo count. Run A (looks fine): Coroutine 1: start reload -.......- finish, ammoInClip = 30 Coroutine 2: start reload -....- finish, ammoInClip = 30 Both coroutines agree on 30. Bug does not show. Run B (bug appears): Coroutine 1: start reload -......................- finish, ammoInClip = 30 Coroutine 2: start reload -....- finish, ammoInClip = 30 player fires -- ammoInClip = 29 Coroutine 1 finishes AFTER the shot, and overwrites ammoInClip back to 30 -- the fired shot is silently undone.

The code never changed between Run A and Run B — only the relative timing of two independent coroutines did. That is the signature of a race condition: identical code, identical inputs, different outcome, because something about scheduling shifted by a few milliseconds. The general fix is to make the outcome not depend on which one finishes last — for example, have the second reload cancel the first one outright instead of letting both run to completion and silently overwrite each other.

3. Frame-Rate Dependence

Code that behaves differently at different frame rates is the third repeat offender, and it usually hides in an exact comparison against a value computed from time:

// BUG: floating-point time almost never lands on an exact value,
// and whether it EVER happens to land close enough is itself
// frame-rate dependent -- fewer, bigger time steps at low frame
// rate are more likely to accidentally straddle the target than
// many tiny steps at high frame rate.
if (Time.time == nextSpawnTime)
{
    SpawnWave();
}

At 30 FPS, Time.deltaTime is a fairly large step each frame (about 0.033s), so Time.time occasionally lands close enough to trip the check by coincidence. At 144 FPS, the steps are much smaller (about 0.007s), and Time.time is more likely to step past nextSpawnTime without ever landing on it exactly — so the wave silently never spawns, on faster machines only. The fix is the same one from the Animator chapter's float-equality pitfall: never compare floating-point time (or any float) for exact equality; use a threshold comparison instead.

if (Time.time >= nextSpawnTime)
{
    SpawnWave();
    nextSpawnTime = Time.time + spawnInterval;
}

The broader lesson behind all three categories: when a bug will not reproduce reliably, do not shrug and re-run it hoping to get lucky. Ask instead, specifically, "what is different between the runs where it happens and the runs where it doesn't?" — a leftover value from a different code path, the finishing order of two independent operations, or the size of a time step. That question turns "random" into "conditional on something specific," which is a bug you can actually hunt with the tools from sections 1 through 7.

9. Unit Testing Pure Logic with the Unity Test Framework

Everything so far in this chapter finds and fixes one bug that already happened. A test is different: it is code that checks other code is correct, automatically, every time you run it — so a bug that was fixed once cannot silently come back without something immediately telling you. A unit test checks the smallest independently testable "unit" of behavior, usually a single method, in isolation from the rest of the game.

Unit tests work best on pure logic: code whose output depends only on its inputs, with nothing that reads the screen, the current frame, a file, or the network. Damage formulas, inventory math, and save-data serialization are exactly this shape — which is also exactly why it pays to write that logic as plain C# classes that do not reference UnityEngine at all, even inside a Unity project:

// No UnityEngine reference anywhere in this file -- plain data in,
// plain data out. That is exactly what makes it fast and easy to
// unit test: no scene, no GameObject, no engine startup required.
public static class DamageMath
{
    public static int CalculateDamage(int baseDamage, int attackerPower,
                                       int defenderArmor, bool isCritical)
    {
        int raw = baseDamage + attackerPower - defenderArmor;
        if (isCritical)
        {
            raw *= 2;
        }
        return System.Math.Max(raw, 1); // a landed hit always deals at least 1
    }
}

To test it, Unity's Test Runner window (Window > General > Test Runner) needs a place to find test code: a folder with an assembly definition (an .asmdef file, covered in the project-structure chapter) marked as a Tests Assembly, which references the NUnit-based test framework Unity ships with. Right-click a folder (conventionally Assets/Tests/EditMode) and choose Create > Testing > Tests Assembly Folder to set this up automatically. Then a test file looks like this:

using NUnit.Framework;

public class DamageMathTests
{
    [Test]
    public void NormalHit_SubtractsArmorFromPower()
    {
        int damage = DamageMath.CalculateDamage(
            baseDamage: 10, attackerPower: 5, defenderArmor: 3, isCritical: false);

        Assert.AreEqual(12, damage); // 10 + 5 - 3
    }

    [Test]
    public void CriticalHit_DoublesTheRawDamage()
    {
        int damage = DamageMath.CalculateDamage(
            baseDamage: 10, attackerPower: 5, defenderArmor: 3, isCritical: true);

        Assert.AreEqual(24, damage); // (10 + 5 - 3) * 2
    }

    [Test]
    public void HeavyArmor_NeverReducesDamageBelowOne()
    {
        int damage = DamageMath.CalculateDamage(
            baseDamage: 10, attackerPower: 0, defenderArmor: 999, isCritical: false);

        Assert.AreEqual(1, damage); // clamped, never zero or negative
    }
}

[Test] marks a method as one independent test case. Opening the Test Runner window and clicking Run All executes every [Test] method it finds and reports pass or fail for each, individually:

Test Runner (EditMode)

  DamageMathTests
    NormalHit_SubtractsArmorFromPower          PASS  (2 ms)
    CriticalHit_DoublesTheRawDamage            PASS  (1 ms)
    HeavyArmor_NeverReducesDamageBelowOne      PASS  (1 ms)

3 passed, 0 failed, 0 skipped

Each test's name describes exactly what it checks, not just "Test1" — the same "context, not here1" principle from section 6 applies to test names too, since a failing test's name is the first, and sometimes only, thing you read when something breaks. Inventory math tests the same way:

public class Inventory
{
    private const int MaxStack = 99;
    private readonly Dictionary<string, int> stacks = new Dictionary<string, int>();

    // Returns how many items did NOT fit, e.g. to spawn a new stack.
    public int AddItem(string itemId, int count)
    {
        int current = stacks.TryGetValue(itemId, out int existing) ? existing : 0;
        int newTotal = current + count;
        stacks[itemId] = System.Math.Min(newTotal, MaxStack);
        return System.Math.Max(0, newTotal - MaxStack);
    }

    public int GetCount(string itemId) =>
        stacks.TryGetValue(itemId, out int c) ? c : 0;
}
[Test]
public void AddItem_BelowStackLimit_AddsFully()
{
    var inv = new Inventory();
    int overflow = inv.AddItem("potion", 40);

    Assert.AreEqual(0, overflow);
    Assert.AreEqual(40, inv.GetCount("potion"));
}

[Test]
public void AddItem_PastStackLimit_ReturnsTheOverflowAmount()
{
    var inv = new Inventory();
    inv.AddItem("potion", 90);
    int overflow = inv.AddItem("potion", 20); // 90 + 20 = 110, cap is 99

    Assert.AreEqual(11, overflow);      // 110 - 99
    Assert.AreEqual(99, inv.GetCount("potion"));
}

And a save/load round trip — does data survive being written out and read back exactly, with nothing dropped or corrupted — is one of the highest-value tests in an entire game, because a save bug is often invisible until a player's real progress is destroyed:

[System.Serializable]
public class PlayerSaveData
{
    public string playerName;
    public int level;
    public float[] position;
    public List<string> inventoryItemIds;
}
[Test]
public void SaveData_SurvivesAJsonRoundTrip()
{
    var original = new PlayerSaveData
    {
        playerName = "Aria",
        level = 7,
        position = new float[] { 12.5f, 0f, -3.2f },
        inventoryItemIds = new List<string> { "sword_01", "potion" }
    };

    string json = JsonUtility.ToJson(original);
    PlayerSaveData loaded = JsonUtility.FromJson<PlayerSaveData>(json);

    Assert.AreEqual(original.playerName, loaded.playerName);
    Assert.AreEqual(original.level, loaded.level);
    Assert.AreEqual(original.position[0], loaded.position[0]);
    Assert.AreEqual(original.inventoryItemIds.Count, loaded.inventoryItemIds.Count);
    Assert.AreEqual(original.inventoryItemIds[0], loaded.inventoryItemIds[0]);
}

Note that this last test does use UnityEngine (JsonUtility), and still runs perfectly fine as an EditMode test, because it does not need a running scene, a frame loop, or a MonoBehaviour — it is testing serialization, not gameplay. The line between "needs the engine running" and "just uses an engine library" matters more than "does it touch UnityEngine at all."

10. Play Mode Tests: When Logic Needs the Engine Alive

Some behavior genuinely cannot be tested without a running scene: anything that depends on MonoBehaviour lifecycle methods (Awake, Start, Update), physics stepping, coroutines that need real frames to pass, or animation. Unity's Play Mode tests handle exactly this: the Test Runner actually enters Play Mode, runs real frames, and lets your test code wait between them using yield — the same coroutine mechanism from earlier chapters, just driving a test instead of gameplay.

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class PlayerJumpPlayModeTests
{
    [UnityTest]
    public IEnumerator Jump_WhenGrounded_GivesTheRigidbodyUpwardVelocity()
    {
        var go = new GameObject("Player", typeof(Rigidbody), typeof(PlayerController));
        var rb = go.GetComponent<Rigidbody>();
        var controller = go.GetComponent<PlayerController>();

        yield return new WaitForFixedUpdate(); // let one physics step settle first

        controller.Jump();

        yield return new WaitForSeconds(0.1f); // let a couple of physics frames pass

        Assert.Greater(rb.velocity.y, 0f);

        Object.Destroy(go);
    }
}

[UnityTest] (instead of plain [Test]) combined with an IEnumerator return type is what allows yield return inside a test — the Test Runner pumps real frames between each yield, exactly like a coroutine running during normal gameplay, so Jump()'s effect on the Rigidbody has actually had time to apply by the time the assertion runs. Console output when this passes:

Test Runner (PlayMode)

  PlayerJumpPlayModeTests
    Jump_WhenGrounded_GivesTheRigidbodyUpwardVelocity   PASS  (114 ms)

1 passed, 0 failed, 0 skipped

Play Mode tests are slower than EditMode tests — entering Play Mode, waiting for real frames, and tearing everything down all cost real wall-clock time, often measured in seconds rather than the milliseconds an EditMode test takes. That trade-off is exactly why section 9's pure-logic tests matter so much: pushing as much logic as possible into plain, engine-free classes like DamageMath means most of your tests can stay fast EditMode tests, and only the genuinely engine-dependent behavior needs the slower Play Mode path.

11. What's NOT Worth Unit Testing in a Game

It is possible to over-apply testing, and games have a larger-than-usual share of code where a test genuinely does not help. The rule of thumb: if the correct answer is a number or a fact you could work out by hand, it is worth testing; if the correct answer is "does this feel right," it is not — the second kind needs a human playtester, not an assertion.

Worth a unit test, because there is exactly one correct answer for any given input:

Not worth a unit test, because there is no single correct number to assert against — only a subjective judgment a human has to make:

There is a middle case worth calling out specifically: pathfinding, procedural generation, and AI decision logic are worth testing, but not by asserting on one exact "correct" output, because there is often more than one valid path or valid layout. Instead assert on the properties a correct result must have — "the returned path never crosses a wall tile," "the generated dungeon has at least one route from entrance to exit," "the AI never selects an ability that is on cooldown" — properties that stay true across every valid answer, not just one specific one.

Tip A practical filter: if writing the test requires you to also build a scene, load assets, and eyeball the Game view to know whether it "worked," it is probably not a unit test candidate at all — it is either a Play Mode test (section 10) or a job for a human playtester (section 13).

12. Regression Tests and Automated Smoke Tests

A regression is a bug that comes back after already being fixed once — usually because a later change touched the same code and nobody noticed the old problem had returned. A regression test is a test written specifically because a real bug happened: it encodes the exact broken scenario as a permanent, automated check, so if the bug is ever reintroduced, a test fails immediately instead of a player finding it months later.

Section 8 walked through the exact bug this test locks in: fall damage triggering right after loading a checkpoint, because LoadCheckpoint never reset fallStartY. Once that is fixed, the regression test for it looks like this:

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class PlayerHealthRegressionTests
{
    [UnityTest]
    public IEnumerator LoadCheckpoint_ResetsFallTracking_SoNoFalseFallDamage()
    {
        // Regression test for TICKET-482: fall damage was firing
        // immediately after a pause-menu checkpoint load.
        var go = new GameObject("Player", typeof(PlayerHealth));
        var health = go.GetComponent<PlayerHealth>();

        health.BeginFalling(fromHeight: 50f);           // a long fall in progress
        health.LoadCheckpoint(new Vector3(0f, 1f, 0f)); // checkpoint on flat ground

        yield return null; // let LateUpdate's fall-damage check run for one frame

        Assert.AreEqual(health.MaxHealth, health.CurrentHealth,
            "Loading a checkpoint mid-fall must not deal fall damage. " +
            "See TICKET-482 -- do not let this regress.");

        Object.Destroy(go);
    }
}

Two details make this a good regression test, not just any test: the comment names the exact ticket the bug was tracked under (so anyone reading a failure later has a paper trail back to the original report), and the failure message states the rule in plain language, not just the raw numbers, so whoever breaks it next understands immediately why it matters, not just that two numbers did not match.

A smoke test is a different, broader kind of automated check: instead of verifying one specific piece of logic in depth, it verifies that the whole game still starts up and reaches a basic playable state at all — "does it still boot," not "is every feature correct." Teams typically run smoke tests automatically on every change, via continuous integration (CI, covered conceptually in an earlier chapter): a server builds the project and runs a headless test the moment code is pushed, without a human needing to launch the game by hand first.

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.TestTools;

public class BootSmokeTests
{
    [UnityTest]
    public IEnumerator Game_BootsAndReachesTheMainMenu()
    {
        SceneManager.LoadScene("Boot");
        yield return null; // let the scene load start

        float elapsed = 0f;
        const float timeout = 10f;
        while (GameObject.Find("MainMenuCanvas") == null && elapsed < timeout)
        {
            elapsed += Time.deltaTime;
            yield return null;
        }

        Assert.IsNotNull(GameObject.Find("MainMenuCanvas"),
            "Main menu did not appear within 10 seconds -- the game likely failed to boot.");
    }
}

Run from the command line in batch mode (no windows, no human at the keyboard, suitable for a CI server), Unity can execute exactly this kind of test headlessly:

Unity -batchmode -nographics -projectPath . -runTests -testPlatform PlayMode -testResults results.xml

A smoke test failing on CI is one of the highest-value signals a team can get: it means whatever change just got pushed broke the game badly enough that it does not even reach the main menu, caught within minutes instead of being discovered by the next person who happens to open the project.

All three kinds of automated test from this chapter form a shape worth remembering, usually drawn as a pyramid: many fast unit tests at the base, fewer, slower Play Mode tests above that, and a small number of broad, slow, whole-game checks (smoke tests, plus manual QA from section 13) at the very top:

Test Pyramid, adapted to a game QA / manual playtests, smoke tests [#####] slowest, fewest, whole game, run before a build ships Play Mode / integration tests [###########] slower, needs the engine and real frames, run often Unit tests on plain C# logic [#######################] fastest, most numerous, run on every save / every commit Games lean flatter than a typical pyramid for other kinds of software: a large share of game code is feel, tuning, and content that section 11 already ruled out as unit-test candidates, so that work shifts up into manual QA instead of down into more unit tests.

Notice that the pyramid is a guide to proportion, not a checklist demanding equal effort at each level — the point is that most of your automated coverage should come cheaply and quickly from the bottom, with the slow, expensive top layer reserved for what nothing else can check.

13. QA in Games: Test Plans, Bug Reports, and Crash Reporting from Real Players

Automated tests catch what you thought to write a test for. QA (quality assurance — the discipline, and often a dedicated team, of finding problems before players do) catches the things nobody thought to test: does this new ability combo with that old one in a way that breaks the economy, does this UI actually make sense to someone who has never seen it before, does the game hold up on a five-year-old phone. This section covers the three pieces of QA a programmer works with directly.

Test Plans

A test plan is a written list of exactly what to check for a given feature, before it is considered done, so testing does not depend on one QA tester happening to remember to try something:

Feature: Checkpoint loading (pause menu)

1. Load a checkpoint while standing on flat ground.
   Expect: no damage, player position matches the checkpoint exactly.
2. Load a checkpoint while mid-fall.
   Expect: no fall damage (see TICKET-482).
3. Load a checkpoint while a status effect (poison) is active.
   Expect: status effect either persists correctly or is clearly
   removed -- not left in a half-applied state.
4. Load a checkpoint immediately after taking damage, before the
   hit-react animation finishes.
   Expect: hit-react does not persist into the reloaded state.
5. Rapidly open the pause menu and load a checkpoint twice in a row.
   Expect: no double-load, no duplicated player object.

Notice step 2 exists specifically because of the bug this chapter has followed the whole way through — a good test plan absorbs every real bug that gets found, so the exact same category of mistake gets checked on every future feature that touches the same system, not just the one that happened to break first.

Bug Reports a Programmer Can Actually Act On

A bug report's entire job is letting a programmer reproduce the bug without talking to whoever filed it. Compare two reports of the same bug:

BAD:
"Fall damage is bugged, please fix."

GOOD:
Title: Fall damage triggers immediately after loading a checkpoint
       mid-fall (TICKET-482)

Build: 1.7.2-dev (commit a1b2c3d)
Platform: Windows, also seen on Switch

Steps to reproduce:
1. Start a new game, let health regenerate to full.
2. Walk off the edge of the bridge in "Docks."
3. While falling, open the pause menu, choose "Load Last Checkpoint."
4. Watch the health bar the instant the level finishes loading.

Expected: health stays full (checkpoint is on flat ground).
Actual: health drops by 35-50 instantly.

Frequency: 10/10, always reproduces with these exact steps.
Does NOT reproduce when respawning from the death screen instead
of the pause menu (only the checkpoint-load path is affected).

Attached: log file (console_log.txt), 15-second video clip.
Severity: High (breaks a core traversal loop). Priority: next patch.

The good version is, almost word for word, the repro from section 7 with a few extra fields: which exact build the bug was seen on (a commit hash or version number, so nobody wastes time debugging a version that already changed), which platform, how often it happens, and what does not trigger it. That last field alone can save a programmer hours — it is a hypothesis half-formed for them already, for free, before they open the debugger.

Common mistake Filing a bug with only "it doesn't work" and no steps, or worse, no build/commit information at all. If a bug cannot be reproduced from the report alone, the programmer has to either reproduce it from scratch (redoing the QA tester's work) or ask a round of clarifying questions before starting — both of which cost far more total time than writing five extra lines in the original report would have.

Crash Reporting from Real Players

Once a game has shipped, most crashes are never seen by anyone on the team directly — they happen on a player's machine, in a configuration nobody tested, and the player is unlikely to file a detailed bug report. A crash reporting service catches the crash automatically, captures a stack trace (the same call-stack idea from section 5, captured automatically at the moment of the crash instead of by a programmer stepping through live), and uploads it, so the team learns about crashes they would otherwise never hear about.

The catch: a shipped build is usually stripped and sometimes compiled through an intermediate step (Unity's IL2CPP backend converts C# to C++ and then to native machine code for many platforms), so a raw crash stack trace from a player's machine often shows meaningless addresses or mangled names instead of readable function names and line numbers:

Raw crash report from a player's device (unreadable on its own): #0 0x1a2b3c4d in ??? #1 0x1a2b41f0 in ??? #2 0x1a2b5502 in ??? Same crash, AFTER symbolication (matched against the symbol file for that exact build): #0 PlayerHealth.TakeFallDamage() PlayerHealth.cs:44 #1 PlayerHealth.LateUpdate() PlayerHealth.cs:19 #2 UnityEngine.Object (internal)

Symbolication is exactly this translation step: matching raw memory addresses back to human-readable function names and source lines, using a symbol file generated at build time (comparable in spirit to the debug info the -g flag adds for gdb back in the C chapters) that maps addresses to names for that exact build and platform. This is why build pipelines archive symbol files for every shipped build, keyed by version or commit hash: without the matching symbol file for the exact build that crashed, a raw stack trace from a player is close to useless, just a list of numbers.

Once symbolicated, crash reports usually get grouped automatically by which function crashed and why (a null reference at the same line, across thousands of players, is one bug, not thousands), ranked by how many players hit each one — which turns "some players occasionally crash for unknown reasons" into a prioritized, readable list: fix the crash affecting 40,000 sessions before the one affecting twelve.

Tip Everything from earlier in this chapter still applies to a crash report from a real player: it is a call stack (section 5), it deserves a hypothesis before a fix (section 1), and if it does not reproduce locally, the report itself — platform, build, frequency — is the closest thing you have to a repro (section 7) until you can build one yourself.

14. Glossary

15. Exercises

Exercise 1 A tester reports: "sometimes when I land a critical hit, the bonus damage seems to apply twice — the number that pops up is way higher than it should be, but only occasionally." Using the hypothesis loop from section 1, write out: (a) one specific, falsifiable belief about the cause, (b) a fact that must be true if that belief is correct, and (c) the smallest test you would run to check it (a debugger step, a log line, or a unit test — describe which, and what exactly you would check).
Show answer

(a) Belief: "I believe the critical-hit multiplier is being applied twice on some frames because two different systems both call the damage function for the same hit — for example, a melee hit-detection script and a separate combo-tracking script that both react to the same collision."

(b) Prediction: "If that is true, then on a frame where the bug shows up, CalculateDamage (or whatever applies the critical multiplier) must be called twice for the same single hit, not once. On a normal frame, it should only be called once."

(c) Smallest test: Set a breakpoint (or, since it is intermittent and hard to catch by hand, a context-rich log line, since the section 6 technique of "context, not here1") inside the damage function, logging the calling context and a hit ID each time it runs: Debug.Log($"[Damage] hitId={hitId} frame={Time.frameCount} isCritical={isCritical}"). Play until the bug shows up, then read the log: if the same hitId appears twice on the same or adjacent frames, the belief is confirmed — two call sites are reacting to one hit. If each hit only ever logs once, the belief is wrong, and the real cause is somewhere else (perhaps the multiplier value itself is wrong, not the call count) — time to form a new belief.

Exercise 2 Movement became frame-rate dependent somewhere in the last 16 commits between tag v2.0 and the current HEAD. Write the exact git bisect commands you would run to start the search and mark the two known endpoints. Then explain roughly how many times you would need to test a commit by hand to find the exact bad one, and describe how you would automate the whole search with git bisect run instead, assuming you already have a script check_speed.sh that exits 0 when movement speed is frame-rate independent and 1 when it is not.
Show answer
git bisect start
git bisect bad HEAD
git bisect good v2.0

With 16 commits between the two endpoints, each test roughly halves the remaining suspects: 16 -> 8 -> 4 -> 2 -> 1, so about 4 manual tests (log2(16) = 4) is enough to land on the exact first bad commit, instead of testing up to 16 commits one at a time.

To automate it fully instead of testing each candidate by hand:

git bisect start
git bisect bad HEAD
git bisect good v2.0
git bisect run ./check_speed.sh

Git checks out each candidate commit automatically, runs check_speed.sh against it, reads the script's exit code (0 means good, non-zero means bad), and keeps narrowing without any manual rebuilding or play-testing, finishing with the same "first bad commit" report at the end. Afterward, git bisect reset returns the working tree to the branch you started on.

Exercise 3 Given this pure C# function with no UnityEngine dependency:
public static class ArmorMath
{
    public static int ApplyArmor(int incomingDamage, int armor)
    {
        int reduced = incomingDamage - armor;
        return System.Math.Max(reduced, 1);
    }
}
Write at least three [Test] methods in a Unity Test Framework EditMode test class that cover: (1) a normal case where armor partially reduces damage, (2) a case where armor is greater than the incoming damage, and (3) a case with zero armor. State what each test asserts and why.
Show answer
using NUnit.Framework;

public class ArmorMathTests
{
    [Test]
    public void PartialArmor_ReducesDamageByArmorAmount()
    {
        int result = ArmorMath.ApplyArmor(incomingDamage: 20, armor: 6);
        Assert.AreEqual(14, result); // 20 - 6
    }

    [Test]
    public void ArmorGreaterThanDamage_ClampsToMinimumOfOne()
    {
        int result = ArmorMath.ApplyArmor(incomingDamage: 5, armor: 50);
        Assert.AreEqual(1, result); // 5 - 50 would be negative, clamped to 1
    }

    [Test]
    public void ZeroArmor_PassesDamageThroughUnchanged()
    {
        int result = ArmorMath.ApplyArmor(incomingDamage: 20, armor: 0);
        Assert.AreEqual(20, result); // nothing to subtract
    }
}

The first test checks the ordinary subtraction path with a plainly predictable result. The second test checks the clamp: without it, heavy armor would produce a negative or zero "hit" that heals or does nothing, so this test locks in the rule that a landed hit always deals at least 1 damage. The third test checks the boundary where armor contributes nothing at all, confirming the function does not accidentally clamp or alter damage when there is no armor to apply. Together, the three tests cover the normal path and both edges of the function's behavior, which is exactly the shape a small, pure function like this deserves — fast, deterministic, and requiring no scene or GameObject to run.

That covers both halves of finding and keeping bugs fixed: the hypothesis loop that turns debugging from guessing into a directed search, binary search across code and across commit history, using a debugger's breakpoints, conditional breakpoints, watches, and call stack — the same ideas you already know from gdb, just with different buttons — logging with real context and sensible levels, getting a reliable repro before touching a fix, and recognizing uninitialized data, races, and frame-rate dependence as the three usual suspects behind an intermittent bug. On the testing side: unit tests for pure logic, Play Mode tests for anything that needs the engine alive, knowing what genuinely is not worth testing, regression tests that keep a fixed bug fixed, automated smoke tests that catch a broken build within minutes, and the QA process — test plans, actionable bug reports, and crash reporting — that catches everything no test was written for. None of these tools replace careful thinking. They just make sure the thinking you already did does not have to be redone every time the same bug tries to come back.

← Back to all chapters