Unit Testing Mastery

Reviewed & published by Brayan K

By the end of this lesson you'll be able to write unit tests that pin down a method's behaviour, structure every test with Arrange-Act-Assert, name tests so failures read like sentences, and drive many cases from one method with [Theory] and [InlineData] — the safety net that lets you change code without fear.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

A car factory doesn't bolt a fuel pump into a car and then go for a drive to find out if it works. It clamps the pump onto a test bench, feeds it a known input, and measures the output — in isolation, away from the engine, the wiring, and the road. If the pump fails, you know instantly it's the pump, not the gearbox. A unit test is that bench: it takes one small piece of your code, feeds it a known input, and checks the output, with everything else stripped away. When a test goes red you know exactly which part broke — long before the whole car (your app) is on the motorway.

The three .NET test frameworks

The three big .NET test frameworks all do the same job — mark a method as a test, run it, and assert on results. The vocabulary differs, but the ideas transfer one-to-one. This lesson uses xUnit (the modern default for new projects), with the NUnit and MSTest equivalents shown alongside so you can read any codebase.

ConceptxUnitNUnitMSTest
Single test[Fact][Test][TestMethod]
Data-driven test[Theory] + [InlineData][TestCase][DataRow]
Test class marker(none needed)[TestFixture][TestClass]
Run before each testconstructor[SetUp][TestInitialize]
Run after each testDispose()[TearDown][TestCleanup]
Equality assertAssert.Equal(exp, act)Assert.AreEqual(exp, act)Assert.AreEqual(exp, act)
Exception assertAssert.Throws<T>(...)Assert.Throws<T>(...)Assert.ThrowsException<T>

One quirk to note: in xUnit's Assert.Equal the expected value comes first; NUnit and MSTest also put expected first in AreEqual. Getting the order right is what makes the failure message read correctly.

1. Why Test, and the Arrange-Act-Assert Shape

A unit test is automated code that runs one small piece of your program — a single method, the "unit" — with a known input and checks it produces the expected output. You write them so a computer re-verifies your logic in seconds every time you change something, instead of you clicking through the app by hand. Every good test follows the same three-part shape: Arrange the inputs, Act by calling the one method under test, then Assert the result is what you expect. Name the test Method_Scenario_Expected so a red result tells the whole story without opening the file. Read this worked example — it hand-rolls a tiny AssertEqual so you see exactly what an assertion does — then run it.

using System;

class Program
{
    // The "unit" under test — a tiny pure function we want to verify.
    static int Add(int a, int b) => a + b;

    // A minimal assertion helper. A real framework (xUnit) gives you this,
    // but writing it once shows you exactly what an assert DOES:
    // compare expected vs actual, then report PASS or FAIL.
    static void AssertEqual(int expected, int actual, string testName)
    {
        if (expected == actual)
            Console.WriteLine($"PASS: {testName}");
        else
            Console.WriteLine($"FAIL: {testName} — expected {expected}, got {actual}");
    }

    static void Main()
    {
        // === One test, written in the Arrange-Act-Assert (AAA) shape ===

        // Arrange — set up the inputs for this one scenario.
        int a = 5, b = 3;

        // Act — call the ONE method you are testing, capture its result.
        int result = Add(a, b);

        // Assert — state what the result MUST be. The test name reads like a sentence.
        AssertEqual(8, result, "Add_TwoPositives_ReturnsSum");

        // ✅ Expected output:
        //    PASS: Add_TwoPositives_ReturnsSum
    }
}

Your turn. The program below is almost complete — finish the Act and Assert lines so the test of Add passes. Fill in the two ___ blanks using the hints, then run it.

using System;

class Program
{
    static int Add(int a, int b) => a + b;

    // 🎯 YOUR TURN — finish the helper, then finish the test.

    static void AssertEqual(int expected, int actual, string testName)
    {
        // 1) Compare expected and actual. Print PASS if equal, FAIL if not.
        if (expected == actual)
            Console.WriteLine($"PASS: {testName}");
        else
            Console.WriteLine($"FAIL: {testName} — expected {expected}, got {actual}");
    }

    static void Main()
    {
        // Arrange — two numbers to add.
        int a = 10, b = 7;

        // 2) Act — call Add and store its result in 'result'.
        int result = ___;        // 👉 Add(a, b)

        // 3) Assert — the sum of 10 and 7 must be 17.
        AssertEqual(___, result, "Add_TwoPositives_ReturnsSum");  // 👉 the expected number: 17

        // ✅ Expected output:
        //    PASS: Add_TwoPositives_ReturnsSum
    }
}

2. Testing Edge Cases

The "happy path" (normal inputs) is the easy half. Bugs hide in the edge cases — zero, empty, negative, the maximum, the boundary where behaviour changes. A good test suite has one test per interesting edge, each with its own clear name, so when an edge breaks you see precisely which one. Here the discount function must never return a negative price; your job is to test the edge where the discount is bigger than the price itself.

