Post

.NET Testing — Part 1: Getting Started with xUnit.net

.NET Testing — Part 1: Getting Started with xUnit.net

Series Overview

This is a 3-part series on testing .NET applications:

  1. Getting Started with xUnit.net (this article) — Project setup, writing tests, data-driven tests, fixtures, parallelism, and advanced features
  2. Writing Readable Tests with Fluent Assertions — Natural-language assertions, clearer failure messages, and custom assertions
  3. Integration Testing with Testcontainers — Testing against real databases using Docker containers and CI setup

Why xUnit.net?

xUnit.net is the most widely-used testing framework in the .NET ecosystem. It was built by the original creators of NUnit with a focus on simplicity, extensibility, and modern .NET idioms. If you’re writing tests for a .NET application today, xUnit is the default choice.

Setting Up Your First Test Project

Create a new xUnit test project with the .NET CLI:

1
dotnet new xunit -o ./tests/MyApp.Unit.Tests -n MyApp.Unit.Tests

This scaffolds a class library project with the essential NuGet packages:

PackagePurpose
xunitThe testing framework itself
xunit.runner.visualstudioVisual Studio / VS Code test runner
Microsoft.NET.Test.SdkThe VSTest platform that runs your tests
coverlet.collectorCode coverage collection

Writing Your First Test

Every method annotated with [Fact] is a test:

1
2
3
4
5
6
7
8
9
10
11
using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_1and2_gives3()
    {
        var result = Add(1, 2);
        Assert.Equal(3, result);
    }
}

The class must be public, but no attributes are needed on the class itself. Run your tests with:

1
dotnet test

Skipping a Test

Add the Skip property to temporarily disable a test:

1
2
3
4
5
[Fact(Skip = "Waiting on API endpoint to be deployed")]
public void CallsExternalApi()
{
    Assert.False(true);
}

Data-Driven Tests with Theory

The real power of xUnit comes from [Theory] — a single test method that runs against multiple data sets. There are four ways to provide data:

1
2
3
4
5
6
7
8
[Theory]
[InlineData(1, 2, 3)]              // Constant values
[MemberData(nameof(AdditionData))] // From a static property/method
[ClassData(typeof(AdditionTestData))] // From a class implementing IEnumerable<object[]>
public void Add_ReturnsCorrectSum(int a, int b, int expected)
{
    Assert.Equal(expected, a + b);
}

InlineData — Simple constants

Best for a handful of cases with primitive values. The data lives right next to the test.

MemberData — Static properties or methods

Use TheoryData<T> for strong typing:

1
2
3
4
5
public static TheoryData<int, int, int> AdditionData => new()
{
    { 18, 24, 42 },
    { 6, 7, 13 },
};

ClassData — Reusable data classes

When the same data set feeds multiple test methods:

1
2
3
4
5
6
7
8
9
10
public class AdditionTestData : IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 9, 1, 10 };
        yield return new object[] { 9, 10, 19 };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

Custom DataAttribute

For full control, inherit from DataAttribute:

1
2
3
4
5
6
7
private class CustomDataAttribute : DataAttribute
{
    public override IEnumerable<object[]> GetData(MethodInfo testMethod)
    {
        yield return new object[] { 2, 3, 5 };
    }
}

The Built-in Assert API

xUnit provides a comprehensive set of assertions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Equality
Assert.Equal(42, value);
Assert.NotEqual(42, value);

// Booleans
Assert.True(condition, "should be true");
Assert.False(condition, "should be false");

// Strings
Assert.Equal("expected", str, ignoreCase: false);
Assert.StartsWith("prefix", str);
Assert.EndsWith("suffix", str);
Assert.Matches("[0-9]+", str);

// Collections
Assert.Empty(collection);
Assert.Single(collection);
Assert.Contains(item, collection);
Assert.All(collection, item => Assert.InRange(item, 0, 10));
Assert.Collection(collection,
    item => Assert.Equal(1, item),
    item => Assert.Equal(2, item));

// Exceptions
var ex = Assert.Throws<ArgumentException>(() => sut.BadMethod());
Assert.Equal("message", ex.Message);

var ex2 = await Assert.ThrowsAsync<InvalidOperationException>(
    () => sut.BadMethodAsync());

// Events
Assert.Raises<EventArgs>(
    handler => obj.MyEvent += handler,
    handler => obj.MyEvent -= handler,
    () => obj.RaiseEvent());

While these work fine, part 2 of this series covers Fluent Assertions — a library that makes your assertions read like plain English.

Setup and Teardown

xUnit uses constructor/disposable patterns instead of [SetUp]/[TearDown] attributes. This is more idiomatic C# and clearer about lifecycle.

Per-Test Setup — Constructor and IDisposable

