Skip to main content
Published / updated

Debugging and Code Quality

Before you start

You need: everything from Articles 01–08. This article debugs the code you have been writing.

Time: about 55 minutes, plus the practice. Do it at the keyboard — reading about a debugger teaches nothing.

Learning objective

Find the line where a value first goes wrong using the debugger, and write C# a colleague can read and change safely.

Topics

  • Reading an exception and a stack trace
  • Breakpoints and stepping
  • Conditional breakpoints
  • Locals, Watch and Immediate
  • The Call Stack
  • The three beginner errors — null, index, parse
  • First-chance exceptions
  • Naming and method size
  • Guard clauses
  • Writing a first unit test

Reading an exception

Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at NexCoding.SchoolConsole.Services.FeeService.CalculateBalance(Int32 studentId) in D:\src\Services\FeeService.cs:line 47
at NexCoding.SchoolConsole.Program.Main(String[] args) in D:\src\Program.cs:line 28

Four facts, stated outright:

FactValue
TypeNullReferenceException — something was null
WhereFeeService.cs, line 47
Called fromProgram.Main, line 28
InputstudentId

Go to line 47 and ask which reference on that line can be null.

// line 47
return account.TotalFees - account.DiscountAmount - account.PaidAmount;

account is null — no fee account exists for that student. The exception was never mysterious.

A stack trace reads bottom to top in call order. The bottom is where execution started; the top is where it failed. Find the topmost frame in your own code — framework frames above it are noise.

Read the inner exception. In .NET the useful one is often several layers down:

Exception root = ex;

while (root.InnerException != null)
{
root = root.InnerException;
}

Console.WriteLine(root.Message);

Search the exact message minus your own identifiers. Search Sequence contains no elements, not Sequence contains no elements for student NCA-2024-0012.

Breakpoints and stepping

KeyActionUse when
F9Toggle breakpoint
F5ContinueMove to the next breakpoint
F10Step overThe next line is a call you trust
F11Step intoYou suspect the call
Shift+F11Step outYou stepped somewhere irrelevant
Ctrl+F10Run to cursorSkip ahead without a breakpoint

The default should be F10. Stepping into every call, including framework code, wastes the session.

Set the breakpoint before the failing line, not on it.

public decimal CalculateBalance(int studentId)
{
FeeAccount account = _repository.GetByStudentId(studentId); // breakpoint here
return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}

Break on line 2 and account is already null with no clue why. Break on the lookup and you can step over it and watch it return null.

A hollow breakpoint that never hits means the running build is stale. Rebuild.

Conditional breakpoints

The feature that makes debugging real data practical.

Right-click a breakpoint → Conditions:

studentId == 12
student.RollNumber == "NCA-2024-0012"
account == null
marks < 0
i > 500

A loop over 800 students that fails on one: without a condition you press F5 hundreds of times. With studentId == 12 you arrive at the failure immediately.

TypeEffect
Conditional expressionBreaks when the expression is true
Hit countBreaks on the 250th pass
Action / tracepointLogs a message and continues — no break

A tracepoint is Console.WriteLine without editing the code. Message with {studentId} and {account?.TotalFees}, tick "Continue execution", and you get a log of every iteration with no rebuild. VS Code calls it a logpoint.

Locals, Watch and Immediate

WindowPurpose
LocalsEvery variable in scope, automatically
AutosVariables near the current line
WatchExpressions you choose to track
ImmediateEvaluate anything, including method calls

Watch takes expressions, not just names:

account?.TotalFees
students.Count(s => s.Status == StudentStatus.Active)
results.Count(r => r.IsAbsent)
results.Count(r => r.MarksObtained == 0)

That last one is a live check for the absent-stored-as-zero bug — if it returns anything above zero, the data is wrong before the code is.

Immediate window:

> account
null
> _repository.GetByStudentId(13)
{FeeAccount}
> students.Count
800
> students.Where(s => s.RollNumber == "NCA-2024-0012").ToList()
Count = 0

The Immediate window calls methods with different arguments, which tests a hypothesis without restarting: does the lookup work for student 13 but not 12?

The Call Stack

FeeService.CalculateBalance(studentId: 0) ← current, and the argument is wrong
Program.ProcessMenuChoice(choice: "2")
Program.Main(args: {string[0]})

The Call Stack answers "how did I get here?" Double-click a frame to inspect that method's locals.

The bug is often two frames above the exception. studentId arriving as 0 means the caller parsed it wrong — the failing method merely received it.

The three beginner errors

Null

FeeAccount account = _repository.GetByStudentId(studentId);
return account.TotalFees; // NullReferenceException
FeeAccount account = _repository.GetByStudentId(studentId);

if (account == null)
{
throw new StudentNotFoundException(studentId);
}

return account.TotalFees;

Anything that looks something up can return nothing. FirstOrDefault, a dictionary lookup, a repository call — check every one.