using System;

class Program
{
    // The unit under test: should never return a negative price.
    static decimal ApplyDiscount(decimal price, decimal percentOff)
    {
        decimal result = price - (price * percentOff / 100);
        return result < 0 ? 0 : result;   // clamp at zero — the EDGE we test
    }

    static void AssertEqual(decimal expected, decimal actual, string testName)
    {
        if (expected == actual)
            Console.WriteLine($"PASS: {testName}");
        else
            Console.WriteLine($"FAIL: {testName} — expected {expected}, got {actual}");
    }

    static void Main()
    {
        // 🎯 YOUR TURN — test the EDGE CASE: a discount over 100% must clamp to 0.

        // Arrange — a £20 item with a 150% discount (more than the price!).
        decimal price = 20m, percentOff = 150m;

        // 1) Act — call ApplyDiscount and capture the result.
        decimal result = ___;     // 👉 ApplyDiscount(price, percentOff)

        // 2) Assert — the result must be clamped to 0, never negative.
        AssertEqual(___, result, "ApplyDiscount_Over100Percent_ClampsToZero");  // 👉 expected: 0m

        // ✅ Expected output:
        //    PASS: ApplyDiscount_Over100Percent_ClampsToZero
    }
}

3. Real xUnit: [Fact], [Theory] & Assertions

In a real project you don't hand-roll AssertEqual — a framework gives you it and a test runner. In xUnit, [Fact] marks a method as a single test with no inputs. [Theory] with one or more [InlineData(...)] rows runs the same test body once per row, so five cases live in one method instead of five copy-pasted ones. You run them all with dotnet test. The example below is exactly what you'd commit — read how each test maps onto the Arrange-Act-Assert shape you just practised.

using Xunit;

// The class we are testing — plain business logic, no test code inside it.
public class Calculator
{
    public int Add(int a, int b) => a + b;
    public bool IsEven(int n) => n % 2 == 0;
    public int Divide(int a, int b)
    {
        if (b == 0) throw new DivideByZeroException("Cannot divide by zero");
        return a / b;
    }
}

// A test CLASS groups related tests. xUnit makes a fresh instance per test,
// so tests never share state — that isolation is built in.
public class CalculatorTests
{
    private readonly Calculator _calc = new();

    // [Fact] = one test, one scenario, no inputs.
    [Fact]
    public void Add_TwoPositives_ReturnsSum()
    {
        // Arrange-Act-Assert, the same shape as before.
        int result = _calc.Add(5, 3);     // Act
        Assert.Equal(8, result);          // Assert: expected FIRST, actual second
    }

    // [Theory] + [InlineData] = the SAME test run once per row of data.
    // One method, five cases — no copy-paste.
    [Theory]
    [InlineData(2, true)]
    [InlineData(3, false)]
    [InlineData(0, true)]
    [InlineData(-4, true)]
    [InlineData(-7, false)]
    public void IsEven_VariousNumbers_ReturnsExpected(int input, bool expected)
    {
        Assert.Equal(expected, _calc.IsEven(input));
    }

    // Asserting that the RIGHT exception is thrown is also a behaviour to test.
    [Fact]
    public void Divide_ByZero_ThrowsDivideByZeroException()
    {
        var ex = Assert.Throws<DivideByZeroException>(() => _calc.Divide(10, 0));
        Assert.Equal("Cannot divide by zero", ex.Message);
    }
}

// ✅ Running 'dotnet test' produces:
//    Passed!  - Failed: 0, Passed: 7, Skipped: 0, Total: 7
//    (1 Add test + 5 Theory rows + 1 exception test)

4. Choosing the Right Assertion

An assertion is the line that decides pass or fail. Always reach for the most specific one: Assert.Equal(8, result) tells you "Expected 8, Actual 7" when it fails, whereas Assert.True(result == 8) only tells you "false" — useless for debugging. xUnit has a rich set: Equal/NotEqual, True/False, Null/NotNull, Contains, StartsWith, Throws<T>, and All for collections. Note floating-point: never assert exact equality on a double — pass a precision instead.

using Xunit;
using System.Collections.Generic;

public class AssertionShowcase
{
    [Fact]
    public void Equality_PrefersAssertEqual()
    {
        // Use the MOST specific assert you can. Assert.Equal gives a clear
        // "Expected: 8, Actual: 7" message; Assert.True(x == y) only says "false".
        Assert.Equal(8, 5 + 3);              // numbers
        Assert.Equal("HELLO", "hello".ToUpper());  // strings
        Assert.NotEqual(0, 5);
    }

