xUnit.net Guide: Complete Tutorial for C# and .NET Testing

xUnit.net Guide: Complete Tutorial for C# and .NET Testing

xUnit.net is the most modern .NET testing framework, favored by Microsoft's own teams and the open-source community. It's the default choice for new .NET projects. This guide covers installation, writing Facts and Theories, using assertions, mocking dependencies, async testing, and running tests in CI pipelines.

Key Takeaways

xUnit uses [Fact] and [Theory] instead of [TestMethod]. Facts are single-scenario tests. Theories are data-driven tests with [InlineData] or [MemberData]. This is more expressive than MSTest or NUnit's attribute model.

Constructor/Dispose replaces [SetUp]/[TearDown]. xUnit creates a new class instance per test, so use the constructor for setup and IDisposable for teardown. This enforces test isolation by design.

ITestOutputHelper replaces Console.WriteLine. Inject ITestOutputHelper into your test class constructor for captured, per-test output — Console output is captured but harder to correlate to specific tests.

Assert.Throws<T> is synchronous; use await Assert.ThrowsAsync<T> for async. Mixing these is a common mistake that causes tests to pass incorrectly.

xUnit is the default in .NET templates. dotnet new xunit scaffolds a ready-to-run test project. For new projects, there's rarely a reason to choose otherwise.

What Is xUnit.net?

xUnit.net is an open-source unit testing framework for .NET, created by the original authors of NUnit v2. It's the testing framework used internally by the .NET team at Microsoft and is the default in many dotnet new templates.

Compared to NUnit and MSTest, xUnit is more opinionated. It:

  • Discourages shared state between tests (new instance per test)
  • Uses constructor injection instead of [SetUp]
  • Eliminates [TestFixture] attributes entirely
  • Promotes explicit, readable test code

Installing xUnit.net

New Project

dotnet new xunit -n MyProject.Tests
cd MyProject.Tests
dotnet add reference ../MyProject/MyProject.csproj

This creates a project with xUnit, xunit.runner.visualstudio, and Microsoft.NET.Test.Sdk already referenced.

Existing Project

dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk

Your .csproj should look like:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
    <PackageReference Include="xunit" Version="2.8.0" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" />
  </ItemGroup>
</Project>

Writing Your First Test

using Xunit;

public class CalculatorTests
{
    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        var result = calculator.Add(2, 3);

        // Assert
        Assert.Equal(5, result);
    }
}

Run it:

dotnet test

Naming Convention

xUnit test names follow the MethodName_Scenario_ExpectedResult pattern:

// Method: Divide
// Scenario: DivideByZero
// Expected: ThrowsDivideByZeroException
public void Divide_ByZero_ThrowsDivideByZeroException()

This naming makes test output self-documenting — when a test fails, you know exactly what broke.

Facts vs Theories

[Fact] — Single Scenario

A [Fact] runs once with no parameters:

[Fact]
public void IsEmpty_EmptyList_ReturnsTrue()
{
    var list = new List<int>();
    Assert.True(list.Count == 0);
}

[Theory] — Data-Driven Tests

A [Theory] runs multiple times with different inputs. Use [InlineData] for simple values:

[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(int.MaxValue, 1, int.MinValue)] // overflow
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
    var calculator = new Calculator();
    Assert.Equal(expected, calculator.Add(a, b));
}

For complex data, use [MemberData]:

public static IEnumerable<object[]> AddTestData =>
    new List<object[]>
    {
        new object[] { new Order { Quantity = 5, Price = 10.0m }, 50.0m },
        new object[] { new Order { Quantity = 0, Price = 10.0m }, 0.0m },
        new object[] { new Order { Quantity = 3, Price = 0.0m }, 0.0m },
    };

[Theory]
[MemberData(nameof(AddTestData))]
public void CalculateTotal_Order_ReturnsExpectedTotal(Order order, decimal expected)
{
    var service = new OrderService();
    Assert.Equal(expected, service.CalculateTotal(order));
}

Or use [ClassData] for a dedicated data class:

public class PrimeNumberData : TheoryData<int>
{
    public PrimeNumberData()
    {
        Add(2);
        Add(3);
        Add(5);
        Add(7);
        Add(11);
    }
}

[Theory]
[ClassData(typeof(PrimeNumberData))]
public void IsPrime_PrimeNumbers_ReturnsTrue(int number)
{
    Assert.True(MathHelper.IsPrime(number));
}

