← Open in the full interactive course (progress tracking, search & more)

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

Not actually a unit test

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.

THE TESTING PYRAMID — BASE TO TIP
FEW — END-TO-END / UI TESTS
SOME — INTEGRATION TESTS (lesson 309, next)
MANY — UNIT TESTS (this lesson)

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:

ARRANGE → ACT → ASSERT
1. ARRANGE — SET UP THE INPUTS AND THE OBJECT UNDER TEST
2. ACT — CALL THE ONE THING BEING TESTED
3. ASSERT — CHECK THE RESULT MATCHES WHAT'S EXPECTED

[Fact] vs. [Theory]/[InlineData]

xUnit gives you two attributes for marking a method as a test, and the difference between them matters:

[Fact]

[Theory] + [InlineData]

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

A Quality Inspector Testing One Component at a Time

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

FROM dotnet test TO A PASS/FAIL REPORT
1. DISCOVERY — REFLECTION FINDS EVERY [Fact] AND [Theory]
2. A FRESH INSTANCE PER TEST — NOT ONE SHARED INSTANCE
3. FOR [Theory], ONE INVOCATION PER InlineData ROW
4. AN Assert FAILURE THROWS — THE RUNNER CATCHES IT

FIRST — a Useful Checklist for What "Good" Looks Like

A widely-cited mnemonic for what makes a unit test actually worth keeping:

LetterMeansWhy it matters
FastRuns in millisecondsHundreds of them still finish in seconds, so they run constantly
IsolatedNo dependency on other tests or external systemsCan run in any order, in parallel, and still be trustworthy
RepeatableSame result every time, on any machineA flaky test that sometimes fails for no reason destroys trust in the whole suite
Self-validatingProduces a clear pass/fail, no manual inspection neededA human reading logs to decide "did it pass?" defeats the purpose
TimelyWritten close to when the code itself is writtenTests 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?

Rule of thumb: If a change to this code could silently produce the wrong answer without anyone noticing until a customer complains, it deserves a unit test. If it's a one-line pass-through with no decision in it, it usually doesn't.

Mental Model

Unit test = one isolated behavior, verified fast, in memory, with no I/O.
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


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.


dotnetmadeeasy.com — Learn C# and .NET, the right way.