Index

string[] fields = line.Split(',');
string section = fields[3]; // IndexOutOfRangeException on a short row
if (fields.Length < 4)
{
Console.WriteLine($"Row skipped: expected 4 fields, found {fields.Length}.");
continue;
}

Check the length before indexing anything that came from outside your program.

Parse

int marks = int.Parse(Console.ReadLine()); // FormatException
int marks;

if (!int.TryParse(Console.ReadLine(), out marks))
{
Console.WriteLine("Enter a whole number between 0 and 100.");
return;
}

TryParse for anything a user typed. These three account for the large majority of beginner crashes, and each has a two-line fix.

First-chance exceptions

Debug → Windows → Exception Settings, tick Common Language Runtime Exceptions.

The debugger then breaks where an exception is thrown, not where it is caught.

try
{
return CalculateBalance(studentId);
}
catch (Exception)
{
return 0m; // the bug is now invisible
}

A swallowed exception is the hardest bug to find, because there is no error anywhere — just a wrong number. With first-chance exceptions on, the debugger stops at the actual throw site and the cause is immediately visible.

Turn it on when a value is wrong and nothing is logged. Turn it off afterwards — it also breaks on exceptions the framework handles normally.

Naming and method size

// Unreadable
public decimal Calc(int a, int b, decimal c)
{
decimal d = c * a;
decimal e = d - b;
return e;
}

// Readable
public decimal CalculateOutstandingFees(int monthsEnrolled, decimal amountPaid, decimal monthlyFee)
{
decimal totalDue = monthlyFee * monthsEnrolled;
decimal outstanding = totalDue - amountPaid;

return outstanding;
}

Naming is the highest-leverage quality decision in the file. The second version needs no comment.

GuidelineReason
Methods do one thingTestable, reusable, reviewable
Under ~30 linesFits in one screen and one thought
Under 4 parametersMore means the parameters belong in a class
Name says what, not howThe how can change
No abbreviationsstudentId not sid
No magic numbersconst decimal MaximumMarks = 100m;

Comments explain why, never what.

// Bad — restates the code
// add the amount to the paid amount
account.PaidAmount = account.PaidAmount + amount;

// Good — explains a decision
// Absent students carry NULL marks, not 0, so the class average is not
// dragged down. The absent check must therefore come before any comparison.

Guard clauses

// Nested — hard to follow
public decimal CalculateBalance(FeeAccount account)
{
if (account != null)
{
if (account.TotalFees > 0)
{
if (account.PaidAmount >= 0)
{
return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}
}
}

return 0m;
}

// Guard clauses — flat, and the happy path is last and obvious
public decimal CalculateBalance(FeeAccount account)
{
if (account == null)
{
throw new ArgumentNullException(nameof(account));
}

if (account.TotalFees <= 0)
{
throw new InvalidOperationException("Fee account has no total fees set.");
}

if (account.PaidAmount < 0)
{
throw new InvalidOperationException("Paid amount cannot be negative.");
}

return account.TotalFees - account.DiscountAmount - account.PaidAmount;
}

Handle the invalid cases first and return or throw immediately. Every line after the guards can assume valid input, and the nesting disappears.

Note the second version also stopped returning 0m for an invalid account — which was the silently-wrong-answer bug from Article 07 hiding inside a formatting problem.

A first unit test

Adding a test project is a four-step job in Solution Explorer, and the step people miss is the third.

  1. Right-click the solution — the top line, not the project — → AddNew Project.
  2. Search for xUnit Test Project, pick the C# one, Next. Name it SchoolConsole.Tests. Create.
  3. In the new test project, right-click Dependencies → Add Project Reference, tick SchoolConsole, OK.
  4. Test → Run All Tests, or Ctrl+R, A.

Step 3 is the one that gets forgotten, and the symptom is confusing: the test project builds fine on its own, then GradingService is reported as not existing. Without the reference, the test project cannot see your code at all.

Right-click the solution, not the project. Adding a project to a project nests it in the wrong place, and Test Explorer will not find the tests.

Test Explorer

View → Test Explorer shows every test in the solution.

Column / controlMeans
Green tickPassed
Red crossFailed — click it to read the assertion message
Blue exclamationSkipped
Run All TestsCtrl+R, A
Debug All TestsRuns them with breakpoints active

Debug All Tests is the point where debugging and testing meet. Put a breakpoint inside GetResult, debug the absent-student test, and step through the exact case that is failing — far faster than adding print statements.

If Test Explorer is empty, build the solution first (Ctrl+Shift+B). It lists tests from the last successful build.

using Xunit;