Assertions

xUnit's Assert class covers the most common scenarios:

Equality

Assert.Equal(expected, actual);          // value equality
Assert.NotEqual(unexpected, actual);
Assert.Equal(3.14, result, precision: 2); // floating point with precision
Assert.StrictEqual(expected, actual);    // reference + value equality

Boolean and Null

Assert.True(condition);
Assert.False(condition);
Assert.Null(value);
Assert.NotNull(value);

Collections

Assert.Empty(collection);
Assert.NotEmpty(collection);
Assert.Single(collection);             // exactly one element
Assert.Contains(item, collection);
Assert.DoesNotContain(item, collection);
Assert.All(collection, item => Assert.True(item > 0)); // every element passes

Strings

Assert.Contains("substring", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.Matches(@"^\d{4}-\d{2}-\d{2}$", dateString); // regex

Exceptions

// Synchronous
var ex = Assert.Throws<ArgumentNullException>(() => service.Process(null));
Assert.Equal("input", ex.ParamName);

// Async
var ex = await Assert.ThrowsAsync<HttpRequestException>(
    async () => await client.GetAsync("/invalid-endpoint")
);

Type Checking

Assert.IsType<Dog>(animal);           // exact type
Assert.IsAssignableFrom<Animal>(dog); // type or subtype

Setup and Teardown

xUnit creates a new instance of your test class for every [Fact] and [Theory]. Use the constructor for setup:

public class OrderServiceTests : IDisposable
{
    private readonly OrderService _service;
    private readonly Mock<IEmailSender> _emailSender;

    public OrderServiceTests()
    {
        // Setup runs before each test
        _emailSender = new Mock<IEmailSender>();
        _service = new OrderService(_emailSender.Object);
    }

    public void Dispose()
    {
        // Teardown runs after each test
        _service?.Dispose();
    }

    [Fact]
    public void PlaceOrder_ValidOrder_SendsConfirmationEmail()
    {
        var order = new Order { Id = 1, Total = 99.99m };
        _service.PlaceOrder(order);
        _emailSender.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
    }
}

Shared Context with IClassFixture

When setup is expensive (database connections, HTTP clients), use IClassFixture<T> to share it across tests in a class:

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

    public DatabaseFixture()
    {
        // Expensive one-time setup
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("TestDb")
            .Options;
        DbContext = new AppDbContext(options);
        DbContext.Database.EnsureCreated();
    }

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

public class ProductRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly AppDbContext _db;

    public ProductRepositoryTests(DatabaseFixture fixture)
    {
        _db = fixture.DbContext;
    }

    [Fact]
    public void GetById_ExistingProduct_ReturnsProduct()
    {
        // Uses shared database context
        var repo = new ProductRepository(_db);
        var product = repo.GetById(1);
        Assert.NotNull(product);
    }
}

Async Testing

xUnit supports async Task test methods natively:

[Fact]
public async Task FetchUser_ValidId_ReturnsUser()
{
    var client = new UserApiClient("https://api.example.com");

    var user = await client.FetchUserAsync(42);

    Assert.NotNull(user);
    Assert.Equal(42, user.Id);
}

[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(100)]
public async Task GetProduct_ValidIds_ReturnsNonNull(int productId)
{
    var service = new ProductService(_dbContext);

    var product = await service.GetProductAsync(productId);

    Assert.NotNull(product);
}

Never use .Result or .Wait() in xUnit tests — they can cause deadlocks in synchronization-context environments.

Mocking with Moq

xUnit is commonly paired with Moq for dependency mocking:

using Moq;
using Xunit;

public class NotificationServiceTests
{
    [Fact]
    public void SendAlert_ActiveUser_CallsEmailProvider()
    {
        // Arrange
        var mockEmail = new Mock<IEmailProvider>();
        var service = new NotificationService(mockEmail.Object);
        var user = new User { Id = 1, IsActive = true, Email = "test@example.com" };

        // Act
        service.SendAlert(user, "System alert");

        // Assert
        mockEmail.Verify(
            x => x.Send("test@example.com", It.Is<string>(s => s.Contains("System alert"))),
            Times.Once
        );
    }

