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.
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.
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.
DateTime.Now or wall-clock timing to pass.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.
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.
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.
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 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.
Nearly every unit test, regardless of framework or language, follows the same three-part shape, commonly called AAA:
[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][InlineData(...)] row of parameters[Fact] methodsA 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.
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.
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.
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.
[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.[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.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.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 |
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.
Assert.Equal(expected, actual) — argument order is not arbitraryxUnit'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.
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.
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.
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.
Test1 or ApplyDiscount_WorksA 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.
[Fact] = one scenario. [Theory] + [InlineData] = the same logic, many scenarios, one method.xUnit, this course's framework going forward (NUnit and MSTest exist and work similarly).[Fact] covers a single scenario; [Theory] with [InlineData] runs one method across many input rows without duplicating test code.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"?
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?
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?
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?
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.