xUnit creates a new instance of the test class for every test, so the constructor runs before each test:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class OrderTests : IDisposable, IAsyncLifetime
{
    public OrderTests()
    {
        // Runs before each test
    }

    public void Dispose()
    {
        // Runs after each test
    }

    // For async setup/teardown:
    public Task InitializeAsync() => Task.CompletedTask;
    public Task DisposeAsync() => Task.CompletedTask;

    [Fact]
    public void Test1() { }

    [Fact]
    public void Test2() { }
}

Per-Class Setup — IClassFixture

When setup is expensive (e.g., creating a database connection), share it across all tests in a class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DatabaseFixture : IDisposable, IAsyncLifetime
{
    public DatabaseFixture()
    {
        // Called once before all tests in the class
    }

    public void Dispose()
    {
        // Called once after all tests in the class
    }

    public Task InitializeAsync() => Task.CompletedTask;
    public Task DisposeAsync() => Task.CompletedTask;
}

public class OrderTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _fixture;

    public OrderTests(DatabaseFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public void Test1() { }
}

Cross-Class Setup — Collection Fixtures

Share a fixture across multiple test classes using collections:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

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

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

Parallel Execution

By default, xUnit runs collections in parallel and creates one collection per class. Understanding this is important for test performance.

Two classes with no explicit collection run in parallel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// These two classes run in parallel (~10 seconds total)
public class UnitTest1
{
    [Fact]
    public async Task Test1() => await Task.Delay(5000);
    [Fact]
    public async Task Test2() => await Task.Delay(5000);
}

public class UnitTest2
{
    [Fact]
    public void Test3() => Thread.Sleep(5000);
    [Fact]
    public void Test4() => Thread.Sleep(5000);
}

Placing them in the same collection forces sequential execution:

1
2
3
4
5
6
// Same collection = sequential (~20 seconds total)
[Collection("Sequential")]
public class UnitTest1 { ... }

[Collection("Sequential")]
public class UnitTest2 { ... }

Use explicit collections when tests share state that isn’t thread-safe.

Categorising Tests with Traits

Traits let you tag and filter tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
internal static class TestCategories
{
    public const string Category = "Category";

    public static class Values
    {
        public const string Integration = "Integration";
        public const string Unit = "Unit";
    }
}

[Trait(TestCategories.Category, TestCategories.Values.Integration)]
public class ApiIntegrationTests
{
    [Fact]
    [Trait("Issue", "123")]
    public void CallsExternalApi() { }
}

Then filter at the command line:

1
2
dotnet test --filter "Category=Integration"    # Run only integration tests
dotnet test --filter "Category!=Integration"   # Exclude integration tests

Diagnostic Output with ITestOutputHelper

Console.WriteLine is swallowed by the test runner. Use ITestOutputHelper instead — especially valuable for debugging CI failures:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class OrderTests
{
    private readonly ITestOutputHelper _output;

    public OrderTests(ITestOutputHelper output) => _output = output;

    [Fact]
    public void ProcessOrder()
    {
        _output.WriteLine("Processing order with ID: 42");
        // ...
    }
}

Customising Test Display Names

By default, xUnit shows the method name. Override it with DisplayName:

1
2
3
4
5
[Fact(DisplayName = "1 + 1 = 2")]
public void Test_that_1_plus_1_eq_2()
{
    Assert.Equal(2, 1 + 1);
}

Or configure it project-wide with xunit.runner.json at the test project root:

1
2
3
4
5
{
  "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
  "methodDisplay": "method",
  "methodDisplayOptions": "replaceUnderscoreWithSpace,useOperatorMonikers,useEscapeSequences"
}

With this config, Test_that_1_X2B_1_eq_3 displays as Test that 1 + 1 = 3.

Dynamically Skipping Tests

Create a custom attribute to conditionally skip tests based on the environment:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public sealed class IgnoreOnWindowsFactAttribute : FactAttribute
{
    public IgnoreOnWindowsFactAttribute()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            Skip = "Ignored on Windows";
        }
    }
}

public class PlatformTests
{
    [IgnoreOnWindowsFact]
    public void UnixOnlyTest() { }
}

Multi-Targeting

Run tests against multiple .NET versions by editing the project file:

1
2
3
<PropertyGroup>
    <TargetFrameworks>net8.0;net9.0</TargetFrameworks>
</PropertyGroup>
1
2
dotnet test                    # Runs against all targets
dotnet test --framework net8.0 # Runs against a specific target

What’s Next?

xUnit gives you a solid foundation, but the built-in Assert API can be verbose and produce unhelpful failure messages. In Part 2, we’ll add Fluent Assertions to make tests read like English and get much better error output when things fail.

References

This post is licensed under CC BY 4.0 by the author.