    [Fact]
    public void SendAlert_InactiveUser_DoesNotCallEmailProvider()
    {
        var mockEmail = new Mock<IEmailProvider>();
        var service = new NotificationService(mockEmail.Object);
        var user = new User { Id = 2, IsActive = false };

        service.SendAlert(user, "Alert");

        mockEmail.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }
}

Organizing Tests

Collections for Parallel Control

xUnit runs test classes in parallel by default. Use [Collection] to group classes that share state:

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

[Collection("Database")]
public class OrderRepositoryTests
{
    // shares DatabaseFixture with other [Collection("Database")] classes
    // runs sequentially within the collection
}

[Collection("Database")]
public class ProductRepositoryTests
{
    // same collection = sequential, not parallel
}

Skipping Tests

[Fact(Skip = "Pending external API credentials")]
public void FetchFromThirdParty_ReturnsData()
{
    // skipped
}

Custom Display Names

[Fact(DisplayName = "Calculator: adding two negative numbers returns a negative result")]
public void Add_NegativeNumbers_ReturnsNegative()
{
    Assert.Equal(-5, new Calculator().Add(-2, -3));
}

Capturing Output

Use ITestOutputHelper instead of Console.WriteLine:

public class DiagnosticTests
{
    private readonly ITestOutputHelper _output;

    public DiagnosticTests(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public void Process_LargeDataset_CompletesWithinTimeout()
    {
        var stopwatch = Stopwatch.StartNew();
        var processor = new DataProcessor();

        processor.ProcessAll(GenerateLargeDataset());

        stopwatch.Stop();
        _output.WriteLine($"Processing took {stopwatch.ElapsedMilliseconds}ms");
        Assert.True(stopwatch.ElapsedMilliseconds < 5000);
    }
}

Output appears in test explorer and dotnet test output only for failed tests (or when using --logger options).

Running Tests

# Run all tests
dotnet <span class="hljs-built_in">test

<span class="hljs-comment"># Run with verbose output
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"

<span class="hljs-comment"># Run a specific test class
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"FullyQualifiedName~OrderServiceTests"

<span class="hljs-comment"># Run tests matching a display name pattern
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"DisplayName~Calculator"

<span class="hljs-comment"># Run with coverage (requires coverlet)
dotnet <span class="hljs-built_in">test --collect:<span class="hljs-string">"XPlat Code Coverage"

CI/CD Integration

GitHub Actions

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'
      - run: dotnet restore
      - run: dotnet build --no-restore
      - run: dotnet test --no-build --logger "trx;LogFileName=results.trx"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: test-results
          path: "**/*.trx"

Azure Pipelines

steps:
- task: DotNetCoreCLI@2
  displayName: 'Run tests'
  inputs:
    command: test
    projects: '**/*.Tests.csproj'
    arguments: '--configuration Release --collect:"XPlat Code Coverage"'
- task: PublishCodeCoverageResults@1
  inputs:
    codeCoverageTool: Cobertura
    summaryFileLocation: '**/coverage.cobertura.xml'

Common Pitfalls

Don't share mutable state between tests. xUnit creates a new instance per test, but static fields are shared. Static state causes tests to interfere with each other.

Don't mix async and sync in the same test. If part of your code is async, make the test async end-to-end.

Use Assert.Equal(expected, actual) — order matters. If you reverse them, failure messages show "expected X but was Y" backwards, which is confusing.

[Theory] with a single [InlineData] should be a [Fact]. Don't use Theories unless you have multiple data sets.

xUnit vs NUnit vs MSTest

Feature xUnit NUnit MSTest
Setup attribute Constructor [SetUp] [TestInitialize]
Data-driven [Theory] / [InlineData] [TestCase] [DataRow]
Per-test isolation New instance Shared instance Shared instance
.NET team choice Yes No Legacy
Parallel by default Yes No No

xUnit's constructor-based isolation and first-class async support make it the modern default for .NET projects. For legacy codebases, NUnit and MSTest remain valid and fully supported.

End-to-End with HelpMeTest

While xUnit handles unit and integration tests inside your codebase, end-to-end browser testing requires a separate layer. HelpMeTest runs Robot Framework + Playwright tests against your live application, catching UI regressions, broken flows, and cross-browser issues that unit tests miss.

You can run HelpMeTest alongside your xUnit suite in CI — xUnit for fast internal feedback, HelpMeTest for the full user-facing layer. The Pro plan ($100/month flat) includes unlimited tests and parallel execution, so you're not paying per run.

Read more