Unit Testing in .NET with xUnit: Getting Started
Start unit testing in .NET with xUnit: Facts and Theories, Arrange-Act-Assert, naming, NSubstitute test doubles, and running everything with dotnet test.
dotnet test. xUnit v3 is the current framework (v2 is maintenance-only), NSubstitute is the test-double library I pick and justify here, and the discipline that matters is testing observable behavior rather than implementation details. The trade-off of test doubles is test brittleness when overused; mock the collaborators that make isolation valuable, and leave everything else real.The scene that makes people want tests: a fix ships on Friday, and by Tuesday someone reports the same bug in a different endpoint, caused by a change nobody connected to that code. The regression was invisible to review and obvious to a test that would have taken ten minutes to write. Unit tests are the cheapest insurance in software: they run in milliseconds, they catch the exact class of bug that “I checked it locally” misses, and the same tests run in CI forever after (covered later in the deployment track).
Unit testing in .NET here means xUnit-based tests of code in isolation: the logic of a service, calculator, or validator without databases, HTTP, or the framework pipeline. The official testing overview maps the full territory. xunit.net is the framework’s home and the source for the v2 versus v3 guidance this page follows.
The examples assume .NET SDK 10.0 with C# 14 and test the TaskApi’s domain logic, whose interfaces as seams come from the object-oriented C# guide. The container wiring that makes seams injectable is the dependency injection guide‘s territory.
Maintaining both healthy and abandoned test suites showed me the difference: tests pay when they test behavior and they rot when they test wiring. Every rule below bends toward that line. If you want a shorter starting point first, I’ve written a separate guide on where to start with .NET unit testing that covers picking a framework and laying out test projects.
Setting up the test project
Two versions of xUnit exist today. The choice is settled: xUnit v3 is the active framework, supports .NET 8 and later, and is where all new features land. V2 (currently in maintenance, receiving only critical fixes) exists for older projects. The official position from the xUnit project is that developers on v2 should move to v3 at their earliest convenience. New projects should start on v3:
dotnet new install xunit.v3.templates
dotnet new xunit3 -o TaskApi.Tests
dotnet add TaskApi.Tests reference TaskApi
dotnet test
The v3 template adds a small wrinkle worth knowing: the project file references xunit.v3 and the Microsoft Testing Platform runner. It may create or update a global.json to pin the SDK for the test project, both visible in the template output. dotnet test runs the suite either way, so the day-to-day command is unchanged.
Add NSubstitute for test doubles (check nuget.org for the current version, since this guide deliberately quotes no package numbers):
dotnet add TaskApi.Tests package NSubstitute
Facts and Theories: the two test types
A [Fact] is a test with fixed inputs and one assertion story. A [Theory] is the same test run once per data row, which is how xUnit expresses “for all these cases”:
public class TaskItemTests
{
[Fact]
public void Complete_MarksTaskDone()
{
var task = new TaskItem { Title = "Ship the API" };
task.Complete();
Assert.True(task.Done);
}
[Theory]
[InlineData("", false)] // empty title: invalid
[InlineData(" ", false)] // whitespace: invalid
[InlineData("a", true)] // one char: valid
[InlineData(new string('x', 200), true)] // max length: valid
[InlineData(new string('x', 201), false)] // over limit: invalid
public void IsValidTitle_MatchesRules(string title, bool expected)
{
Assert.Equal(expected, TaskItem.IsValidTitle(title));
}
}
The theory replaces five copy-pasted facts with one readable table of cases, and the data is visible right above the assertion. For larger data sets, [MemberData] points at a property, and [ClassData] at a shared fixture class. Start with inline data and graduate only when rows grow.
Arrange-Act-Assert and naming
Every test has three parts, separated the same way every time: arrange the inputs, act on the unit under test, assert the observable result:
[Fact]
public void Complete_MissingTask_Throws()
{
// arrange
var repository = Substitute.For<ITaskRepository>();
repository.Find(Arg.Any<Guid>()).Returns((TaskItem?)null);
var service = new TaskService(repository);
// act + assert
Assert.Throws<InvalidOperationException>(() => service.Complete(Guid.NewGuid()));
}
Naming is a team decision, so make it once: the common conventions are Method_Scenario_ExpectedResult or Given_When_Then. Both put the behavior in the name, which is the point. A failing test that reads Complete_MissingTask_Throws tells the reader what broke before they open the file.
Test doubles with NSubstitute, and why it is the pick
Three mocking libraries dominate .NET: Moq, NSubstitute, and FakeItEasy. I pick NSubstitute, for reasons that matter more than syntax preference. It is free under Apache 2, actively maintained, its API reads like plain assertions (substitute.Received().Method() instead of Verify(...) expressions with callback trees). It has no history of bundling telemetry or installer hooks with the library. Moq’s 2023 SponsorLink incident (bundling a package that collected developer data, later removed) damaged trust in tooling that runs in every repository, and although Moq reversed course, the episode is a reasonable reason for a team to standardize on a library that never went there. The bottom line: all three libraries do the same job, so pick one, write it in the team handbook, and stay consistent.
The vocabulary is small. A substitute is created for an interface, configured with Returns (the stub role), and checked with Received (the mock role):
[Fact]
public void Complete_SavesChangedTask()
{
// arrange
var task = new TaskItem { Title = "Ship the API" };
var repository = Substitute.For<ITaskRepository>();
repository.Find(task.Id).Returns(task);
var service = new TaskService(repository);
// act
service.Complete(task.Id);
// assert: the service saved the mutated task
repository.Received(1).Save(task);
Assert.True(task.Done);
}
ROLE SYNTAX QUESTION IT ANSWERS
stub x.Find(id).Returns(task) "what should happen when..."
mock x.Received(1).Save(task) "did the unit call..."
loose arg Arg.Any<Guid>() "for any input of this kind"
Two rules keep doubles honest. Use Arg.Any sparingly: the more specific the argument checks, the more the test proves. Never substitute records, value types, or simple classes. Real objects make tests simpler and more truthful than doubles ever can.
Running and filtering
One command runs everything, and the output is your feedback loop:
dotnet test
# expected output (trimmed):
# Passed! - Failed: 0, Passed: 12, Skipped: 0, Total: 12, Duration: 34 ms
Filtering keeps the loop tight while you work on one area: dotnet test --filter "FullyQualifiedName~TaskService" runs only the tests whose full name contains TaskService. The same command, unfiltered, is what CI runs on every pull request, which the CI/CD guide places in the pipeline’s test stage.
How real systems do this
- One test project per solution area, named after what it tests.
TaskApi.Testsfor the domain, integration tests in their own project because they run slower and need infrastructure. The split keeps the fast feedback loop fast. - Deterministic tests only. Time comes from an injected clock, randomness from an injected source. A test that fails only sometimes is treated as a bug in the test, not a ticket to retry.
- Coverage as a signal, not a target. Teams look at uncovered areas to find missing tests, and do not chase a percentage, because a test with no assertion raises coverage the same as a good one.
A habit I formed in production: every time a bug reached staging or production, the first fix was the regression test, written before the code fix, failing against the current code. The second fix was the code change, and watching the new test flip from red to green was the proof. Over a year the suite became a catalog of every bug this codebase ever had, and re-releases of old bugs stopped almost completely. What I took from it: the bug you just saw is a test specification written by reality itself.
Decision framework: what gets a unit test
- Does it contain rules, branching, or math? Yes: unit test it, with theories for the boundaries. Straight-line glue code gets no test of its own.
- Does it talk to a database, HTTP, or the framework pipeline? That is integration territory. Cover the wiring in the WebApplicationFactory guide, and unit test only the logic around it.
- Does the test need a collaborator’s response? Substitute the interface seam, configure what you need with
Returns, and assert the interaction withReceivedonly when the interaction itself is the contract. - Would several inputs behave differently? Write one theory with inline rows instead of several near-identical facts.
- Is the test hard to name? If the name needs “and”, the test covers two behaviors. Split it until each name says one thing.
When NOT to write a unit test
- Generated code, mappings, and one-line delegates. If a change to the code requires an identical change to the test, the test documents nothing. Let the surrounding behavior’s tests cover it.
- Framework plumbing you did not write. Testing that model binding or LINQ works is testing Microsoft’s code. Test your rules, and let integration tests cover your configuration of the framework.
- Private methods. They are implementation details. Test the public behavior that exercises them. When a private method really wants its own tests, that is usually the design asking you to extract a class.
Common mistakes
- Testing implementation details. A test that asserts how many internal calls happened breaks on every refactor. Assert observable outcomes and reserve
Receivedfor interactions that are the contract. - Substituting value objects and records. A substitute of a record proves nothing about your logic; use the real object.
- Shared mutable state across tests. One test’s leftovers change another’s outcome. Each test arranges its own world. XUnit runs each test method on a fresh class instance for exactly this reason.
- Multiple behaviors in one test. When a test name needs “and”, it hides which assertion failed; split by behavior so failures point at one rule.
- Nondeterministic inputs.
DateTime.UtcNowandRandominside the unit under test make tests flaky. Inject a clock or seeded source and pin the test’s world. - Living with flaky tests. Retrying a sometimes-red suite trains the team to ignore red, which is the same as having no suite. Flaky means broken, so fix or delete.
Key takeaways
- xUnit v3 is the current framework and the default for new projects. Install its templates with
dotnet new install xunit.v3.templates, create withdotnet new xunit3, run withdotnet test. [Fact]for one fixed case,[Theory]with[InlineData]for a table of cases; boundaries and invalid inputs belong in theories.- Arrange-Act-Assert in every test, with a name that states the behavior (
Method_Scenario_ExpectedResultorGiven_When_Then). - NSubstitute is this guide’s pick: free, maintained, assertion-like syntax, and no telemetry history; one library, used consistently, beats any debate.
- Substitute interface seams to isolate the unit:
Returnsto stub responses,Receivedto assert interactions, real objects for everything else. - Test behavior, not wiring. Rules, branching, and boundaries get unit tests, while databases and the framework pipeline get the integration track.
FAQ
Should I use xUnit v2 or v3 for new projects?
Use xUnit v3. It is the actively developed version, supports .NET 8 and later, and the xUnit project itself recommends upgrading from v2, which is in maintenance mode and receives only critical fixes. Install the v3 templates with dotnet new install xunit.v3.templates and create projects with dotnet new xunit3. Dotnet test works the same way.
What is the difference between Fact and Theory in xUnit?
A Fact is a single test with fixed inputs. A Theory runs the same test body once per data row, supplied with InlineData attributes or MemberData and ClassData for larger sets. Use theories when one rule has many cases, such as validation boundaries, so the cases read as a table next to the assertion.
How do I mock dependencies in .NET unit tests?
Create substitutes for the interfaces the unit depends on, using a library such as NSubstitute. Configure responses with Returns, and verify interactions with Received, so the test isolates the unit from its collaborators. Only mock interface seams you own, and prefer real value objects over substitutes wherever possible.
How do I run specific tests with dotnet test?
Use the –filter option, for example dotnet test –filter “FullyQualifiedName~TaskService” to run every test whose full name contains TaskService, or –filter “FullyQualifiedName=MyClass.MyTest” for one exact test. Without a filter, dotnet test runs the whole suite, which is what CI does on every build.
What should I unit test in a .NET service?
Test code with rules: branching, calculations, validation, mapping, and state transitions. Give every bug a regression test before fixing it. Do not unit test framework plumbing, one-line mappings, or private methods. Those are covered indirectly by the tests of the behavior they support, and the framework wiring belongs to integration tests.
Test the behavior, not the implementation
A good suite reads like a specification of the system: names state behaviors, theories enumerate the boundaries, and every historical bug appears as a case. The unit layer keeps that loop fast and deterministic, the integration layer (next in this series) covers the framework wiring, and CI runs both on every change. Continue with integration testing with WebApplicationFactory, and every guide in the series lives on the .NET series index. The rule of thumb: a test that breaks when behavior is unchanged is testing the wrong thing. Test what the system does, not how it does it.
Last updated on 14 November 2026