xUnit Fundamentals — Facts, Theories and Test Organisation

📋 Table of Contents
  1. xUnit Core Features
  2. Common Mistakes

xUnit’s design philosophy is opinionated — it enforces isolation by creating a new class instance per test and provides minimal setup/teardown options. This pushes test writers toward clean, isolated tests rather than shared mutable state. Understanding xUnit’s key features — Facts, Theories, Fixtures, and parallelisation — is essential for writing fast, reliable test suites for the BlogApp’s C# code.

xUnit Core Features

// ── [Theory] with multiple data sources ──────────────────────────────────
public class SlugGeneratorTests
{
    private readonly SlugGenerator _sut = new();

    // [InlineData] — simple inline values
    [Theory]
    [InlineData("Getting Started with .NET", "getting-started-with-net")]
    [InlineData("C# vs F# vs VB",            "c-vs-f-vs-vb")]
    [InlineData("  Spaces Around  ",          "spaces-around")]
    [InlineData("Hello World!",               "hello-world")]
    [InlineData("",                           "")]    // edge case
    public void Generate_ReturnsExpectedSlug(string title, string expected)
    {
        var result = _sut.Generate(title);
        result.Should().Be(expected);
    }

    // [MemberData] — complex test data from a static property
    public static IEnumerable<object[]> UnicodeTestCases =>
        new List<object[]>
        {
            new object[] { "Über alles",  "uber-alles"  },
            new object[] { "日本語",       ""             },  // non-Latin stripped
            new object[] { "café au lait", "cafe-au-lait" },
        };

    [Theory]
    [MemberData(nameof(UnicodeTestCases))]
    public void Generate_WithUnicodeInput_NormalisesCorrectly(
        string title, string expected)
    {
        _sut.Generate(title).Should().Be(expected);
    }
}

// ── IClassFixture — expensive shared setup per test class ─────────────────
// Database setup that should run once per class, not per test:
public class PostsRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture _db;

    public PostsRepositoryTests(DatabaseFixture db) => _db = db;

    [Fact]
    public async Task GetBySlugAsync_WhenSlugExists_ReturnsPost()
    {
        var post = await _db.Repository.GetBySlugAsync("existing-slug", default);
        post.Should().NotBeNull();
        post!.Slug.Should().Be("existing-slug");
    }
}

// ── ICollectionFixture — share one fixture across multiple test classes ────
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }

[Collection("Database")]
public class CommentsRepositoryTests
{
    private readonly DatabaseFixture _db;
    public CommentsRepositoryTests(DatabaseFixture db) => _db = db;
    // ...
}

// ── ITestOutputHelper — diagnostic output in test results ─────────────────
public class PerformanceTests
{
    private readonly ITestOutputHelper _output;
    public PerformanceTests(ITestOutputHelper output) => _output = output;

    [Fact]
    public void GetPublished_ShouldCompleteUnder100ms()
    {
        var sw = Stopwatch.StartNew();
        // ... test code ...
        sw.Stop();
        _output.WriteLine($"Elapsed: {sw.ElapsedMilliseconds}ms");
        sw.ElapsedMilliseconds.Should().BeLessThan(100);
    }
}

// ── [Trait] — categorise tests for selective running ──────────────────────
[Trait("Category", "Unit")]
public class PostsServiceTests { /* ... */ }

[Trait("Category", "Integration")]
public class PostsControllerIntegrationTests { /* ... */ }

// Run only unit tests:
// dotnet test --filter "Category=Unit"
Note: xUnit runs test classes in parallel by default — each test class runs simultaneously. Test methods within a class run sequentially. This means test classes should not share mutable state (static fields, shared databases without isolation). If two test classes both write to the same database table without transaction rollback or separate schemas, they will interfere. Use [Collection("DatabaseTests")] to disable parallelism between specific test classes that share a resource.
Tip: Use [Theory] with [InlineData] for any test that validates a function against multiple inputs — slug generation, email validation, status transitions, price calculation. A single [Theory] with 20 data points is cleaner and faster to add to than 20 separate [Fact] methods. The xUnit test runner reports each data point as a separate test result, so failures are pinpointed to the exact failing input.
Warning: IClassFixture<T> creates the fixture once per test class and shares it across all test methods in that class. If tests modify the fixture’s state (e.g., inserting rows into a shared test database), earlier tests can affect later tests — creating order-dependent, flaky tests. Design class fixtures as read-only where possible, or reset state between tests using database transactions that are rolled back after each test.

Common Mistakes

Mistake 1 — Static shared state between xUnit test classes (test interference)

❌ Wrong — static field holding test data modified by one test class; second parallel test class reads stale/modified data.

✅ Correct — no shared mutable static state; use IClassFixture for expensive setup; create fresh instances per test method.

Mistake 2 — [InlineData] with magic values (unclear test intent)

❌ Wrong — [InlineData("a", true)]; what does “a” represent? Why should it be true?

✅ Correct — [InlineData("valid-slug", true)]; the value communicates its intent directly.

🧠 Test Yourself

A test class has 10 test methods and uses IClassFixture<ExpensiveFixture>. How many times is ExpensiveFixture created?