public class GradingServiceTests
{
[Fact]
public void GetResult_AbsentStudent_ReturnsAbsent()
{
// Arrange
ExamResult result = new ExamResult();
result.IsAbsent = true;
result.MarksObtained = null;

Subject subject = new Subject();
subject.PassingMarks = 35;

GradingService service = new GradingService();

// Act
string outcome = service.GetResult(result, subject);

// Assert
Assert.Equal("Absent", outcome);
}

[Theory]
[InlineData(90, "Pass")]
[InlineData(35, "Pass")]
[InlineData(34, "Fail")]
[InlineData(0, "Fail")]
public void GetResult_PresentStudent_ReturnsExpectedOutcome(int marks, string expected)
{
ExamResult result = new ExamResult();
result.IsAbsent = false;
result.MarksObtained = marks;

Subject subject = new Subject();
subject.PassingMarks = 35;

GradingService service = new GradingService();

string outcome = service.GetResult(result, subject);

Assert.Equal(expected, outcome);
}
}

Arrange, Act, Assert. Set up the data, call the thing, check the result.

[Theory] with [InlineData] runs one test body over several inputs. Note 35 and 34 — testing the boundary either side of the passing mark, which is where off-by-one errors live.

The absent test is the one that matters. Written from the requirement, it fails immediately against the wrong condition order from Article 03 — which is exactly what a test is for.

Verify every test fails without its fix. Comment out the implementation, run, watch it fail, restore. A test that passes either way tests nothing.

Errors you will hit

What you seeCauseFix
Breakpoint is a hollow circle and never hitsThe running build is stale, or the project is not the startup projectRebuild (Ctrl+Shift+B); right-click the project → Set as Startup Project
Test Explorer is emptySolution not built since the tests were writtenBuild, then reopen Test Explorer
CS0246: The type or namespace name 'GradingService' could not be found in the test projectMissing project referenceRight-click Dependencies → Add Project Reference
CS0234: The type or namespace name 'Xunit' does not existWrong template, or packages not restoredCreate an xUnit Test Project; Build → Rebuild Solution
Tests pass whether or not the code is rightThe test asserts what the code does, not the requirementRewrite the test from the requirement
A value is wrong but nothing throwsAn exception is being swallowedDebug → Windows → Exception Settings → tick CLR Exceptions

A hollow breakpoint means the debugger and your source disagree. Nine times in ten a rebuild fixes it.

Common mistakes

  • Guessing instead of setting a breakpoint
  • Breaking on the failing line rather than before it
  • F11 into framework code
  • Debugging a stale build and blaming the debugger
  • Ignoring the Call Stack when the argument is already wrong
  • Console.WriteLine where a conditional breakpoint is faster
  • Catch blocks that swallow exceptions
  • Methods doing four things
  • Abbreviated names and magic numbers
  • Comments restating the code
  • Deep nesting where guard clauses belong
  • Tests written from the code instead of the requirement

Practice

  1. Trigger a NullReferenceException, read the trace, and name the null reference before opening the file.
  2. Wrap an exception twice and write the loop that finds the innermost one.
  3. Set a breakpoint before a lookup and step over it, watching the return value.
  4. Loop over 800 students and set a conditional breakpoint on studentId == 12.
  5. Set a hit-count breakpoint that stops on the 250th iteration.
  6. Set a tracepoint that logs {studentId} and continues, then read the output.
  7. Add a Watch counting results.Count(r => r.MarksObtained == 0) and use it to detect absent-as-zero data.
  8. Use the Immediate window to call a repository method with a different id.
  9. Break in a method called from three places and use the Call Stack to find which caller passed 0.
  10. Trigger the null, index and parse errors in turn, then fix each with the two-line guard.
  11. Write a catch block that swallows an exception, then find the throw site with first-chance exceptions.
  12. Rename Calc(int a, int b, decimal c) and its variables until the comment above it becomes unnecessary.
  13. Rewrite a triply-nested method using guard clauses, and confirm you removed a return 0m that hid a failure.
  14. Add an xUnit Test Project to the solution, add a project reference to the console project, and run the tests from Test Explorer (Ctrl+R, A).
  15. Write the absent-student test. Run it against the wrong condition order from Article 03 and watch it fail.
  16. Fix the order and watch it pass.
  17. Add a [Theory] covering 90, 35, 34 and 0.
  18. Comment out the fix and confirm the absent test fails again.

Exercises 15, 16 and 18 are the loop that makes a test worth having.

You can now

  • Read an exception message and a stack trace for what they state
  • Set a conditional breakpoint and reach a failure on one record
  • Use Locals, Watch, Immediate and the Call Stack
  • Find a swallowed exception with Exception Settings
  • Add an xUnit project, reference it correctly, and run it in Test Explorer
  • Write a test from the requirement and prove it fails without the fix
  • Rewrite a nested method using guard clauses

Review questions

  1. Why set a breakpoint before the failing line rather than on it?
  2. What does enabling first-chance exceptions let you find?
  3. What do guard clauses give you besides less nesting?
  4. Why must a test be written from the requirement rather than from the code?

Next: Guided console project