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.
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.
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.
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.
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:
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.
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.
git bisectBinary 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:
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.
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.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:
These map directly onto gdb commands you already know the shape of, plus the Visual Studio keys shown here for reference:
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.
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.
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.
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.
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.
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:
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.
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.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.
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.
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.
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.
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.
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.
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.
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."
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.
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:
Physics.Raycast hits a collider, only that your code reacts correctly once it does.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.
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:
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.
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.
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.
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.
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:
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.
git bisect — Git's built-in binary search over commit history, used to find the exact commit that introduced a bug.[UnityTest] — the attribute (combined with an IEnumerator return type) that lets a test yield across real frames.(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.
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.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.
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.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.