DEV Community

Cover image for xUnit: The Modern .NET Unit Testing Framework
Rhuturaj Takle
Rhuturaj Takle

Posted on

xUnit: The Modern .NET Unit Testing Framework

xUnit: The Modern .NET Unit Testing Framework

A practical guide to xUnit — the most widely used unit testing framework for .NET — covering test structure, assertions, fixtures and shared context, data-driven tests, mocking with test doubles, testing async code, and how xUnit fits into the layered testing strategy covered elsewhere in this series.


Table of Contents

  1. Introduction
  2. Anatomy of an xUnit Test
  3. Assertions
  4. Test Lifecycle: Why xUnit Has No [SetUp]
  5. Sharing Context with Fixtures
  6. Collection Fixtures for Cross-Class Sharing
  7. Data-Driven Tests: Theory and InlineData
  8. Testing Asynchronous Code
  9. Test Doubles: Mocks, Stubs, and Fakes
  10. Testing Exceptions
  11. Organizing and Running Tests
  12. Where xUnit Fits in the Testing Pyramid
  13. Common Pitfalls
  14. Quick Reference Table
  15. Conclusion

Introduction

xUnit is the most widely adopted unit testing framework in the modern .NET ecosystem — the default choice for new .NET projects (including being the template used by dotnet new for test projects), and the framework this series has referenced throughout its testing discussions (EF Core, Dapper, Vertical Slices, Feature Flags, and elsewhere) without yet giving it its own dedicated treatment. This guide covers that ground directly: how to structure tests, share setup logic correctly, write data-driven tests, and test asynchronous code idiomatically.

