Unit Testing in C# With xUnit: A Practical Guide
Everything up to this point taught you how to build a correct, performant, cloud-native .NET system. This new Part asks the question that actually matters once it's built: how do you know it works — and when it eventually doesn't, how do you find out why?
Part X ended with .NET Aspire (306) making it easy to stand up and observe a whole multi-project system locally. That's a genuine milestone — but standing a system up and knowing it behaves correctly are two different things. Nothing in this course so far has asked "how do you actually prove your code does what you think it does, on every future change, without re-reading it by eye every time?" That question is the whole reason this new Part — Testing & Prod Eng — exists, and it opens with the most fundamental layer of the answer: the unit test.
You've almost certainly written code and run it manually to "see if it works" dozens of times already in this course. That works fine once. It falls apart the moment your codebase has hundreds of methods and dozens of contributors, because manual checking doesn't scale, doesn't repeat itself automatically, and leaves no record that it ever happened at all.
In this lesson, you'll learn what actually makes a test a unit test, how to write one with xUnit — the framework this course uses from here forward — the Arrange-Act-Assert structure nearly every test follows, the difference between [Fact] and [Theory], and where unit tests sit in the broader testing pyramid this whole Part will build on top of.
What Is It?
The Simple Explanation
A unit test is a small, automated piece of code that calls a small piece of your real code and checks that it produced the result you expected — without touching a database, the network, the filesystem, or the system clock. It runs in milliseconds, it runs the same way every single time, and it either passes or fails with no ambiguity.
The Technical Definition
A unit test verifies a single, isolated unit of behavior — typically one public method or one small cluster of tightly related logic — in complete isolation from external dependencies, using an assertion library to compare an actual result against an expected one. "Unit" here refers to a unit of behavior, not literally "exactly one method call": a unit test can legitimately call several private helper methods internally, as long as everything outside the process boundary — I/O, other services, real time — is absent or replaced.
A genuine unit test
- Runs entirely in memory, no I/O of any kind
- Finishes in milliseconds
- Produces the exact same result every time it runs, on any machine
- Tests one specific, isolated piece of behavior
Not actually a unit test
- Opens a real database connection
- Makes a real HTTP call to another service
- Depends on
DateTime.Nowor wall-clock timing to pass - Can fail depending on what other tests ran before it
xUnit — the Framework This Course Uses From Here On
.NET has three long-standing test frameworks: MSTest (Microsoft's original), NUnit (a mature, JUnit-inspired framework with a long history in .NET), and xUnit — the framework this course will use for every testing lesson from here forward. xUnit is the framework the .NET team itself uses to test the runtime and ASP.NET Core, and it's the default in most modern .NET project templates, which is why it's the most .NET-idiomatic choice today. Nothing about the concepts in this lesson — Arrange-Act-Assert, isolation, the testing pyramid — is xUnit-specific; they apply just as well if a team you join happens to use NUnit or MSTest instead.
Why Does It Exist?
The Problem — Manual Verification Doesn't Scale, and Doesn't Remember
Running your app and clicking through it by hand to "check it still works" has two fatal weaknesses. First, it doesn't scale — a system with hundreds of business rules can't be manually re-verified after every change without it taking hours, so in practice it simply doesn't happen, and regressions slip through. Second, it leaves no record — a month from now, nobody can tell whether the discount calculation was ever actually verified for a zero-quantity cart, or whether that edge case was just never tried.
The Solution — Encode the Verification, Run It Constantly
A unit test takes the exact verification a developer would otherwise do by hand — "given this input, I expect this output" — and turns it into code that runs automatically, in milliseconds, every time anything in the codebase changes. It becomes a permanent, repeatable record of an expected behavior, and a safety net that catches the moment some later change accidentally breaks it — often called a regression. This is also what makes fearless refactoring possible at all: a large body of fast, trustworthy unit tests is what lets a developer confidently restructure code, knowing that if they broke something, the tests will say so within seconds.
Big Picture — the Testing Pyramid
Unit tests are the base of a shape you'll hear referenced constantly across this Part: the testing pyramid. It describes, roughly, how many tests of each kind a healthy codebase should have, and why.
- Drive the whole system through a real browser or client — slow (seconds per test), brittle, but catch things nothing else can. Not this Part's focus.
- Exercise real collaboration between components — a real HTTP pipeline, a real database — slower than unit tests, but catch wiring problems unit tests structurally cannot.
- The base of the pyramid: the most numerous, fastest, narrowest-scoped tests. Cheap enough to write hundreds of, and fast enough to run all of them on every single build.
The shape matters as much as the labels: a healthy suite has many fast unit tests, a moderate number of slower integration tests, and very few slow, brittle end-to-end tests — because the closer a test gets to exercising the real, whole system, the slower and more fragile it tends to become. This Part follows that exact order: unit tests here, integration tests next (309), then mocking (310) and Testcontainers (311) as the tools that make integration tests trustworthy, then API testing (312) as a specific, common shape of integration test.
How It Works — Arrange, Act, Assert
Nearly every unit test, regardless of framework or language, follows the same three-part shape, commonly called AAA:
- Construct the thing you're testing and prepare whatever input values the scenario needs.
- Invoke the single method or behavior the test exists to verify. Ideally exactly one line.
- Compare the actual outcome against the expected one. If it doesn't match, the test fails — clearly and immediately.
[Fact] vs. [Theory]/[InlineData]
xUnit gives you two attributes for marking a method as a test, and the difference between them matters:
[Fact]
- A single test case with no parameters
- Use it when there's exactly one scenario to verify
[Theory] + [InlineData]
- One test method, run once per
[InlineData(...)]row of parameters - Use it when the same logic needs verifying across several inputs — avoids copy-pasting nearly-identical
[Fact]methods
Naming a Test So It Explains Itself
A widely-used, effective convention is MethodName_Scenario_ExpectedBehavior — e.g. CalculateTotal_EmptyCart_ReturnsZero. Read as plain English, that name already tells you exactly what broke, before you even look at the assertion — which matters enormously when a test fails in a CI log you're scanning quickly.
Simple Example
A small DiscountCalculator, tested with both a [Fact] and a [Theory]:
public class DiscountCalculator
{
public decimal ApplyDiscount(decimal price, decimal percentOff)
{
if (percentOff is < 0 or > 100)
throw new ArgumentOutOfRangeException(nameof(percentOff));
return price - (price * percentOff / 100m);
}
}
public class DiscountCalculatorTests
{
[Fact]
public void ApplyDiscount_ZeroPercentOff_ReturnsOriginalPrice()
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var result = calculator.ApplyDiscount(100m, 0m);
// Assert
Assert.Equal(100m, result);
}
[Theory]
[InlineData(100, 10, 90)]
[InlineData(200, 50, 100)]
[InlineData(50, 100, 0)]
public void ApplyDiscount_VariousPercentages_ReturnsExpectedPrice(
decimal price, decimal percentOff, decimal expected)
{
// Arrange
var calculator = new DiscountCalculator();
// Act
var result = calculator.ApplyDiscount(price, percentOff);
// Assert
Assert.Equal(expected, result);
}
[Fact]
public void ApplyDiscount_PercentOffAbove100_ThrowsArgumentOutOfRangeException()
{
// Arrange
var calculator = new DiscountCalculator();
// Act & Assert
Assert.Throws<ArgumentOutOfRangeException>(
() => calculator.ApplyDiscount(100m, 150m));
}
}Meaning: Three tests, three clear scenarios — a zero-discount edge case, a table of ordinary cases via [Theory], and an invalid-input case that expects an exception. Every one of them runs in a fraction of a millisecond, with zero I/O, and every one tells you exactly what it checks from its name alone.
Real-World Example
Consider a shipping-cost rule with several genuinely tricky edge cases: free shipping over $50, a flat $5.99 otherwise, but a special $2.99 rate for orders under 1kg. Written out as a [Theory], the test table itself becomes a readable specification of the business rule — arguably clearer than the rule's own prose description, and one that can never silently drift out of sync with the code, because it fails loudly the moment it does:
[Theory]
[InlineData(49.99, 2.0, 5.99)] // under threshold, normal weight
[InlineData(50.00, 2.0, 0.00)] // exactly at free-shipping threshold
[InlineData(30.00, 0.5, 2.99)] // under threshold, light package
[InlineData(0.00, 0.5, 2.99)] // empty-ish cart, light package
public void CalculateShipping_VariousOrders_ReturnsExpectedCost(
decimal orderTotal, decimal weightKg, decimal expectedCost)
{
var calculator = new ShippingCalculator();
var result = calculator.CalculateShipping(orderTotal, weightKg);
Assert.Equal(expectedCost, result);
}Notice what this table captures that a single example never could: the boundary at exactly $50, and the interaction between two independent rules (weight and total) at once. Real business logic is full of boundaries like this, and they're precisely where bugs live — which is exactly why unit tests earn their keep most on this kind of code, not on trivial one-line getters.
Analogy
Picture a factory quality inspector whose job is to test one small component — a single bolt, a single circuit board — completely off the assembly line, on their own bench, with no other machinery running. They apply a known stress, and check the component responds exactly as specified. They don't care whether the rest of the assembly line is running correctly at that moment; that's a different inspector's job, checking the whole assembled product later.
A unit test is that bench inspector. It pulls one small piece of logic off the "assembly line" of your running application, tests it in isolation with a known input, and checks the output precisely — fast, cheap, repeatable, and telling you nothing at all about whether the pieces fit together correctly once assembled. That's what the next lesson's integration tests are for.
Under the Hood — How xUnit Finds and Runs Your Tests
- xUnit scans your test assembly using reflection (lesson 182) to find every public method decorated with the
[Fact]or[Theory]attributes (lesson 183) — this is exactly the same attribute-plus-reflection mechanism those two lessons taught, applied to a very practical end.
- xUnit creates a brand-new instance of your test class for every single test method. This is a deliberate, important design choice: it means one test's leftover state can never leak into another, so tests can safely run in any order — including in parallel — without silently corrupting each other.
- Each
[InlineData(...)]row produces its own separate test invocation, reported individually — a[Theory]with four rows shows up as four distinct pass/fail results, not one.
- Every
Assert.*call that fails throws an exception under the hood; the test runner catches it, marks that specific test as failed, records the message, and moves on to the next test rather than stopping the whole run.
FIRST — a Useful Checklist for What "Good" Looks Like
A widely-cited mnemonic for what makes a unit test actually worth keeping:
| Letter | Means | Why it matters |
|---|---|---|
| Fast | Runs in milliseconds | Hundreds of them still finish in seconds, so they run constantly |
| Isolated | No dependency on other tests or external systems | Can run in any order, in parallel, and still be trustworthy |
| Repeatable | Same result every time, on any machine | A flaky test that sometimes fails for no reason destroys trust in the whole suite |
| Self-validating | Produces a clear pass/fail, no manual inspection needed | A human reading logs to decide "did it pass?" defeats the purpose |
| Timely | Written close to when the code itself is written | Tests written months later tend to simply never get written at all |
Common Confusion
1. "Unit means exactly one method call" — no, it means one isolated behavior
A unit test can absolutely exercise a method that internally calls several private helpers — that's still one unit test, as long as nothing crosses the process boundary to a real database, a real network call, or real wall-clock time. What makes it a unit test is isolation from the outside world, not a literal count of method calls inside it.
2. Assert.Equal(expected, actual) — argument order is not arbitrary
xUnit's convention is Assert.Equal(expected, actual) — expected value first. Getting this backwards doesn't break the test's pass/fail outcome, but it does scramble the failure message ("Expected: 5, Actual: 3" becomes confusingly reversed), making a failing test noticeably harder to read at exactly the moment clarity matters most.
3. "AAA and FIRST are competing philosophies" — they answer different questions
AAA is a structural pattern for how to write a single test's body. FIRST is a checklist for judging whether a test — however it's structured — is actually a good one to keep in the suite. They're complementary, not alternatives: you can write a perfectly AAA-structured test that still isn't Fast, Isolated, or Repeatable.
Common Mistakes
Mistake 1 — One test method silently verifying several unrelated behaviors
A single test method with a dozen unrelated Assert calls checking a dozen different scenarios — when it fails, the test name and the first failing assertion tell you almost nothing about which of the twelve things actually broke.
One behavior, one test method (or one [Theory] row per scenario) — a failure should point, immediately and unambiguously, at exactly what broke.
Mistake 2 — Testing private implementation details instead of observable behavior
Reaching for reflection to call a private method directly, or asserting on an internal field's exact value, just because it's technically reachable.
Test through the class's public API — the same way real callers will use it. A test tied to private internals breaks the moment you refactor the internals, even when the public behavior never changed at all — exactly the brittleness the "Common Mistakes" section of the upcoming mocking lesson (310) revisits from a different angle.
Mistake 3 — Vague test names like Test1 or ApplyDiscount_Works
A name that tells you nothing about the scenario or expected outcome forces anyone reading a failing-test list to open the actual test body just to understand what failed.
MethodName_Scenario_ExpectedBehavior — a failing test name alone should already tell a teammate roughly what went wrong, before they've opened a single file.
When Should I Use It?
- Any business logic, calculation, validation rule, or algorithm — this is where unit tests earn their keep most, and where bugs actually tend to hide.
- Edge cases and boundaries specifically — zero, negative, empty, maximum values — the inputs a developer is most likely to overlook when reasoning by hand.
- Not a trivial one-line property getter with no logic in it — there's no meaningful behavior there to verify, and the test adds maintenance cost for near-zero protection.
- Not a substitute for verifying that your components actually work correctly together — real database queries, real HTTP pipelines, real middleware. That's what lesson 309, next, is for.
Mental Model
Arrange, Act, Assert = set up → do the one thing → check the result.
[Fact] = one scenario. [Theory] + [InlineData] = the same logic, many scenarios, one method.FIRST = the checklist for whether a test is actually worth keeping.
Unit tests are the wide base of the testing pyramid — many, fast, narrow. Everything from here builds a layer above them.
Key Takeaway
- A unit test verifies one isolated piece of behavior — fast, deterministic, no I/O — using
xUnit, this course's framework going forward (NUnit and MSTest exist and work similarly). - Arrange-Act-Assert is the near-universal structural pattern for a test's body; FIRST (Fast, Isolated, Repeatable, Self-validating, Timely) is the checklist for whether it's actually a good one.
[Fact]covers a single scenario;[Theory]with[InlineData]runs one method across many input rows without duplicating test code.- Unit tests are the base of the testing pyramid — the most numerous, fastest, narrowest tests. The next lesson (309) moves one layer up: integration tests, which verify real components working together.
Check Your Understanding
You've learned what makes a test a genuine unit test, how AAA structures one, and where [Fact] and [Theory] fit. Let's confirm it clicked.
1. Which of these tests would still correctly be called a "unit test"?
Show answer
Correct: B
Why B is correct: "Unit" refers to an isolated unit of behavior, not a literal single method call — calling several private helpers internally is fine, as long as nothing crosses out to a real external dependency.
Why A is incorrect: A real database connection is real I/O — this crosses into integration-test territory (lesson 309), not a unit test.
Why C is incorrect: A real HTTP call, even to a sandbox, is exactly the kind of external dependency a unit test must avoid to stay fast and deterministic.
Why D is incorrect: A result that depends on real wall-clock time isn't repeatable — running it at 11:59pm versus 12:01am could change the answer, violating the Repeatable requirement.
Reinforcement: Isolation from the outside world — not a call count — is what makes a test a unit test.
2. A developer needs to verify the same discount-calculation logic against six different price/percentage combinations. What's the idiomatic xUnit way to do this without writing six nearly-identical [Fact] methods?
Show answer
Correct: B
Why B is correct: This is exactly what [Theory] plus [InlineData] exists for — one method, run once per data row, each reported as its own distinct pass/fail result.
Why A is incorrect: Six asserts in one [Fact] means a failure doesn't tell you which of the six scenarios actually broke — exactly Mistake 1 from this lesson.
Why C is incorrect: Copy-pasting six near-identical methods is exactly the duplication [Theory] exists to eliminate, and it's far more work to maintain.
Why D is incorrect: A loop inside one [Fact] collapses six scenarios into one pass/fail result, and a failure partway through the loop can be far less clear than [Theory]'s per-row reporting.
Reinforcement: [Theory] + [InlineData] is the idiomatic xUnit answer whenever the same logic needs checking across several inputs.
3. Why does xUnit create a brand-new instance of the test class for every single test method, rather than reusing one shared instance?
Show answer
Correct: B
Why B is correct: A fresh instance per test guarantees no shared mutable state can accidentally carry over from one test to the next — exactly what "Isolated" in FIRST requires, and what makes parallel and any-order test execution safe.
Why A is incorrect: This is a deliberate design choice specifically to guarantee isolation, not an accident.
Why C is incorrect: C# classes can absolutely be reused across many method calls — this isn't a language limitation, it's an xUnit isolation design decision.
Why D is incorrect: Instance creation happens at run time, not during discovery — discovery only locates the attributed methods via reflection.
Reinforcement: Fresh-instance-per-test is precisely what protects test isolation — one of FIRST's five pillars.
4. Following the MethodName_Scenario_ExpectedBehavior naming convention, which is the best name for a test verifying that calling Withdraw with an amount greater than the account balance throws an exception?
Show answer
Correct: C
Why C is correct: It names the method under test, the specific scenario, and the expected outcome — a failing test named this way tells a teammate what broke without opening the file.
Why A is incorrect: Tells you nothing about what's being verified — exactly the vague naming Mistake 3 warns against.
Why B is incorrect: "Works" describes no specific scenario or expected outcome — it's better than Test1, but still not actionable when it fails.
Why D is incorrect: This looks like a class name, not an individual test method name, and identifies no scenario at all.
Reinforcement: A good test name is a mini-specification of the behavior — method, scenario, expected result.
You now know how to write a genuine, isolated unit test with xUnit — the base layer of every testing strategy this Part builds from here. Next: what happens the moment your code needs to talk to something real, like a database or an HTTP pipeline — integration testing.