    [Fact]
    public void Booleans_And_Nulls()
    {
        Assert.True(10 > 5, "ten should be greater than five");  // optional message
        Assert.False(2 > 3);
        string? name = null;
        Assert.Null(name);
        Assert.NotNull("Alice");
    }

    [Fact]
    public void Strings_And_Collections()
    {
        Assert.Contains("World", "Hello, World!");
        Assert.StartsWith("Hello", "Hello, World!");

        var nums = new List<int> { 1, 2, 3, 4, 5 };
        Assert.Equal(5, nums.Count);
        Assert.Contains(3, nums);
        Assert.DoesNotContain(99, nums);
        Assert.All(nums, n => Assert.True(n > 0));   // every item is positive
    }

    [Fact]
    public void Floats_NeedATolerance()
    {
        // 0.1 + 0.2 is 0.30000000000000004 in binary floating point.
        // Assert.Equal(0.3, ...) would FAIL — give it a precision (digits).
        Assert.Equal(0.3, 0.1 + 0.2, precision: 10);
    }
}

// ✅ 'dotnet test' →  Passed!  - Failed: 0, Passed: 4, Skipped: 0, Total: 4

🔎 Deep Dive: test isolation & the FIRST principles

Tests must not depend on each other or on running in a particular order. If test B only passes because test A ran first and left some data behind, you have a flaky suite that fails randomly. xUnit defends you here: it creates a brand-new instance of the test class for every single test, so any fields you set in the constructor are fresh each time — there is no shared mutable state by default.