public class OrderTests
{
    [Fact]
    public void AddLineItem_IncreasesTotal()
    {
        var order = new Order(customerId: 1);
        order.AddLineItem(productId: 42, unitPrice: 10.00m, quantity: 2);

        Assert.Equal(20.00m, order.Total);
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a complete, runnable xUnit test — no base class to inherit from, no special test-runner attribute beyond [Fact], and a plain Assert call that reads almost like the assertion it's making.


1. Anatomy of an xUnit Test

A test class is just a plain class

public class OrderTests
{
    [Fact]
    public void NewOrder_HasZeroTotal()
    {
        var order = new Order(customerId: 1);
        Assert.Equal(0m, order.Total);
    }
}
Enter fullscreen mode Exit fullscreen mode

Unlike some testing frameworks, xUnit test classes require no special base class and no interface implementation — a test class is a plain C# class, and any public, parameterless, void-or-Task-returning method decorated with [Fact] is discovered and run automatically by the test runner.

[Fact]: a test with no parameters, exercising one specific scenario

[Fact]
public void Confirm_ThrowsWhenOrderHasNoLineItems()
{
    var order = new Order(customerId: 1);
    Assert.Throws<InvalidOperationException>(() => order.Confirm());
}
Enter fullscreen mode Exit fullscreen mode

[Fact] marks a test that runs exactly once, testing one specific, fixed scenario — this is the right choice for the majority of tests, reserving [Theory] (Section 6) specifically for cases where the same logical test needs to run repeatedly against multiple different input values.

Naming conventions worth adopting

// A common, readable convention: MethodUnderTest_Scenario_ExpectedBehavior
[Fact]
public void AddLineItem_WhenOrderIsConfirmed_ThrowsInvalidOperationException() { }

[Fact]
public void Confirm_WhenOrderHasLineItems_SetsStatusToConfirmed() { }
Enter fullscreen mode Exit fullscreen mode

xUnit doesn't enforce any particular naming convention, but a consistent one — naming the method under test, the scenario, and the expected outcome — makes a failing test's name alone, visible in a CI pipeline's test results (per this series' CI/CD Pipelines and GitHub Actions guides), immediately tell you what broke, without needing to open the test file to understand what was being verified.

The Arrange-Act-Assert structure

[Fact]
public void AddLineItem_IncreasesTotal()
{
    // Arrange: set up the scenario
    var order = new Order(customerId: 1);

    // Act: perform the action under test
    order.AddLineItem(productId: 42, unitPrice: 10.00m, quantity: 2);

    // Assert: verify the outcome
    Assert.Equal(20.00m, order.Total);
}
Enter fullscreen mode Exit fullscreen mode

This three-part structure — set up, act, verify — isn't an xUnit-specific feature, but it's the near-universal convention for structuring a unit test's body, and it's worth following consistently: a test that mixes setup, action, and assertion together without this visual separation is considerably harder to scan and understand quickly, especially once a test suite grows into the hundreds or thousands of tests a real production codebase accumulates.


2. Assertions

The Assert static class

Assert.Equal(expected, actual);
Assert.NotEqual(unexpected, actual);
Assert.True(condition);
Assert.False(condition);
Assert.Null(value);
Assert.NotNull(value);
Assert.Contains(item, collection);
Assert.DoesNotContain(item, collection);
Assert.Empty(collection);
Assert.Single(collection);
Assert.IsType<Order>(obj);
Enter fullscreen mode Exit fullscreen mode

xUnit's Assert class provides a broad, well-designed set of assertion methods — worth knowing the more specific ones (Assert.Single, Assert.Empty, Assert.Contains) rather than defaulting to Assert.True(collection.Count == 1) for everything, since a specific assertion produces a considerably more informative failure message.

Why specific assertions matter: the failure message difference

// ❌ A generic assertion — failure message tells you almost nothing
Assert.True(order.LineItems.Count == 1);
// Failure: "Assert.True() Failure: Expected: True, Actual: False"

// ✅ A specific assertion — failure message tells you exactly what was wrong
Assert.Single(order.LineItems);
// Failure: "Assert.Single() Failure: Expected: 1, Actual: 0"
Enter fullscreen mode Exit fullscreen mode

This is a genuinely practical, easy-to-underrate distinction — when a test fails months from now, possibly investigated by someone other than whoever wrote it, a specific assertion's failure message alone often tells you what went wrong without needing to attach a debugger; a generic Assert.True on a boolean expression forces exactly that kind of manual investigation every time.

Comparing objects and collections

Assert.Equal(expectedOrder, actualOrder); // uses .Equals() — works well for records/value objects (per this series' DDD guide)

Assert.Equal(new[] { 1, 2, 3 }, actualList); // sequence equality for collections — element-by-element comparison
Enter fullscreen mode Exit fullscreen mode

For types with meaningful structural equality (records, and DDD-style value objects, per this series' DDD and C# guides), Assert.Equal compares correctly out of the box; for collections, Assert.Equal performs a sequence comparison (same elements, same order) rather than reference equality, which is almost always the comparison you actually want in a test.

Custom assertion messages, sparingly

Assert.True(order.Total >= 0, $"Order total should never be negative, but was {order.Total}");
Enter fullscreen mode Exit fullscreen mode

A custom message is occasionally worth adding for an assertion whose failure wouldn't otherwise be self-explanatory — but as covered above, reaching for the most specific built-in assertion available is usually a better investment of effort than adding a custom message to a generic one.


3. Test Lifecycle: Why xUnit Has No [SetUp]

A deliberate design choice, different from other frameworks

// Some other testing frameworks (and older xUnit-adjacent frameworks) use an explicit setup method:
// [SetUp] public void Setup() { _order = new Order(1); }

// xUnit's approach: the CONSTRUCTOR is the setup
public class OrderTests
{
    private readonly Order _order;

    public OrderTests() // runs before EVERY test method in this class
    {
        _order = new Order(customerId: 1);
    }

    [Fact]
    public void NewOrder_HasZeroTotal() => Assert.Equal(0m, _order.Total);

    [Fact]
    public void AddLineItem_IncreasesTotal()
    {
        _order.AddLineItem(productId: 42, unitPrice: 10.00m, quantity: 2);
        Assert.Equal(20.00m, _order.Total);
    }
}
Enter fullscreen mode Exit fullscreen mode

xUnit deliberately has no [SetUp]/[TearDown] attributes — instead, a new instance of the test class is created for every single test method, and the constructor runs as that instance's setup. This is a genuinely important, sometimes surprising design decision worth understanding explicitly: it guarantees test isolation by construction — since every test gets a fresh instance, one test's mutations to instance fields can never accidentally leak into or affect another test, a class of bug that's possible in frameworks relying on a shared instance reset via an explicit [SetUp] method.

IDisposable as the teardown equivalent

public class DatabaseConnectionTests : IDisposable
{
    private readonly SqlConnection _connection;

    public DatabaseConnectionTests() // setup
    {
        _connection = new SqlConnection(TestConnectionString);
        _connection.Open();
    }

    public void Dispose() // teardown — called after EVERY test method
    {
        _connection.Dispose();
    }

    [Fact]
    public void Connection_IsOpen() => Assert.Equal(ConnectionState.Open, _connection.State);
}
Enter fullscreen mode Exit fullscreen mode

For teardown logic (closing a connection, cleaning up a temporary file), implementing IDisposable on the test class gives xUnit an explicit hook it calls automatically after each test method completes — the constructor/Dispose pair together form xUnit's complete per-test setup/teardown lifecycle, with no separate attributes needed.

Why per-test instantiation is the right default, even though it costs a little more

Creating a new instance (and running the constructor) for every single test does have a real, if usually small, performance cost compared to reusing one shared instance across many tests — but the isolation guarantee it provides is almost always worth that cost; a test suite where tests can silently affect each other's outcomes based on execution order is a genuinely worse problem to have than a marginally slower test run, and it's precisely the kind of flaky, hard-to-diagnose test behavior this series' CI/CD Pipelines guide warned against.


4. Sharing Context with Fixtures

The problem: some setup is genuinely expensive, and per-test re-creation is wasteful

Creating a new Order object: cheap, fine to do per-test (Section 3's default)
Spinning up a test database container, or an expensive in-memory service: genuinely expensive,
  worth sharing across many tests within the same class
Enter fullscreen mode Exit fullscreen mode

For setup that's costly enough that recreating it for every single test method would meaningfully slow down the test suite (per this series' CI/CD Pipelines guide's emphasis on fast feedback), xUnit provides the IClassFixture<T> mechanism specifically to share one instance of that expensive setup across every test in a class, while still preserving test isolation for the actual test data itself.

IClassFixture<T>: one shared instance per test class

public class DatabaseFixture : IDisposable
{
    public AppDbContext DbContext { get; }

    public DatabaseFixture()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlite("Filename=:memory:") // per this series' EF Core guide's testing fidelity discussion
            .Options;
        var connection = new SqliteConnection("Filename=:memory:");
        connection.Open();
        DbContext = new AppDbContext(options);
        DbContext.Database.EnsureCreated();
    }

    public void Dispose() => DbContext.Dispose();
}

public class OrderRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;
    public OrderRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;

    [Fact]
    public async Task GetById_ReturnsExistingOrder()
    {
        // uses _fixture.DbContext — the SAME instance across every test in this class
    }
}
Enter fullscreen mode Exit fullscreen mode

IClassFixture<DatabaseFixture> tells xUnit to create exactly one DatabaseFixture instance, shared across every test method in OrderRepositoryTests, injected via the test class's constructor — the expensive setup (spinning up the in-memory SQLite connection, per this series' EF Core guide) happens once, not once per test.

