HT
How Things Work
System Online

xUnit & NUnit (.NET)

The .NET unit-testing frameworks — facts vs theories, data-driven [InlineData]/[TestCase], assertions and fixtures, and the crucial lifecycle difference that affects test isolation.

How It Works

xUnit and NUnit are the standard frameworks for testing .NET code. They provide test discovery, assertions, fixtures, and data-driven tests via [Theory]/[InlineData] or [TestCase]. Their biggest practical difference is the lifecycle: xUnit creates a fresh test-class instance per test for isolation by default, while NUnit reuses one instance with explicit [SetUp]/[TearDown].

1
The .NET Test Frameworks

xUnit and NUnit are the two dominant .NET unit-testing frameworks (MSTest is the third). They discover and run test methods, provide assertions, and report results to the runner (dotnet test, IDE, CI). xUnit is the modern default for new projects; NUnit has a long history and rich attribute set.

2
Facts, Theories & Test Cases

A [Fact] (xUnit) or [Test] (NUnit) is a single test. Data-driven tests run the same logic over many inputs: xUnit's [Theory] with [InlineData]/[MemberData], or NUnit's [TestCase]/[TestCaseSource]. Each data row is reported as its own pass/fail, so one method cleanly covers many edge cases.

3
The Lifecycle Difference

The key distinction: xUnit creates a new instance of the test class for every test method, giving isolation by default — shared setup goes in the constructor, cleanup in Dispose, and cross-test context via IClassFixture/ICollectionFixture. NUnit reuses one instance and relies on [SetUp]/[TearDown] (per test) and [OneTimeSetUp]/[OneTimeTearDown] (per fixture).

4
Assertions & Fixtures

Both ship assertion APIs (Assert.Equal, Assert.That), and many teams add FluentAssertions for readable, expressive checks. Fixtures share expensive setup (a database, a test host) across tests without re-creating it each time — but shared fixtures must be used carefully to avoid cross-test coupling.

Key Concepts

✔️Fact / Test

A single, parameterless test method ([Fact] in xUnit, [Test] in NUnit).

🔢Theory / TestCase

A data-driven test run once per data row, each reported as a separate case — ideal for edge cases.

🧼Per-Test Isolation (xUnit)

xUnit news up a fresh test-class instance for every test, so no state leaks between tests.

🔁SetUp / TearDown (NUnit)

NUnit hooks that run before/after each test on a shared instance; OneTime variants run per fixture.

🧰Fixture

Shared, expensive setup (DB, host) reused across tests via IClassFixture (xUnit) or OneTimeSetUp (NUnit).

💬FluentAssertions

A popular library for readable assertions: result.Should().Be(80) instead of Assert.Equal.

xUnit Theory vs NUnit TestCase
tsx
1// xUnit — fresh instance per test; data-driven with [Theory]/[InlineData]
2public class LeapYearTests : IDisposable {
3 private readonly Calendar _sut = new(); // ctor runs BEFORE EACH test
4
5 [Theory] // one method, many cases
6 [InlineData(2024, true)]
7 [InlineData(1900, false)] // ÷100 but not ÷400 → not leap
8 [InlineData(2000, true)] // ÷400 → leap
9 public void IsLeapYear(int year, bool expected)
10 => _sut.IsLeapYear(year).Should().Be(expected); // FluentAssertions
11
12 public void Dispose() { /* runs AFTER EACH test */ }
13}
14
15// NUnit — one shared instance; explicit SetUp/TearDown
16public class LeapYearTests {
17 private Calendar _sut;
18 [SetUp] public void Setup() => _sut = new Calendar(); // before each
19 [TestCase(2024, true)]
20 [TestCase(1900, false)]
21 public void IsLeapYear(int year, bool expected)
22 => Assert.That(_sut.IsLeapYear(year), Is.EqualTo(expected));
23 [TearDown] public void Cleanup() { /* after each */ }
24}
💡
Why This Matters

Knowing your test framework well — especially data-driven tests and the lifecycle/isolation model — is essential for writing maintainable .NET test suites and avoiding subtle shared-state bugs. It's also routinely probed in .NET interviews, where the xUnit-vs-NUnit lifecycle distinction is a favorite.

Common Pitfalls

Relying on test execution order — tests must be independent; never assume one runs before another.
Sharing mutable fixture/static state across tests, causing order-dependent flaky failures.
Misusing IClassFixture/OneTimeSetUp for state that should be per-test, leaking data between tests.
Copy-pasting near-identical tests instead of using a [Theory]/[TestCase] data source.
Putting slow I/O (real DB, network) in 'unit' tests, blurring the line with integration tests.
Real-World Use Cases

1Twelve Near-Identical Tests Become One Theory

Scenario

A pricing rule had twelve copy-pasted test methods — one per tier and boundary — differing only in input and expected output. Adding a tier meant copying another method, and a fix to the assertion had to be made twelve times.

Problem

Duplicated test methods are noisy, error-prone, and hard to maintain. The intent (a table of input→output cases) is buried in boilerplate, and coverage gaps are easy to miss.

Solution

Collapse them into a single [Theory] with [InlineData] rows (or [MemberData] from a data source). Each row is still reported individually in the runner, so failures pinpoint the exact input — but there's one method to maintain and adding a case is one line.

💡

Takeaway: Use data-driven tests for any logic you'd otherwise test with copy-pasted methods. One [Theory] with many rows is concise, exhaustive, and still reports per-case results.

2The Test That Passed Alone but Failed in the Suite

Scenario

A test passed when run by itself but failed when the whole suite ran. It turned out a previous test had mutated a static cache the failing test depended on — a classic shared-state bug.

Problem

State leaked between tests. On a framework with a shared instance (or via static/global state), one test's side effects corrupted another, producing order-dependent, hard-to-reproduce failures.

Solution

Lean on xUnit's per-test instance isolation (or NUnit's [SetUp] resetting state every test), and eliminate static mutable state in the code under test. Put shared expensive setup behind a fixture that's read-only or reset between tests. Each test now starts from a clean, known state.

💡

Takeaway: Test isolation is non-negotiable. Understand your framework's lifecycle (fresh instance vs SetUp) and avoid shared mutable state so tests pass or fail on their own merits, regardless of order.

Join the discussion

Comments

?

Loading comments...

Cookie preferences

We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.