Skip to main content

πŸ“ Lesson 5.2: Debugging and the Unity Test Framework

Fast code (Lesson 5.1) still has to be correct. This lesson covers the two halves of reliability: debugging β€” finding and fixing bugs with logging, Gizmos, and breakpoints β€” and testing β€” writing automated checks with the Unity Test Framework so bugs stay fixed as your project grows.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Use Debug.Log/LogWarning/LogError effectively (with context objects)
  • Visualize logic in the Scene view with Debug.DrawRay and Gizmos
  • Attach a debugger and use breakpoints to inspect runtime state
  • Diagnose the classic Unity bugs (null/missing references, execution order)
  • Write Edit Mode and Play Mode tests with the Unity Test Framework
  • Structure code so gameplay logic is testable, separate from MonoBehaviours

Estimated Time: 60 minutes

Project: Debug a broken component with logging and Gizmos, then write unit tests for a pure-C# scoring rule.

In This Lesson

Debugging Mindset

Debugging is a science, not luck: form a hypothesis about what's wrong, gather evidence, and narrow it down. Unity gives you tools for each step β€” the Console for messages, Gizmos for seeing invisible logic, and a debugger for freezing time and inspecting state.

graph LR O["Observe the bug"] --> H["Hypothesis:
what could cause it?"] H --> E["Gather evidence
(log, gizmo, breakpoint)"] E --> N["Narrow down
the cause"] N --> F["Fix & verify"] F -->|"still wrong?"| H style E fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style F fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
πŸ’‘ The most common Unity bug isn't in your logic β€” it's a reference that's null, an event function that's misspelled (Lesson 1.2), or code running in the wrong order. Before suspecting complex logic, check the simple things: Is the reference assigned in the Inspector? Is the object active? Did Awake run before this?

Logging Well

The Debug class writes to the Console. Three severity levels help you scan output β€” and errors/warnings can be filtered separately:

Debug.Log("Player spawned");                     // white β€” info
Debug.LogWarning("Health below 10%");            // yellow β€” attention
Debug.LogError("Missing weapon reference!");     // red β€” a real problem

// Pass 'this' (or any Object) as a second argument: clicking the message
// pings that object in the Hierarchy β€” invaluable for "which one broke?".
Debug.LogError("Target not assigned", this);

βœ… Make logs useful, then remove them

Include values, not just labels: Debug.Log($"Health: {current}/{max}") beats Debug.Log("took damage"). Log state at decision points to see what the code actually saw. But logging every frame floods the Console and even allocates strings (Lesson 5.1) β€” remove or guard temporary logs when you're done. Wrap verbose logging in [Conditional("DEBUG")] methods or an if (verbose) flag so it's off in release.

⚠️ Read the whole error, including the stack trace

A NullReferenceException in the Console expands to a stack trace β€” the chain of calls that led to it. Click it: it shows the exact file and line. Beginners often panic at the red text; the fix is usually right there in the first line of the trace ("object reference not set" + the line that dereferenced null).

Visual Debugging: Gizmos

Some bugs are spatial β€” a detection radius that's wrong, a ray pointing the wrong way, a patrol path off by a bit. Numbers in the Console won't reveal these; drawing them will. Unity offers two visual tools:

ToolWhereUse for
Debug.DrawRay / DrawLineScene view (and Game view if Gizmos on)Quick rays/lines from inside Update
OnDrawGizmos()Scene view, always (even when not playing)Persistent shapes β€” radii, bounds, waypoints
using UnityEngine;

public class EnemyVision : MonoBehaviour
{
    [SerializeField] private float viewDistance = 10f;
    [SerializeField] private float detectRadius = 3f;

    void Update()
    {
        // Draw the forward view ray each frame (red), visible in the Scene view.
        Debug.DrawRay(transform.position, transform.forward * viewDistance, Color.red);
    }

    // Gizmos draw in the editor even when not playing β€” great for tuning.
    void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(transform.position, detectRadius);   // see the detection range
    }
}

πŸ’‘ Gizmos make invisible logic visible

A DrawWireSphere at your detection radius instantly shows if it's too big or too small β€” no guessing at numbers. OnDrawGizmos runs always; OnDrawGizmosSelected only when the object is selected (less clutter). These calls are editor-only and cost nothing in a build. When a spatial thing "doesn't work," draw it before you theorize.

Breakpoints & Common Bugs

Logging shows values after the fact; a breakpoint freezes the game at a line so you can inspect everything β€” variable values, the call stack, and step through line by line. Attach your IDE (Visual Studio / Rider) to Unity ("Attach to Unity"/"Play"), set a breakpoint by clicking the gutter, and trigger the code.