The trade-off: shared state needs careful test design

[Fact]
public async Task Test1_AddsAnOrder()
{
    _fixture.DbContext.Orders.Add(new Order(1));
    await _fixture.DbContext.SaveChangesAsync();
    // this order now persists in the SHARED context for every subsequent test
}
Enter fullscreen mode Exit fullscreen mode

Because the fixture instance — and anything stateful inside it, like a database connection with data already written to it — is genuinely shared across tests, individual tests within a fixture-sharing class need to be written carefully to avoid depending on, or being polluted by, another test's data changes; this is precisely the trade-off for the performance benefit, and it's why fixtures are reserved specifically for expensive setup, not applied as a default for every test class regardless of whether the setup is actually costly.


5. Collection Fixtures for Cross-Class Sharing

Sharing setup across multiple test classes, not just within one

[CollectionDefinition("Database collection")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

[Collection("Database collection")]
public class OrderRepositoryTests
{
    private readonly DatabaseFixture _fixture;
    public OrderRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;
}

[Collection("Database collection")]
public class CustomerRepositoryTests
{
    private readonly DatabaseFixture _fixture; // the SAME fixture instance as OrderRepositoryTests
    public CustomerRepositoryTests(DatabaseFixture fixture) => _fixture = fixture;
}
Enter fullscreen mode Exit fullscreen mode

Where IClassFixture<T> shares one instance within a single test class, a collection fixture extends that sharing across multiple test classes grouped into a named collection — useful when several test classes all need the same expensive shared setup (the same test database, the same WebApplicationFactory per this series' ASP.NET Core guide's integration testing pattern).

The concurrency implication: classes in the same collection run sequentially, not in parallel

xUnit runs test classes in different collections in parallel by default (a genuine performance benefit for a large test suite, connecting to this series' CI/CD Pipelines guide's emphasis on fast feedback), but test classes sharing a collection run sequentially relative to each other, specifically to avoid concurrent access issues against the shared fixture — this is a deliberate, important trade-off: collection fixtures buy you shared, expensive setup, at the cost of losing inter-class parallelism for everything in that collection.


6. Data-Driven Tests: Theory and InlineData

The problem: testing the same logic against many different inputs

// ❌ Repetitive — the same assertion logic, copy-pasted for each input
[Fact]
public void IsValidDiscount_Rejects_Negative() => Assert.False(IsValidDiscount(-5));
[Fact]
public void IsValidDiscount_Rejects_OverHundred() => Assert.False(IsValidDiscount(150));
[Fact]
public void IsValidDiscount_Accepts_Fifty() => Assert.True(IsValidDiscount(50));
Enter fullscreen mode Exit fullscreen mode

[Theory] and [InlineData]: one test method, many input combinations

[Theory]
[InlineData(-5, false)]
[InlineData(150, false)]
[InlineData(0, true)]
[InlineData(50, true)]
[InlineData(100, true)]
public void IsValidDiscount_ValidatesRange(int discount, bool expectedValid)
{
    Assert.Equal(expectedValid, IsValidDiscount(discount));
}
Enter fullscreen mode Exit fullscreen mode

[Theory] marks a test method as data-driven — xUnit runs the method once per [InlineData] attribute, each treated and reported as an independent test result, meaning a single test method definition can cover an entire range of boundary and typical-case inputs without any repeated code, and a failure clearly identifies exactly which specific input combination failed.

[MemberData] for more complex or reused test data

public static IEnumerable<object[]> DiscountTestCases()
{
    yield return new object[] { -5, false };
    yield return new object[] { 150, false };
    yield return new object[] { 50, true };
}

[Theory]
[MemberData(nameof(DiscountTestCases))]
public void IsValidDiscount_ValidatesRange(int discount, bool expectedValid)
{
    Assert.Equal(expectedValid, IsValidDiscount(discount));
}
Enter fullscreen mode Exit fullscreen mode

For test data too complex to express as literal [InlineData] values (objects, or data reused across multiple test methods), [MemberData] references a static method or property returning the test cases — worth reaching for once [InlineData] starts feeling cramped, particularly for object-shaped test inputs [InlineData]'s attribute-based syntax can't express directly.

[ClassData] for test data shared across multiple test classes

public class DiscountTestData : TheoryData<int, bool>
{
    public DiscountTestData()
    {
        Add(-5, false);
        Add(150, false);
        Add(50, true);
    }
}

[Theory]
[ClassData(typeof(DiscountTestData))]
public void IsValidDiscount_ValidatesRange(int discount, bool expectedValid) { }
Enter fullscreen mode Exit fullscreen mode

TheoryData<T1, T2, ...> (a strongly-typed alternative to the IEnumerable<object[]> pattern from [MemberData]) gives compile-time type checking on the test data itself, and — packaged as its own class — can be reused across multiple test classes that need the same data-driven scenarios.


7. Testing Asynchronous Code

async Task test methods, natively supported

[Fact]
public async Task PlaceOrderAsync_PersistsTheOrder()
{
    var repository = new InMemoryOrderRepository();
    var handler = new PlaceOrderHandler(repository);

    var orderId = await handler.HandleAsync(new PlaceOrderCommand(customerId: 1, items: new List<OrderItemDto>()));

    var savedOrder = await repository.GetByIdAsync(orderId);
    Assert.NotNull(savedOrder);
}
Enter fullscreen mode Exit fullscreen mode

xUnit test methods can be async Task directly — no special attribute or wrapper needed, unlike some older or other testing frameworks that historically required blocking on async code inside a synchronous test method (a pattern with well-documented deadlock risks, particularly in certain synchronization contexts). Writing the test method itself as async Task and await-ing the code under test is the correct, idiomatic approach.

Never block on async code inside a test

// ❌ Blocking on async code — a real deadlock risk in some contexts, and unnecessary since xUnit supports async tests natively
[Fact]
public void PlaceOrder_PersistsTheOrder()
{
    var result = handler.HandleAsync(command).Result; // .Result or .Wait() — avoid this
}

// ✅ The test method itself is async
[Fact]
public async Task PlaceOrderAsync_PersistsTheOrder()
{
    var result = await handler.HandleAsync(command);
}
Enter fullscreen mode Exit fullscreen mode

Since xUnit fully supports async Task test methods, there's never a genuine need to synchronously block on an async operation (.Result, .Wait()) inside a test — doing so anyway reintroduces exactly the deadlock risk this series' C# guide's async/await coverage warns against, for no benefit, since the async-native alternative is equally simple to write.

Testing code that uses CancellationToken

[Fact]
public async Task LongRunningOperation_RespectsCancellation()
{
    using var cts = new CancellationTokenSource();
    cts.CancelAfter(TimeSpan.FromMilliseconds(50));

    await Assert.ThrowsAsync<OperationCanceledException>(
        () => _service.LongRunningOperationAsync(cts.Token));
}
Enter fullscreen mode Exit fullscreen mode

Assert.ThrowsAsync<T> is the async counterpart to Assert.Throws<T> (Section 9) — necessary specifically because awaiting a task that throws requires the assertion itself to be awaited, and Assert.Throws (the synchronous version) can't correctly capture an exception thrown from inside an awaited async operation.


8. Test Doubles: Mocks, Stubs, and Fakes

xUnit itself doesn't include mocking — that's a deliberate, separate concern

xUnit is specifically a test runner and assertion framework; it doesn't provide mocking capability itself, which is a deliberate design choice (keeping the framework focused) rather than an oversight — mocking is handled by a separate, complementary library, most commonly Moq or NSubstitute in the .NET ecosystem.

Mocking a dependency with Moq

[Fact]
public async Task PlaceOrderAsync_SendsConfirmationEmail()
{
    var mockEmailService = new Mock<IEmailService>();
    var handler = new PlaceOrderHandler(new InMemoryOrderRepository(), mockEmailService.Object);

    await handler.HandleAsync(new PlaceOrderCommand(customerId: 1, items: SampleItems()));

    mockEmailService.Verify(
        s => s.SendOrderConfirmationAsync(It.IsAny<int>(), It.IsAny<string>()),
        Times.Once);
}
Enter fullscreen mode Exit fullscreen mode

A mock is a test double that lets you verify a specific interaction actually happened (here, confirming SendOrderConfirmationAsync was called exactly once) — Moq's Mock<T> generates a dynamic implementation of an interface, and .Verify() checks that specific calls occurred, with It.IsAny<T>() matching any argument of that type when the exact value isn't the point of the test.

Stubbing a return value

var mockInventoryService = new Mock<IInventoryService>();
mockInventoryService
    .Setup(s => s.CheckStockAsync(It.IsAny<int>()))
    .ReturnsAsync(15); // "stub" behavior: whenever CheckStockAsync is called, return 15
Enter fullscreen mode Exit fullscreen mode

Stubbing configures a test double to return a specific, controlled value when called — distinct from mocking's focus on verifying that a call happened; stubbing is about controlling what the dependency returns, letting the test exercise a specific code path (here, "there's plenty of stock") without depending on a real inventory service actually being available.

Fakes: a simpler, hand-written alternative for straightforward dependencies

public class InMemoryOrderRepository : IOrderRepository
{
    private readonly Dictionary<int, Order> _orders = new();
    public Task<Order?> GetByIdAsync(int id) => Task.FromResult(_orders.GetValueOrDefault(id));
    public Task AddAsync(Order order) { _orders[order.Id] = order; return Task.CompletedTask; }
}
Enter fullscreen mode Exit fullscreen mode

A fake is a genuine, working (if simplified) implementation, rather than a dynamically-generated mock — an in-memory repository implementation is often simpler to write, read, and reason about than an equivalent Moq setup, especially for a dependency (like a repository) whose interface is used extensively across many tests; this is the same in-memory fake pattern referenced in this series' Repository pattern discussion (Design Patterns guide) as one of the concrete reasons Repository earns its abstraction cost.

When to reach for a mock vs. a fake vs. the real thing

Mock:  when you need to VERIFY a specific interaction happened (an email was sent, an event was published)
Fake:   when you need a working, simplified substitute used across MANY tests (an in-memory repository)
Real:   integration tests, per this series' EF Core guide's Testcontainers discussion — highest fidelity,
         reserved for a smaller number of tests specifically verifying real-system behavior
Enter fullscreen mode Exit fullscreen mode

This maps directly onto the testing pyramid covered in this series' CI/CD Pipelines and Microservices guides — unit tests (the base of the pyramid) typically use mocks/fakes for speed and isolation; integration tests (higher up, fewer in number) increasingly use real or realistically-simulated dependencies specifically to verify behavior mocks and fakes can't fully guarantee matches reality.


9. Testing Exceptions

Assert.Throws<T> for synchronous exceptions

[Fact]
public void Confirm_ThrowsInvalidOperationException_WhenOrderHasNoLineItems()
{
    var order = new Order(customerId: 1);

    var exception = Assert.Throws<InvalidOperationException>(() => order.Confirm());

    Assert.Equal("Cannot confirm an order with no line items", exception.Message);
}
Enter fullscreen mode Exit fullscreen mode

Assert.Throws<T> both verifies that the expected exception type was thrown and returns the caught exception, letting you make further assertions against it (its message, or any custom properties) — a meaningfully stronger test than merely catching an exception in a manual try/catch block, since it also fails clearly and immediately if the wrong exception type (or no exception at all) is thrown.

Assert.ThrowsAsync<T> for asynchronous code

[Fact]
public async Task HandleAsync_ThrowsWhenCustomerNotFound()
{
    var handler = new PlaceOrderHandler(new InMemoryOrderRepository());

    await Assert.ThrowsAsync<CustomerNotFoundException>(
        () => handler.HandleAsync(new PlaceOrderCommand(customerId: 999, items: SampleItems())));
}
Enter fullscreen mode Exit fullscreen mode

As covered in Section 7, this is the required form for verifying an exception thrown from within an async operation — Assert.Throws (synchronous) cannot correctly observe an exception surfaced through a Task.

Testing business rule violations that use a Result type instead of exceptions

[Fact]
public async Task HandleAsync_ReturnsFailure_WhenOrderHasNoItems()
{
    var result = await handler.HandleAsync(new PlaceOrderCommand(customerId: 1, items: new List<OrderItemDto>()));

    Assert.False(result.IsSuccess);
    Assert.Equal("Order must have at least one item", result.Error);
}
Enter fullscreen mode Exit fullscreen mode

As covered in this series' Vertical Slices guide, many modern .NET codebases prefer a Result<T> return type over exceptions for expected, "this can legitimately fail" business outcomes — testing this pattern is simpler than exception testing (just a plain assertion against the result), which is one of the practical arguments in favor of the Result pattern for genuinely expected failure cases, reserving exceptions for truly exceptional, unexpected conditions.


10. Organizing and Running Tests

Project structure mirroring the code under test

MyApp/
  MyApp.csproj
  Features/Orders/PlaceOrder/PlaceOrderHandler.cs
MyApp.Tests/
  MyApp.Tests.csproj
  Features/Orders/PlaceOrder/PlaceOrderHandlerTests.cs
Enter fullscreen mode Exit fullscreen mode

Mirroring the production project's folder structure in the test project (directly connecting to this series' Vertical Slices guide's per-feature organization) makes it straightforward to find the tests for any given piece of code, and vice versa — a test project structure that diverges significantly from what it's testing tends to become harder to navigate as both grow.

Running tests via the .NET CLI

dotnet test
dotnet test --filter "FullyQualifiedName~OrderTests"
dotnet test --filter "Category=Integration"
dotnet test --logger "trx" --results-directory TestResults
Enter fullscreen mode Exit fullscreen mode

As referenced throughout this series' GitHub Actions and Azure DevOps guides, dotnet test is the standard command for running xUnit tests both locally and in CI — the --filter flag lets you run a subset of tests (by name, or by a [Trait]-based category, below), and --logger "trx" produces a structured results file CI pipelines can parse and publish as readable test reports.

[Trait] for categorizing tests

[Fact]
[Trait("Category", "Integration")]
public async Task GetOrderById_ReturnsOrderFromRealDatabase() { }

[Fact]
[Trait("Category", "Unit")]
public void AddLineItem_IncreasesTotal() { }
Enter fullscreen mode Exit fullscreen mode

[Trait] attaches arbitrary key-value metadata to a test, most commonly used to categorize tests (unit vs. integration, per this series' CI/CD Pipelines guide's testing pyramid) so a CI pipeline can run fast unit tests in an early, quick-feedback stage and slower integration tests in a later stage — directly implementing the fail-fast, layered testing strategy that guide describes.


11. Where xUnit Fits in the Testing Pyramid

xUnit as the primary tool for the pyramid's base

As covered in this series' CI/CD Pipelines guide's testing pyramid discussion, xUnit is typically the tool used for the large base of fast, isolated unit tests — testing a single class or a small cluster of closely related classes, using mocks/fakes (Section 8) to isolate the code under test from slower dependencies like a real database or network call.

xUnit for integration tests too, with real or realistic dependencies

public class OrderApiIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
    public OrderApiIntegrationTests(WebApplicationFactory<Program> factory) => _client = factory.CreateClient();

    [Fact]
    public async Task PostOrder_ReturnsCreated()
    {
        var response = await _client.PostAsJsonAsync("/orders", new PlaceOrderCommand(1, SampleItems()));
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
    }
}
Enter fullscreen mode Exit fullscreen mode

