AlgoMaster Logo

Testing Basics (xUnit / NUnit)

Medium Priority28 min readUpdated June 6, 2026

Tests are code that runs your code and checks that it does what you expected. They turn "I think this works" into "I can prove it works" and they give you the courage to change things later without breaking them. This lesson covers the two most popular test frameworks for .NET, xUnit and NUnit, how to write good unit tests, how to assert cleanly, how to mock dependencies with Moq, and how to test async code. It stays at the unit level. Higher-level testing (integration, end-to-end, load) is a separate topic.

Why Tests Are Worth Writing

A test isn't paperwork. It's a tiny program that exists to answer one question: "given these inputs, does my code produce the right output?" Once you have that program, you can run it every time you change anything. The cost of writing the test is paid back the first time a refactor would have broken a customer-facing bug and the test catches it before the code ships.

Three concrete benefits people actually notice:

  • Refactor confidence. Renaming a method, splitting a class, swapping an algorithm. With tests, you change the code, hit run, and either the tests pass (ship it) or they don't (you broke something specific, fix it). Without tests, every refactor is a gamble.
  • Regression safety. Bugs that got fixed in March show up again in October if there's no test pinning the behavior in place. A test written for the original bug becomes a permanent guard against that exact regression.
  • Design feedback. Code that's hard to test is usually hard to use. If you can't write a clean test for a method, the method probably has too many responsibilities, too many hidden dependencies, or both. Testing pushes you toward smaller, more focused units almost by accident.

There's also the negative case worth being honest about. Tests aren't free. They take time to write, they take time to read, and a flaky or brittle test is worse than no test at all because it lies to you. The goal isn't 100% coverage of every line. The goal is enough tests to make changes safe, and to make the behavior of important methods unambiguous.

Visualizing where unit tests sit in the wider picture:

The pyramid is shaped the way it is because unit tests are cheap to run and cheap to write. You want lots of them at the bottom and fewer of the expensive kinds further up. This lesson lives entirely at the bottom layer.

Creating a Test Project

A test project is a regular .NET class library with a test framework added and a test runner attached. The dotnet new templates handle the setup for you.

For xUnit:

For NUnit:

Each template produces a .csproj that references the framework and the Microsoft test SDK. The xUnit version looks like this:

Three packages do the real work. Microsoft.NET.Test.Sdk plugs the project into dotnet test and the IDE test runners. xunit (or nunit) is the framework that defines the attributes and assertions. xunit.runner.visualstudio (or NUnit3TestAdapter for NUnit) is the adapter that lets the test SDK discover and run tests. coverlet.collector is for code coverage, covered later in this lesson.

A typical solution layout puts production code and tests side by side:

The test project references the production project so it can see the types it wants to test. The production project does not reference the test project. That keeps test code out of shipped binaries and makes the dependency direction clear.

A First xUnit Test

The shape of a test is small. A class holds a group of tests. Each test is a method tagged with [Fact]. The method has no parameters, no return value, and uses Assert to fail if the result is wrong.

Consider a small piece of production code in DiscountCalculator.cs:

A test for the happy path:

Run it:

The test created an instance of the class under test, called the method with specific inputs, and asserted the returned value. If Apply returns anything other than 90m, Assert.Equal throws, the test runner catches the exception, and the test is reported as failed.

The same shape covers error paths. Use Assert.Throws to check that an exception is raised:

Assert.Throws<T> runs the delegate and checks that it threw an exception of type T. If no exception is thrown, or a different type is thrown, the test fails. The delegate form is required because the assertion can't run the code itself, the framework has to wrap the call in a try/catch.

The AAA Pattern

A clean unit test reads in three steps. The pattern is so common it has a name: Arrange, Act, Assert.

The three sections are usually separated by a blank line, which makes the structure obvious at a glance:

You don't have to write the // Arrange / // Act / // Assert comments. Most teams drop them once the pattern becomes second nature, and rely on the blank lines instead. The comments are useful when teaching and when a test gets unusually long.

Two rules:

  • Act once. A test that calls the method under test twice is testing two things and will be hard to read when it fails. If you find yourself wanting to act twice, write two tests.
  • Assert one behavior. It's fine to have multiple Assert.Equal calls in one test when they all check facets of the same behavior. It's not fine to test "the happy path and the error path" in a single method.

When a test fails, the failure message points to a line. A test that does one thing tells you immediately what's broken. A test that does five things makes you read it carefully to figure out which thing.