βœ… Breakpoint vs Debug.Log

Use logging for a quick trace or something that happens fast/often. Use a breakpoint when you need to poke around a specific moment β€” inspect several variables, check the call stack ("who called this?"), or step through branching logic. Breakpoints don't require editing and re-running code, but they pause the whole editor.

Most Unity bugs fall into a few recognizable buckets. Knowing the pattern speeds the fix:

SymptomLikely causeCheck
NullReferenceExceptionReference never assigned, or lookup failedInspector slot filled? GetComponent found it? (Lesson 1.3)
MissingReferenceExceptionThe object was destroyed but still referencedGuard with != null; unsubscribe events (Lesson 3.2)
Event function never runsTypo / wrong case / wrong signatureExact name & params (Lesson 1.2); is the object active?
Value is wrong on frame 1Read in Start before another AwakeTwo-phase startup order (Lesson 1.2)
Works in Editor, breaks in buildEditor-only API, or a missing scene/assetScene in Build Settings? (Lesson 4.1)

The Unity Test Framework

Debugging fixes a bug once. A test makes sure it stays fixed. The Unity Test Framework (UTF) builds on NUnit β€” the same framework from the Intermediate C# course β€” and runs from Window β†’ General β†’ Test Runner. There are two kinds:

graph TD UTF["Unity Test Framework"] UTF --> EM["Edit Mode tests
run without Play mode
(fast β€” pure logic)"] UTF --> PM["Play Mode tests
run in a live scene
(MonoBehaviours, physics, frames)"] style EM fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px style PM fill:#eff6ff,stroke:#3b82f6,stroke-width:2px

An Edit Mode test is a plain NUnit test β€” perfect for pure C# logic. It follows the Arrange–Act–Assert pattern you learned before:

using NUnit.Framework;

public class ScoreRulesTests
{
    [Test]
    public void ComboMultiplier_DoublesScore_AtThreeHits()
    {
        // Arrange
        var rules = new ScoreRules();

        // Act
        int score = rules.Points(baseValue: 100, comboHits: 3);

        // Assert
        Assert.AreEqual(200, score);
    }
}

A Play Mode test runs in a real scene and can span frames using [UnityTest] with an IEnumerator (the coroutine shape from Lesson 3.3) β€” so you can yield and check results after time passes:

using System.Collections;
using UnityEngine;
using UnityEngine.TestTools;   // [UnityTest]
using NUnit.Framework;

public class MovementTests
{
    [UnityTest]
    public IEnumerator Player_MovesForward_OverTime()
    {
        // Arrange: build a live object with the component under test.
        var go = new GameObject();
        var mover = go.AddComponent<PlayerMover>();
        Vector3 start = go.transform.position;

        // Act: let a few frames pass.
        yield return new WaitForSeconds(0.5f);

        // Assert: it actually moved.
        Assert.Greater(go.transform.position.z, start.z);
    }
}

πŸ’‘ Edit Mode vs Play Mode β€” pick the cheaper one

Prefer Edit Mode tests: they're fast, don't start Play mode, and are ideal for logic (scoring, inventory, state rules). Use Play Mode only when you truly need the running engine β€” physics, coroutines, real component interaction. Tests live in a special test assembly (the Test Runner can create the folder/asmdef for you).

Writing Testable Code

Here's the payoff of good architecture: logic that doesn't depend on Unity is trivial to test. A scoring rule buried inside a MonoBehaviour needs a running scene to test; the same rule in a plain C# class can be tested in milliseconds, in Edit Mode.

// ❌ Hard to test: logic tangled with MonoBehaviour + Unity state.
public class ScoreManager : MonoBehaviour
{
    private int score;
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
            score += 100 * (comboHits >= 3 ? 2 : 1);   // rule hidden in Update
    }
}
// βœ… Easy to test: the RULE is a pure class; the MonoBehaviour just calls it.
public class ScoreRules   // no MonoBehaviour β€” plain C#, unit-testable
{
    public int Points(int baseValue, int comboHits)
        => baseValue * (comboHits >= 3 ? 2 : 1);
}

public class ScoreManager : MonoBehaviour
{
    private readonly ScoreRules rules = new();
    private int score;

    public void RegisterHit(int comboHits) => score += rules.Points(100, comboHits);
}

βœ… Separate logic from the engine

This echoes the ScriptableObject lesson's "data vs behavior" split and 5.1's testable-code note: keep decisions and rules in plain C# classes, and let MonoBehaviours handle Unity glue (input, references, lifecycle). You get code that's easier to test, reuse, and reason about β€” and Edit Mode tests that run instantly.