The same xUnit framework, combined with WebApplicationFactory (per this series' ASP.NET Core, EF Core, and Vertical Slices guides) and, for genuine database fidelity, Testcontainers (per the EF Core guide's testing section), is equally the standard tool for the pyramid's higher, slower, more integrated test layers — it's not exclusively a "unit testing" framework in the narrowest sense, despite the common shorthand; it's the general-purpose .NET test execution framework, applicable at every level of the pyramid.

Contract and end-to-end tests, briefly

As covered in this series' Microservices guide, contract tests (via a library like Pact) and full end-to-end tests can also be written and run as xUnit tests, using xUnit purely as the execution and assertion harness around test logic that happens to exercise a broader scope than a single class — the framework itself doesn't constrain which layer of the testing pyramid a given test belongs to; that's a function of what the test actually exercises and how it's structured, not which attribute it's decorated with.


12. Common Pitfalls

Pitfall Why it hurts Better approach
Blocking on async code inside a test (.Result, .Wait()) Unnecessary deadlock risk; xUnit fully supports async Task tests natively Write the test method itself as async Task
Generic assertions (Assert.True(x == y)) instead of specific ones Unhelpful failure messages, harder to diagnose without a debugger Use the most specific assertion available (Assert.Equal, Assert.Single, etc.)
Sharing mutable state via IClassFixture without considering test isolation Tests can pollute each other's data, causing order-dependent flakiness Design fixture-sharing tests carefully, or reset shared state between tests where needed
Reaching for [Theory]/[InlineData] reflexively for tests with no real data variation Unnecessary indirection for what's really just one scenario Use [Fact] for single, fixed scenarios; reserve [Theory] for genuine input variation
Test class names/structure diverging significantly from the production code they test Hard to locate the tests for a given piece of code as the codebase grows Mirror the production project's structure in the test project
No [Trait]-based categorization, running the full slow suite on every CI stage Slower feedback than necessary; violates the fail-fast principle Categorize unit vs. integration tests; run fast tests first in CI
Mocking everything, including simple, stable dependencies better served by a fake Verbose, brittle mock setup for dependencies that don't need interaction verification Use a fake (a real, simplified implementation) where verifying behavior, not interaction, is the goal