A handy checklist for a healthy test is the FIRST acronym: Fast (milliseconds, so you run them constantly), Isolated (no dependence on order or other tests), Repeatable (same result every run, no clocks or random without control), Self-validating (it asserts pass/fail, you don't eyeball output), and Timely (written alongside the code, not months later).

public class CartTests
{
    // Runs BEFORE EACH test — every test gets a fresh, empty cart.
    private readonly Cart _cart = new();

    [Fact] public void Add_OneItem_CountIsOne()  { _cart.Add("x"); Assert.Equal(1, _cart.Count); }
    [Fact] public void NewCart_IsEmpty()         { Assert.Equal(0, _cart.Count); }  // unaffected by the test above
}

🔎 Deep Dive: code coverage (and its trap)

Code coverage measures the percentage of your code lines (or branches) that ran while the tests executed. You generate it with dotnet test --collect:"XPlat Code Coverage" and view the report to spot untested branches — especially the else paths and exception cases that are easy to forget.

The trap: coverage tells you which lines ran, not whether they were checked. A test with no asserts can hit 100% coverage and verify nothing. Treat coverage as a flashlight for finding untested code, never as a target to game. Aim for the important paths covered with meaningful asserts, not a vanity number.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskxUnit codeNotes
Mark one test[Fact]Above a public void method
Data-driven test[Theory] [InlineData(2, true)]One row = one case
Check equalityAssert.Equal(8, result)Expected first
Check a conditionAssert.True(x > 0)Use Equal where possible
Check an exceptionAssert.Throws<T>(() => ...)Returns the exception
Check nullAssert.Null(x) / Assert.NotNull(x)Nullability
Float compareAssert.Equal(0.3, v, precision: 10)Never exact equality
Run the suitedotnet testAdd --collect for coverage

Frequently Asked Questions

Q: What exactly is a "unit"?

The smallest piece you can test in isolation — usually a single method, sometimes a single class. If a test needs a real database, network, or file system to pass, it's an integration test, not a unit test.

Q: xUnit, NUnit, or MSTest — which should I use?

For a new project, xUnit is the modern default and what most .NET open source uses. NUnit is mature and feature-rich; MSTest ships with Visual Studio. The concepts are identical, so learning one teaches you all three — see the table above.

Q: How many tests do I need? Is 100% coverage the goal?

Cover the behaviours that matter: the happy path plus each meaningful edge (zero, empty, negative, boundary, error). 100% coverage with weak asserts is worse than 80% with strong ones — coverage finds untested code, it doesn't prove correctness.

Q: Should I test private methods?

No — test them indirectly through the public methods that call them. Private methods are implementation detail; testing them directly couples your tests to internals and breaks them on every refactor.

Q: Why did my test pass without checking anything?

A test only fails if an Assert fails or the code throws. If you forgot the assertion, the test "passes" by doing nothing useful. Every test needs at least one assertion that could fail.

Mini-Challenge: a tiny test harness

No blanks this time — just a brief and an outline. Test the Classify function across several cases, printing PASS/FAIL for each and a final tally. This is the Arrange-Act-Assert loop a real runner does for you, written by hand so the mechanics are clear. Run it and check your output against the expected lines in the comments.

using System;

class Program
{
    // The unit under test — a simple FizzBuzz-style classifier.
    static string Classify(int n)
    {
        if (n % 15 == 0) return "FizzBuzz";
        if (n % 3 == 0)  return "Fizz";
        if (n % 5 == 0)  return "Buzz";
        return n.ToString();
    }

    static void Main()
    {
        // 🎯 MINI-CHALLENGE: test Classify across several cases, count pass/fail.
        // 1. Keep two counters: int passed = 0, failed = 0.
        // 2. Write a small Check(int input, string expected) local helper that:
        //      - calls Classify(input)
        //      - compares it to 'expected'
        //      - prints "PASS: ..." or "FAIL: ..." and bumps the right counter.
        // 3. Run these cases:  Check(3,"Fizz"); Check(5,"Buzz");
        //    Check(15,"FizzBuzz"); Check(7,"7");
        // 4. After all checks, print:  $"Results: {passed} passed, {failed} failed".
        //
        // ✅ Expected output (all correct):
        //    PASS: 3 -> Fizz
        //    PASS: 5 -> Buzz
        //    PASS: 15 -> FizzBuzz
        //    PASS: 7 -> 7
        //    Results: 4 passed, 0 failed

        // your code here
    }
}

🎉 Lesson Complete

Practice quiz

What is a "unit" in unit testing?

  • The whole application running end to end
  • A database table
  • The smallest piece you can test in isolation, usually a single method
  • A deployment environment

Answer: The smallest piece you can test in isolation, usually a single method. A unit is the smallest testable piece — typically one method or class — tested in isolation from external systems.

What do the three steps of the Arrange-Act-Assert pattern do, in order?

  • Set up the inputs, call the method under test, check the result
  • Assert the result, act on it, arrange the inputs
  • Mock everything, run it, log the output
  • Compile, run, deploy

Answer: Set up the inputs, call the method under test, check the result. Arrange sets up the inputs, Act calls the one method under test, and Assert verifies the result is what you expect.

In xUnit, which attribute marks a single test method with no inputs?

  • [Test]
  • [TestMethod]
  • [Theory]
  • [Fact]

Answer: [Fact]. [Fact] marks one test with no parameters in xUnit. NUnit uses [Test] and MSTest uses [TestMethod].

Which xUnit combination runs the same test body once per row of data?

  • [Fact] alone
  • [Theory] with [InlineData]
  • [TestClass] with [Setup]
  • [Fact] with [InlineData]

Answer: [Theory] with [InlineData]. [Theory] paired with one or more [InlineData(...)] rows runs the same test logic once per row, avoiding copy-paste.

In xUnit's Assert.Equal(expected, actual), which argument comes first?

  • The expected value
  • The actual value
  • The test name
  • It does not matter for the message

Answer: The expected value. The expected value comes first in xUnit's Assert.Equal. Getting the order right makes the failure message read correctly.

Why prefer Assert.Equal(8, result) over Assert.True(result == 8)?

  • It runs faster
  • Assert.True is deprecated
  • Assert.Equal gives a clearer failure message showing expected vs actual
  • They behave identically in every way

Answer: Assert.Equal gives a clearer failure message showing expected vs actual. Assert.Equal reports "Expected: 8, Actual: 7" on failure, whereas Assert.True only reports "false", which is useless for debugging.

How does xUnit keep tests isolated from each other by default?

  • It runs tests alphabetically
  • It creates a brand-new instance of the test class for every test
  • It shares one static instance across all tests
  • It disables all fields

Answer: It creates a brand-new instance of the test class for every test. xUnit constructs a fresh instance of the test class per test, so constructor-initialised fields are reset and there is no shared mutable state by default.

Which xUnit assertion verifies that calling a method throws a specific exception?

  • Assert.Equal
  • Assert.NotNull
  • Assert.Contains
  • Assert.Throws<T>

Answer: Assert.Throws<T>. Assert.Throws<T>(() => ...) asserts the delegate throws exception type T and returns the exception so you can inspect its message.

Why should you avoid Assert.Equal(0.3, 0.1 + 0.2) on doubles?

  • Doubles cannot be compared at all
  • Binary floating-point rounding makes 0.1 + 0.2 not exactly 0.3
  • 0.3 is not a valid double
  • Assert.Equal does not accept doubles

Answer: Binary floating-point rounding makes 0.1 + 0.2 not exactly 0.3. 0.1 + 0.2 is 0.30000000000000004 in binary floating point, so exact equality fails. Use the overload with a precision argument instead.

What does the FIRST acronym describe for healthy unit tests?

  • Fixed, Indexed, Reusable, Static, Threaded
  • Fast, Integrated, Random, Slow, Typed
  • Fast, Isolated, Repeatable, Self-validating, Timely
  • Functional, Internal, Remote, Shared, Tested

Answer: Fast, Isolated, Repeatable, Self-validating, Timely. FIRST stands for Fast, Isolated, Repeatable, Self-validating, and Timely — the qualities of a good unit test.

Continue this course