Advanced Track
Unit Testing Mastery (xUnit, NUnit, MSTest)
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.
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.
Worked example: Arrange-Act-Assert with a hand-rolled assert
Read every comment, run it, and confirm it prints PASS.
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: {tes
...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.
🎯 Your turn: complete an Arrange-Act-Assert test
Fill in the ___ blanks so the Add test prints PASS.
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
...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.
🎯 Your turn: test an edge case (PASS/FAIL)
Test that a 150% discount clamps to 0, not a negative number.
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
...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.
Worked example: real xUnit Fact, Theory & InlineData
The genuine xUnit syntax — note expected comes first in Assert.Equal.
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 c
...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.
Worked example: picking the clearest assertion
Numbers, strings, collections, nulls, and the float-tolerance gotcha.
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_
...🔎 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
staticfield 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
doubleequality:Assert.Equal(0.3, 0.1 + 0.2)fails because of binary rounding. Use the overload with aprecisionargument. - 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.
🎯 Mini-Challenge: count pass/fail across cases
Write a Check helper, run four cases, and print the tally.
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 Che
...🎉 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
- ✅ Next lesson: Mocking Frameworks — replace real dependencies with controllable fakes
Sign up for free to track which lessons you've completed and get learning reminders.