πŸ’‘ What to test first: your logic β€” scoring, inventory limits, damage formulas, save/load round-trips, state-machine transitions (Lesson 4.1). These are where subtle bugs hide and where a fast Edit Mode test pays off every time you refactor. You don't need 100% coverage; test the rules that would hurt if they broke.

Exercise & Quiz

πŸ‹οΈ Exercise: Debug, then Test

Objective: Practice both halves β€” visualize a bug with Gizmos, then lock a rule down with a unit test.

Instructions:

  1. Add the EnemyVision script (Section 3) to an object. Use OnDrawGizmosSelected to draw its detection radius and Debug.DrawRay for its view direction; tune the values by seeing them in the Scene view.
  2. Extract a pure-C# ScoreRules class with int Points(int baseValue, int comboHits) that doubles the score at 3+ combo hits.
  3. Open Window β†’ General β†’ Test Runner, create an Edit Mode test assembly, and write tests for: 1 hit (no bonus), 3 hits (doubled), and 5 hits (doubled).
  4. Run the tests β€” all green. Then change the rule to a bug (e.g. > 3 instead of >= 3) and watch the 3-hit test go red, proving the test catches it.
  5. Fix it back to green. You now have a rule that can't silently break again.

Starter Code:

public class ScoreRules
{
    // TODO: double the base value when comboHits is 3 or more.
    public int Points(int baseValue, int comboHits) => baseValue;
}
using NUnit.Framework;

public class ScoreRulesTests
{
    // TODO: [Test] methods for 1 hit, 3 hits, 5 hits using Assert.AreEqual.
}
πŸ’‘ Hint

Rule: baseValue * (comboHits >= 3 ? 2 : 1). Each test: Arrange var rules = new ScoreRules();, Act int result = rules.Points(100, hits);, Assert Assert.AreEqual(expected, result);. Expected: 1 hit β†’ 100, 3 hits β†’ 200, 5 hits β†’ 200.

βœ… Solution
public class ScoreRules
{
    public int Points(int baseValue, int comboHits)
        => baseValue * (comboHits >= 3 ? 2 : 1);
}
using NUnit.Framework;

public class ScoreRulesTests
{
    [Test]
    public void OneHit_NoBonus()
    {
        var rules = new ScoreRules();
        Assert.AreEqual(100, rules.Points(100, 1));
    }

    [Test]
    public void ThreeHits_Doubled()
    {
        var rules = new ScoreRules();
        Assert.AreEqual(200, rules.Points(100, 3));
    }

    [Test]
    public void FiveHits_Doubled()
    {
        var rules = new ScoreRules();
        Assert.AreEqual(200, rules.Points(100, 5));
    }
}

Because ScoreRules is plain C#, these are fast Edit Mode tests β€” no Play mode, no scene. Break the rule and a test turns red immediately; that's a regression caught before it ever reaches the game.

🎯 Quick Quiz

Question 1: What's the advantage of passing this to Debug.LogError("...", this)?

Question 2: You want to test a pure scoring formula quickly, without starting the game. Which test type fits?

Question 3: Why extract a scoring rule into a plain C# class instead of leaving it in a MonoBehaviour's Update?

Summary

πŸŽ‰ Key Takeaways

  • Debug scientifically: observe β†’ hypothesize β†’ gather evidence β†’ narrow β†’ fix. Suspect the simple causes (null refs, typos, order) first.
  • Debug.Log/LogWarning/LogError β€” include values, pass this as context to ping the object, and read the full stack trace. Remove noisy temporary logs.
  • Use Gizmos (OnDrawGizmos) and Debug.DrawRay to make spatial logic visible; use breakpoints to freeze and inspect runtime state.
  • Know the classic Unity bug patterns: NullReference/MissingReference, misspelled event functions, execution-order surprises, Editor-vs-build differences.
  • The Unity Test Framework (NUnit-based): Edit Mode tests for fast pure logic, Play Mode ([UnityTest]) for engine behavior across frames. Follow Arrange–Act–Assert.
  • Keep logic in plain C# classes, MonoBehaviours as glue β€” testable, reusable, and easy to reason about.

πŸ“š Additional Resources

πŸš€ What's Next?

You can build fast, correct, well-tested Unity C#. Time to put it all together. Lesson 5.3, the Capstone Project, guides you through building a complete gameplay system β€” components, lifecycle, input, physics, data, events, managers, UI, save/load, and performance β€” everything from this course in one cohesive build.

πŸŽ‰ Reliable and verified!

You can now hunt down any bug and write tests that keep it dead. That confidence is what lets you build big without fear of breaking what works.