Naming Tests

A test name is a sentence the failure log will read out loud. Make it specific. The most widely used pattern in .NET is MethodUnderTest_StateOrInput_ExpectedBehavior, separated by underscores.

Test nameWhat it tells you when it fails
Apply_TenPercentDiscountOnHundred_ReturnsNinetyThe discount calculation for 10% off $100 is wrong.
Apply_NegativeSubtotal_ThrowsArgumentOutOfRangeThe validation for negative subtotals is missing or wrong.
Apply_RateGreaterThanOne_ThrowsArgumentOutOfRangeThe upper bound check on the discount rate is missing.
CalculateTotal_EmptyCart_ReturnsZeroThe empty-cart case for total calculation is broken.

Names like Test1, DiscountTest, or WorksCorrectly tell you nothing when you see them in a failure log. The convention isn't enforced by the compiler, but the people reading the log later (including future you) will appreciate the precision.

You'll see other naming styles too. Some teams use a "Given/When/Then" form (GivenAHundredDollarCart_WhenApplyingTenPercent_ThenReturnsNinety), which is wordier but reads like a scenario. Pick a convention, write it down somewhere visible, and stick to it.

Parameterized Tests with [Theory]

Repeating the same test five times with different numbers is a code smell. xUnit's answer is [Theory], which lets a single test method accept parameters and run once per data row.

The data source for a [Theory] can be inline literals via [InlineData]:

The single method ran four times, once per [InlineData] row, and each run counts as a separate test in the report. If only one row fails, the failure message names that row's arguments, so you know exactly which combination broke.

[InlineData] only accepts compile-time constants. For richer data (objects, computed values, dates), use [MemberData], which points at a static property or method returning IEnumerable<object[]>:

The DiscountScenarios method is a regular method that yields one object[] per test run. Each array's length and order must match the parameter list on the [Theory] method. xUnit also supports [ClassData] for cases where the data source benefits from living in its own class implementing IEnumerable<object[]>.

Each [InlineData] or [MemberData] row counts as a separate test in the runner. A theory with 200 rows is 200 tests. That's fine for fast pure-function tests, but watch out when the body of the test does anything slow (database I/O, network, file system), because the cost multiplies.

NUnit Equivalents

NUnit predates xUnit and is still widely used, especially in older codebases and Unity projects. The concepts are the same; the attribute names and lifecycle differ.

The differences are mostly cosmetic. [Test] replaces [Fact]. [TestCase] replaces [InlineData]. [SetUp] runs before every test, similar to a constructor in xUnit. [OneTimeSetUp] runs once for the whole fixture. Assert.That(actual, Is.EqualTo(expected)) is NUnit's fluent assertion style; the older Assert.AreEqual(expected, actual) form still works but is no longer recommended.

A side-by-side comparison of the everyday pieces:

ConceptxUnitNUnit
Test class marker(none, any public class)[TestFixture] (optional in modern NUnit)
Test method[Fact][Test]
Parameterized test[Theory] + [InlineData] / [MemberData][TestCase] / [TestCaseSource]
Per-test setupConstructor[SetUp]
Per-test cleanupIDisposable.Dispose[TearDown]
One-time setup (per class)IClassFixture<T>[OneTimeSetUp]
Skip a test[Fact(Skip = "reason")][Ignore("reason")]
Expected exceptionAssert.Throws<T>(() => ...)Assert.Throws<T>(() => ...)

Which framework you pick usually comes down to what's already in the project. New projects often default to xUnit because it ships with the dotnet new templates and is the framework used by the .NET runtime team itself. Older projects, especially those originally targeting .NET Framework, lean NUnit. Both work fine; the unit testing concepts in this lesson apply equally to either.

The rest of the lesson uses xUnit syntax. Translating to NUnit is mechanical once you know the table above.

Fixtures and Shared State

A fixture is an object that's shared across tests, typically because it's expensive to build. A common case is a database connection, a configured HTTP client, or a service container.

xUnit creates a new instance of the test class for each test. That gives you natural isolation: each test gets a fresh constructor, so there's no leftover state from the previous test. For state that you want to share across tests in the same class, use IClassFixture<T>:

For state shared across multiple test classes, use ICollectionFixture<T> with a [CollectionDefinition]. That's how you share an expensive setup across a whole suite of tests.

