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
- Explain why unit tests exist and what counts as a 'unit'
- Structure every test with the Arrange-Act-Assert pattern
- Name tests with Method_Scenario_Expected so failures explain themselves
- Write xUnit tests with [Fact], and data-drive them with [Theory] / [InlineData]
- Pick the right assertion (Equal, True, Throws, Contains) for a clear failure message
- Keep tests isolated, test behaviour not implementation, and read a coverage report
💡 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.
| Concept | xUnit | NUnit | MSTest |
|---|---|---|---|
| Single test | [Fact] | [Test] | [TestMethod] |
| Data-driven test | [Theory] + [InlineData] | [TestCase] | [DataRow] |
| Test class marker | (none needed) | [TestFixture] | [TestClass] |
| Run before each test | constructor | [SetUp] | [TestInitialize] |
| Run after each test | Dispose() | [TearDown] | [TestCleanup] |
| Equality assert | Assert.Equal(exp, act) | Assert.AreEqual(exp, act) | Assert.AreEqual(exp, act) |
| Exception assert | Assert.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
- 💡 Name tests Method_Scenario_Expected (e.g. Add_TwoNegatives_ReturnsNegativeSum) so a failure report reads like a sentence — you know what broke without opening the file.
- 💡 One logical assert per test: a test should fail for exactly one reason. Several asserts that all check the same outcome are fine; testing two unrelated behaviours in one method is not.
- 💡 Prefer [Theory] over copy-paste: when only the inputs change, one data-driven test beats five near-identical [Fact]s.
- 💡 Test behaviour, not implementation: assert on what the method returns or does, never on private fields or internal call order — those change on every refactor.
- 💡 Write the failing test first (red-green-refactor): see it fail, make it pass, then clean up. A test you've never seen fail might be testing nothing.
Common Errors (and the fix)
- Testing implementation instead of behaviour: asserting on a private field or that an internal helper was called makes the test break on every refactor even though the output is still correct. Assert only on the public result or observable effect.
- A test with no asserts: if the method just calls your code and never calls Assert, it passes no matter what — it only proves "didn't throw". Every test needs at least one assertion that can fail.
- Shared mutable state between tests: a static field or a single shared object that one test mutates makes others pass or fail depending on order. Give each test fresh data (the xUnit constructor does this for you).
- Over-mocking: replacing so many dependencies with fakes that the test only verifies the mock setup, not real behaviour. Mock the slow/external edges (network, clock) and let plain logic run for real.
- Asserting exact double equality: Assert.Equal(0.3, 0.1 + 0.2) fails because of binary rounding. Use the overload with a precision argument.
- Expected/actual reversed: Assert.Equal(result, 8) still works but prints the values the wrong way round. In xUnit the expected value goes first.
📋 Quick Reference
| Task | xUnit code | Notes |
|---|---|---|
| Mark one test | [Fact] | Above a public void method |
| Data-driven test | [Theory] [InlineData(2, true)] | One row = one case |
| Check equality | Assert.Equal(8, result) | Expected first |
| Check a condition | Assert.True(x > 0) | Use Equal where possible |
| Check an exception | Assert.Throws<T>(() => ...) | Returns the exception |
| Check null | Assert.Null(x) / Assert.NotNull(x) | Nullability |
| Float compare | Assert.Equal(0.3, v, precision: 10) | Never exact equality |
| Run the suite | dotnet test | Add --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
- ✅ A unit test runs one small piece of code with a known input and checks the output
- ✅ Every test follows Arrange-Act-Assert; name it Method_Scenario_Expected
- ✅ xUnit uses [Fact] for one case and [Theory] + [InlineData] for many
- ✅ NUnit and MSTest share the same ideas with different attribute names
- ✅ Pick the most specific assertion for a clear failure message; mind float precision
- ✅ Keep tests isolated, test behaviour not implementation, and avoid over-mocking
- ✅ Use coverage to find untested paths — never as a target to game
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
- Previous: WebSockets & Streaming Data in .NET
- Next: Mocking Frameworks (Moq, NSubstitute, FakeItEasy) — Mock dependencies with Moq, set up expectations, and verify calls
- Quick reference: C# cheat sheet