Quick Reference Table

Concept Purpose
[Fact] A test with a single, fixed scenario
[Theory] + [InlineData]/[MemberData]/[ClassData] A data-driven test run once per supplied input set
Constructor / IDisposable xUnit's setup/teardown mechanism — a fresh instance per test
IClassFixture<T> Shares one expensive setup instance across tests within a class
ICollectionFixture<T> + [Collection] Shares setup across multiple test classes; disables inter-class parallelism for that group
Assert.Throws<T> / Assert.ThrowsAsync<T> Verifies an expected exception, synchronous or asynchronous
Mock (Moq/NSubstitute) Verifies a specific interaction occurred
Stub Configures a controlled return value from a dependency
Fake A simplified, genuinely working substitute implementation
[Trait] Attaches categorization metadata, enabling filtered CI test runs

Conclusion

xUnit's design choices — constructor-based per-test setup guaranteeing isolation, native async Task support, and a deliberately narrow scope that leaves mocking to complementary libraries like Moq — reflect a framework built specifically around the lessons learned from earlier .NET testing frameworks' rougher edges, which is a large part of why it's become the default choice for new .NET projects. Understanding its lifecycle model (a fresh instance per test, fixtures for the genuine exceptions where sharing expensive setup is worth the trade-off) and its data-driven testing support ([Theory]) covers the large majority of what a well-structured .NET test suite actually needs.

Every testing discussion referenced throughout this series — the EF Core guide's testing fidelity spectrum, the Vertical Slices guide's per-slice isolation testing, the CI/CD Pipelines guide's testing pyramid, and the Microservices guide's contract testing — assumes xUnit (or a framework very much like it) as the execution engine underneath; this guide is where that assumption finally gets its own full, direct treatment, and the patterns here — Arrange-Act-Assert, specific assertions, careful fixture use, and a clear mock/stub/fake distinction — are what make a test suite something a team can actually trust as the safety net every guide in this series' CI/CD and quality-gate discussions depends on.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the specific assertion that turned a mystifying test failure into an instantly obvious one.

Top comments (0)