A word of warning. Shared state is the most common source of flaky tests. If two tests share a fixture and one of them mutates it, the order in which they run starts to matter. Tests that pass on your laptop but fail on the build server, or pass when run alone but fail when run together, are almost always a shared-state problem. Keep fixtures immutable when possible. When you can't, reset the state in each test or use a fresh instance.

Asserting Cleanly

Both xUnit and NUnit ship with a useful but minimal Assert class. For most teams, the assertion vocabulary that ships with the framework is enough:

These work, but the failure messages aren't always informative. Assert.True(order.Total > 0) fails with Assert.True() Failure, which doesn't tell you what order.Total actually was.

FluentAssertions is a third-party library that wraps the assertion API in a more readable form and produces much better failure messages. Add it to the test project:

Then the same checks read like English:

When it fails, the message includes both the expected and actual values:

The fluent API covers most common cases:

The trade-off is one more dependency and a tiny bit of mental cost to learn the API. In return, the failure messages are substantially better, and the assertions read closer to a specification than a procedure. Most professional .NET teams pull in FluentAssertions on day one of a new project.

FluentAssertions builds rich diagnostic messages on failure, which involves reflection and string formatting. The overhead is irrelevant when tests pass (which they do most of the time), but very tight loops or asserts inside hot paths can be measurably slower than the built-in Assert.Equal. For unit tests, this never matters in practice.

Mocking with Moq

Most useful code has dependencies. A CheckoutService doesn't just compute things; it also talks to a payment provider, queries a stock service, sends an email. Tests that hit real implementations of those dependencies are slow, flaky, and order-dependent. The fix is to swap each dependency for a stand-in that the test controls.

A mock is a fake implementation of an interface. Moq is the most common mocking library for .NET. Add it:

Start with the dependency expressed as an interface. Interfaces are easy to mock; concrete classes are harder, so production code that needs to be testable usually depends on interfaces, not concretes.

The test uses Mock<T> to create a fake IInventoryService, tells it how to respond to specific calls, and then verifies the expected calls were made:

The pieces:

  • `new Mock<IInventoryService>()` creates a fake whose methods, by default, return default(T) (false for bool, 0 for int, null for reference types).
  • `Setup(...).Returns(...)` programs the fake to return a specific value when a specific method is called with specific arguments.
  • `It.IsAny<T>()` is a wildcard that matches any argument of type T. Useful when you don't care what the test passes in.
  • `mock.Object` gets the actual IInventoryService instance to hand to the system under test.
  • `Verify(...)` asserts that a method was (or was not) called, with arguments matching a pattern, the right number of times (Times.Once, Times.Never, Times.Exactly(3)).

The interaction between a mock and the code under test, drawn out:

The test controls both ends. It tells the mock what to return, lets the production code run against the mock, and then checks both the production code's output and the conversation it had with the mock.

A small note on style. Many teams reserve Verify for cases where the side effect is the whole point of the test (sending an email, writing to a log, calling a payment provider). For tests that already check a return value, an extra Verify adds noise. The rule of thumb is to assert on the outcome when one exists, and verify on the interaction when no outcome is observable.

A mock that's set up for too many calls quickly becomes harder to read than the code under test. If a single test has five Setup lines and four Verify lines, the system under test probably has too many collaborators. The mock count is a design signal.

Testing Async Code

C# code that uses async/await doesn't need a special test framework. Both xUnit and NUnit understand async Task test methods.

The production code:

The test method returns Task and uses await:

The test method signature is async Task, not async void. xUnit (and NUnit) detect the returned Task and await it before reporting the test as complete. async void tests would discard exceptions without raising them, which is the worst possible behavior for a test.

For async methods that throw, use Assert.ThrowsAsync<T>:

The await in front of Assert.ThrowsAsync matters. Without it, the assertion returns a Task that the test ignores, and any failure inside it would never be observed.

When mocking an async method, prefer ReturnsAsync(value) over Returns(Task.FromResult(value)). They're equivalent, but ReturnsAsync reads more clearly and handles Task<T> vs ValueTask<T> more consistently.

Code Coverage

Code coverage measures how much of the production code is exercised by the tests. The dotnet test command can collect it via the Coverlet collector that ships in the xUnit/NUnit templates.

The command runs the tests as usual and writes a coverage report (typically a coverage.cobertura.xml file) under TestResults/. Tools like ReportGenerator, Coverlet's own reporting options, or your IDE convert that XML into an HTML report that highlights every line of production code as covered (green) or not covered (red).

Coverage is a useful diagnostic, not a goal. A few honest observations:

  • Coverage tells you what is exercised, not what is correct. A test that calls a method and doesn't assert anything still counts toward coverage. So does a test that asserts the wrong thing.
  • 100% coverage is rarely worth the cost. The last 5-10% is usually defensive null checks, exception handlers, and edge cases that are expensive to set up and don't catch real bugs.
  • Coverage trends matter more than absolute numbers. A team that drops from 70% to 50% over a quarter is probably adding code without tests. A team holding 70% is probably fine.

Most teams target a coverage floor (60-80%) on production code that's part of the main business logic, and accept lower coverage on infrastructure glue (DTOs, config loaders, Program.cs). The exact number matters less than agreeing on one and watching it.

What Not to Test

It's easy to write tests that look thorough but add no value. A few patterns to avoid:

  • Trivial getters and setters. A test for cart.Total = 50; Assert.Equal(50, cart.Total); is testing the language, not your code. Test the behavior that uses the property, not the property itself.
  • Framework code. Don't test that string.IsNullOrEmpty("") returns true. Microsoft tested that. Test your method that uses it, with inputs you care about.
  • Private methods directly. Private methods are implementation details. If you feel the urge to test one, it usually wants to be its own class with a public method, or the public method that calls it needs a test that drives the private behavior indirectly.
  • Generated code. EF Core models, gRPC stubs, OpenAPI clients. The generator's tests cover the generator. Your tests should cover the code you wrote on top.
  • Logging and debug output. A test that asserts a specific log message is brittle and rarely catches anything useful. Test the behavior that produces the log, not the log text.

The principle behind the list is simple. A test exists to give you confidence that a behavior is correct. If the test would never catch a real defect, it costs maintenance and provides nothing in return.

Common Test Smells

Tests rot. The codebase changes, the tests don't get re-read, and over months the test suite can develop problems that hurt more than they help. A few common smells to watch for:

SmellWhat it looks likeWhy it's bad
Over-mockingA test with 8 Setup calls and 6 Verify calls.The mocks now encode the implementation. Any refactor breaks them, even when the behavior is right.
Test interdependenceTests pass when run together, fail when run alone (or vice versa).Shared state is leaking. The test order is implicit, and CI runs in a different order than your laptop.
Flaky testsSame test passes 90% of the time and fails 10%.Trust evaporates. People start ignoring failures and rerunning the build. Real bugs hide in the noise.
Snapshot tests on volatile outputAsserting on a 2000-line JSON dump that changes whenever any field is added.Every change becomes a test churn. The test stops indicating "is this correct" and starts indicating "did anything change."
God testsA 200-line test that exercises five behaviors.When it fails, you don't know which behavior is broken. Each behavior deserves its own test.
Magic literalsAssert.Equal(42, result); with no comment explaining why 42.Future you has no idea where 42 came from and is afraid to change it.
Conditional logic in testsif (...) Assert.Equal(...) else Assert.Equal(...).Either the test has two paths (split it) or one path is dead (delete it).

The fix for most smells is the same. Make tests small. Make them independent. Assert on behavior, not implementation. When a test is hard to write, the production code is usually the actual problem; the test is just where the pain shows up first.

Two specific fixes worth calling out. For flakiness, replace Thread.Sleep with an explicit synchronization mechanism, and replace DateTime.Now (which advances between calls) with an injected clock that the test controls. For over-mocking, ask whether the dependency genuinely belongs in this class or whether the test is screaming that the class has too many collaborators.

Putting It Together

A worked example that pulls together xUnit, parameterized tests, mocking, and FluentAssertions. The production class is a discount engine that uses an injected promotion service to decide which discount applies.

The production code:

The tests:

Running it:

Eight tests, all green. Notice what they cover:

  • The no-coupon happy path, with a Verify that the promotion service was never asked.
  • The valid-coupon happy path, four variations via [Theory].
  • The defensive path where the promotion service returns nonsense.
  • The argument validation path that throws.
  • The blank-coupon variants (null, "", whitespace), three of them, with the same expected outcome.

Each test is small, the AAA pattern is visible, the mock is set up only for what the test cares about, and the assertions read like specifications. If CalculateTotal changes in a way that breaks any of these behaviors, one or more of these tests will fail with a specific name and a specific message. That's the payoff.

Quiz

Testing Basics Quiz

10 quizzes