xUnit Shared Fixture Trap — Local Pass, CI Fail
Test saw 3 instead of 2 orders when xUnit shared an in-memory database across parallel fixtures.
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- xUnit runs tests via
dotnet testor Test Explorer using [Fact] and [Theory] attributes - Constructor + IDisposable replace [SetUp]/[TearDown] — each test gets a fresh class instance
- IClassFixture
shares expensive setup without sharing mutable state - Moq creates fake dependencies; always depend on interfaces in constructors
- Default parallel execution per class can cause flaky tests — use [Collection] to isolate
- Biggest mistake: writing a single [Theory] with only one InlineData — that's just a verbose [Fact]
xUnit is a free, open-source unit testing framework for .NET, created by the original author of NUnit v2. It's the de facto standard for modern .NET testing, used by Microsoft internally and in most production .NET codebases. Unlike MSTest or NUnit, xUnit was designed from scratch to eliminate common anti-patterns like shared mutable state between tests — which is exactly why its IClassFixture and ICollectionFixture features can backfire when misused.
The framework enforces test isolation by default: each test class gets a fresh instance, and setup/teardown happens via constructor and IDisposable rather than [Setup]/[TearDown] attributes. This design forces you to think about state management explicitly, which is both its strength and the source of the 'local pass, CI fail' trap when shared fixtures inadvertently introduce ordering dependencies or resource contention.
Where xUnit shines is in its clean separation of test structure: [Fact] for parameterless, deterministic tests and [Theory] with [InlineData], [MemberData], or [ClassData] for data-driven scenarios. Combined with mocking frameworks like Moq (or NSubstitute), you can isolate dependencies and test logic in true unit-test fashion.
However, xUnit's fixture system — IClassFixture<T> for per-class shared setup and ICollectionFixture<T> for cross-class sharing — is often misunderstood. These fixtures are meant for expensive, read-only resources (like a database connection or HTTP client), not for mutable state.
When you treat them as a cache or shared context, tests that pass sequentially on your dev machine fail under parallel CI execution because xUnit runs test collections in parallel by default. The fix is either to make fixtures immutable, use IClassFixture with Dispose for cleanup, or switch to IAsyncLifetime for async setup that doesn't leak state between tests.
In practice, you should avoid xUnit shared fixtures entirely for most unit tests — constructor injection of mocks and per-test setup is simpler and more reliable. Reserve fixtures for integration tests where you genuinely need a shared database container (via Testcontainers) or an HTTP server (via WebApplicationFactory).
For data-driven tests, MemberData and ClassData give you strongly-typed, reusable test data without the overhead of fixtures. The key insight: xUnit's defaults are designed to catch the exact bugs that 'local pass, CI fail' represents — embrace per-test isolation, and only reach for shared fixtures when you've measured a real performance bottleneck.
Imagine you build a vending machine. Before shipping it to a hospital, you test every single button — does pressing B3 drop a Snickers? Does it reject a fake coin? Unit testing is exactly that: you write tiny automated 'button-press checks' for every function in your code, so you catch broken buttons before your users do. xUnit is the machine that runs all those checks for you, prints a green tick when everything works, and screams a red X when something breaks.
Every production codebase eventually reaches a tipping point where a developer changes one function and silently breaks three others. Without automated tests, you only find out when a customer tweets at you at 2am. Unit testing is not a 'nice to have' — it is the safety harness that lets your team move fast without falling off a cliff. Companies like Microsoft, Stripe, and Shopify treat untested code as unshippable code, and for good reason.
The specific pain xUnit solves is the chaos of manual regression checking. Without it, every new feature means re-clicking through the entire app to make sure nothing broke. xUnit lets you encode that mental checklist as code, run it in under a second, and get a precise pass/fail report with zero human effort. It also integrates natively into the .NET ecosystem, works beautifully with GitHub Actions and Azure DevOps pipelines, and produces machine-readable output that pull-request bots can act on automatically.
By the end of this article you will know how to structure a real xUnit test project from scratch, write both simple Fact tests and data-driven Theory tests, isolate dependencies using the Moq library, and avoid the three mistakes that waste most beginners' first week with xUnit. You will also understand why each xUnit design decision exists, so you can make smart choices on your own projects rather than blindly copying Stack Overflow snippets.
Here's the honest truth: most developers learn xUnit by copying a template, write a few tests that all pass, and then ship untested code into production because they never hit the awkward edges. This article exists to force you past that plateau. You'll leave with the mental model that separates a junior who writes tests from a senior who designs systems that are testable.
What xUnit Shared Fixtures Actually Do — And Where They Break
xUnit shared fixtures let you create a single object instance that is shared across all test methods in a class (or collection). You implement IClassFixture<T> or ICollectionFixture<T>, and xUnit injects that fixture into the test class constructor once per class, not once per test. This is the core mechanic: one setup, many tests.
In practice, shared fixtures are ideal for expensive resources like database connections, HTTP clients, or service containers. They reduce test runtime by avoiding repeated construction and teardown. But they also introduce state coupling: if a test mutates the fixture, subsequent tests see that mutation. xUnit does not reset the fixture between tests. This is the trap — tests pass locally in isolation but fail in CI when run in a different order or with more parallelism.
Use shared fixtures only for read-only or reset-safe resources. For mutable state, prefer collection fixtures with explicit cleanup in Dispose, or use the newer IAsyncLifetime for async teardown. In real systems, the cost of a shared fixture bug is a flaky CI pipeline that erodes team trust in the test suite.
Setting Up a Real xUnit Project — Structure That Scales
The number one mistake teams make is dumping tests into the same project as production code. That forces your shipping binary to carry test dependencies, and it blurs the line between what you own and what you are testing. The industry-standard layout is a separate .Tests project that references your production project.
Here is exactly how to scaffold this from the terminal. The key insight is that dotnet new xunit gives you a ready-to-run test runner — xUnit's runner is baked in via the xunit.runner.visualstudio package, which is what lets Visual Studio's Test Explorer and dotnet test both work without extra wiring.
Notice that your test project references your production project directly. xUnit discovers test classes by scanning for public classes with methods decorated with [Fact] or [Theory] — no base class, no interface, no ceremony. That is a deliberate philosophy: tests should read like plain C#, not like a framework DSL.
# Run these commands in your terminal to scaffold the solution # 1. Create a solution folder mkdir OrderProcessingApp && cd OrderProcessingApp # 2. Create the production class library dotnet new classlib -n OrderProcessing.Core # 3. Create the xUnit test project dotnet new xunit -n OrderProcessing.Tests # 4. Create a solution file to hold both dotnet new sln -n OrderProcessingApp # 5. Add both projects to the solution dotnet sln add OrderProcessing.Core/OrderProcessing.Core.csproj dotnet sln add OrderProcessing.Tests/OrderProcessing.Tests.csproj # 6. Reference the production project FROM the test project dotnet add OrderProcessing.Tests/OrderProcessing.Tests.csproj \ reference OrderProcessing.Core/OrderProcessing.Core.csproj # Your folder structure should look like this: # OrderProcessingApp/ # ├── OrderProcessingApp.sln # ├── OrderProcessing.Core/ <-- production code # │ ├── OrderProcessing.Core.csproj # │ └── OrderCalculator.cs # └── OrderProcessing.Tests/ <-- test code (separate project) # ├── OrderProcessing.Tests.csproj # └── OrderCalculatorTests.cs # Run all tests from the solution root dotnet test
OrderCalculator.cs maps to OrderCalculatorTests.cs, in a matching namespace like OrderProcessing.Tests. This makes navigation instant: when you open a class, you always know exactly where its tests live without hunting.dotnet test works via CLI, but Visual Studio needs the adapter.Fact vs Theory — Writing Tests That Actually Prove Something
xUnit gives you two test primitives: [Fact] and [Theory]. Understanding the difference is the key to writing tests that are genuinely useful rather than tests that only prove one lucky path through your code.
A [Fact] is a single, unconditional assertion: 'this is always true, no arguments needed.' Use it for edge cases, boundary conditions, and single-scenario checks. A [Theory] is a parameterised test that says 'this should be true for all of these inputs.' You supply multiple data sets via [InlineData], [MemberData], or [ClassData], and xUnit runs your test method once per set, independently.
Why does this matter? Because a bug in a calculation function usually lives at an edge — zero, negative numbers, null strings, max integer. A single [Fact] with one happy-path number gives you false confidence. A [Theory] with seven representative inputs — including edge cases — is what actually catches real bugs before production does.
Below is a production-realistic example using an OrderCalculator that applies discounts. Notice the test names are descriptive English sentences — that is intentional. When a test fails in CI, the name is your first clue, so 'CalculateTotal_WhenDiscountExceedsHundredPercent_ThrowsArgumentException' tells you exactly what broke without opening the file.
// OrderProcessing.Core/OrderCalculator.cs — the class we are testing namespace OrderProcessing.Core { public class OrderCalculator { // Applies a percentage discount to the subtotal and returns the final price public decimal CalculateTotal(decimal subtotal, decimal discountPercent) { if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal), "Subtotal cannot be negative."); if (discountPercent < 0 || discountPercent > 100) throw new ArgumentOutOfRangeException(nameof(discountPercent), "Discount must be between 0 and 100."); var discountAmount = subtotal * (discountPercent / 100m); return subtotal - discountAmount; } // Returns true only if the order qualifies for free shipping public bool QualifiesForFreeShipping(decimal orderTotal, string customerTier) { return customerTier == "Gold" && orderTotal >= 50m || customerTier == "Standard" && orderTotal >= 100m; } } } // ───────────────────────────────────────────────────────── // OrderProcessing.Tests/OrderCalculatorTests.cs using Xunit; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class OrderCalculatorTests { // Create one instance of the calculator — it has no state, so it's safe to share private readonly OrderCalculator _calculator = new OrderCalculator(); // ── [Fact] tests: single, unconditional scenarios ── [Fact] public void CalculateTotal_WhenSubtotalIsNegative_ThrowsArgumentOutOfRangeException() { // Arrange: a clearly invalid subtotal decimal negativeSubtotal = -10.00m; decimal validDiscount = 10m; // Act + Assert: xUnit's Assert.Throws captures the exception cleanly var exception = Assert.Throws<ArgumentOutOfRangeException>( () => _calculator.CalculateTotal(negativeSubtotal, validDiscount) ); // Also verify the exception message mentions the right parameter name Assert.Equal("subtotal", exception.ParamName); } [Fact] public void CalculateTotal_WhenDiscountIsZero_ReturnFullSubtotal() { // Arrange decimal subtotal = 200.00m; decimal noDiscount = 0m; // Act decimal result = _calculator.CalculateTotal(subtotal, noDiscount); // Assert: no discount means we pay full price Assert.Equal(200.00m, result); } // ── [Theory] tests: same logic, many data sets ── [Theory] // Format: subtotal, discountPercent, expectedTotal [InlineData(100.00, 10, 90.00)] // standard 10% off [InlineData(200.00, 25, 150.00)] // 25% off a larger order [InlineData(50.00, 100, 0.00)] // 100% off — free item promotion [InlineData(99.99, 0, 99.99)] // zero discount — pay full price [InlineData(0.00, 50, 0.00)] // zero subtotal — result must also be zero public void CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal( decimal subtotal, decimal discountPercent, decimal expectedTotal) { // Act: run the same logic for every [InlineData] row above decimal actualTotal = _calculator.CalculateTotal(subtotal, discountPercent); // Assert.Equal on decimals — xUnit compares value, not reference Assert.Equal(expectedTotal, actualTotal); } [Theory] [InlineData(50.00, "Gold", true)] // Gold hits threshold of 50 [InlineData(49.99, "Gold", false)] // Gold just under threshold [InlineData(100.00, "Standard", true)] // Standard hits threshold of 100 [InlineData(99.99, "Standard", false)] // Standard just under threshold [InlineData(500.00, "Bronze", false)] // Unknown tier never qualifies public void QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected( decimal orderTotal, string customerTier, bool expectedResult) { bool actualResult = _calculator.QualifiesForFreeShipping(orderTotal, customerTier); Assert.Equal(expectedResult, actualResult); } } }
Mocking Dependencies With Moq — Testing Code in Isolation
Real services talk to databases, payment gateways, and email providers. If your unit test actually hits a database, it is not a unit test — it is a slow, flaky integration test that fails whenever the DB is unreachable. The solution is dependency injection plus mocking: you inject a fake version of the dependency that behaves exactly as you dictate, so your test owns the scenario completely.
Moq is the most widely used mocking library in the .NET ecosystem. You install it into your test project only — production code never sees it. The mental model is simple: Mock<IEmailService> creates a stand-in actor who plays the role of IEmailService. You script its lines with Setup(...), run the test, then verify it delivered those lines with Verify(...).
This pattern only works if your production class accepts its dependencies through a constructor (constructor injection). If a class creates its own new internally, you cannot mock it. This is why dependency injection is not just an architectural nicety — it is a testability requirement.EmailService()
Below we test an OrderService that sends a confirmation email after a successful order. We want to verify the email is sent exactly once with the right address, without firing off a real email during our test run.
// First, add Moq to the test project: // dotnet add OrderProcessing.Tests package Moq // ─── OrderProcessing.Core/IEmailService.cs ─── namespace OrderProcessing.Core { public interface IEmailService { // Sends a confirmation to the given address and returns true on success bool SendOrderConfirmation(string recipientEmail, int orderId); } } // ─── OrderProcessing.Core/OrderService.cs ─── namespace OrderProcessing.Core { public class OrderService { private readonly IEmailService _emailService; private readonly OrderCalculator _calculator; // Dependencies injected through constructor — this is what makes mocking possible public OrderService(IEmailService emailService, OrderCalculator calculator) { _emailService = emailService; _calculator = calculator; } // Places an order, calculates final price, and sends a confirmation email // Returns the final order total, or throws if email fails public decimal PlaceOrder(string customerEmail, decimal subtotal, decimal discountPercent) { if (string.IsNullOrWhiteSpace(customerEmail)) throw new ArgumentException("Customer email is required.", nameof(customerEmail)); decimal finalTotal = _calculator.CalculateTotal(subtotal, discountPercent); // Generate a deterministic order ID (simplified for the example) int orderId = Math.Abs(customerEmail.GetHashCode() % 100000); bool emailSent = _emailService.SendOrderConfirmation(customerEmail, orderId); if (!emailSent) throw new InvalidOperationException("Order confirmation email could not be sent."); return finalTotal; } } } // ─── OrderProcessing.Tests/OrderServiceTests.cs ─── using Xunit; using Moq; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class OrderServiceTests { // Moq creates a fake IEmailService — no real emails, no SMTP server needed private readonly Mock<IEmailService> _mockEmailService; private readonly OrderService _orderService; public OrderServiceTests() { _mockEmailService = new Mock<IEmailService>(); var calculator = new OrderCalculator(); // real calculator — no need to mock pure math // Inject the MOCK email service into OrderService _orderService = new OrderService(_mockEmailService.Object, calculator); } [Fact] public void PlaceOrder_WhenEmailSucceeds_ReturnsFinalTotal() { // Arrange: script the fake email service to succeed for ANY string and int _mockEmailService .Setup(emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), // match any email address It.IsAny<int>())) // match any order ID .Returns(true); // always say 'yes, email was sent' // Act: place a real order through the real OrderService decimal finalTotal = _orderService.PlaceOrder( customerEmail: "alice@example.com", subtotal: 150.00m, discountPercent: 10m ); // Assert 1: the math came out right (10% off 150 = 135) Assert.Equal(135.00m, finalTotal); // Assert 2: verify the email service was called exactly once // This proves we didn't forget to send the confirmation _mockEmailService.Verify( emailSvc => emailSvc.SendOrderConfirmation( "alice@example.com", // must be this exact address It.IsAny<int>()), // any order ID is fine Times.Once() // called exactly once — not zero, not twice ); } [Fact] public void PlaceOrder_WhenEmailServiceFails_ThrowsInvalidOperationException() { // Arrange: script the fake email service to FAIL _mockEmailService .Setup(emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), It.IsAny<int>())) .Returns(false); // simulate an SMTP outage // Act + Assert: placing the order should throw because email failed var exception = Assert.Throws<InvalidOperationException>( () => _orderService.PlaceOrder("bob@example.com", 200.00m, 0m) ); Assert.Contains("confirmation email", exception.Message); } [Fact] public void PlaceOrder_WhenEmailIsEmpty_ThrowsArgumentException_BeforeCallingEmailService() { // Act + Assert: an empty email should fail immediately Assert.Throws<ArgumentException>( () => _orderService.PlaceOrder("", 100.00m, 0m) ); // Verify the email service was NEVER called — we should have thrown before reaching it _mockEmailService.Verify( emailSvc => emailSvc.SendOrderConfirmation( It.IsAny<string>(), It.IsAny<int>()), Times.Never() // this line proves our guard clause fired correctly ); } } }
virtual. If you try to mock a non-virtual method on a concrete class, Moq silently ignores your Setup and calls the real method — your test passes for the wrong reason. The clean solution is always to depend on interfaces, not concrete types. If you own neither, use a wrapper interface.Test Lifecycle with IClassFixture — Shared Setup Without Shared State
xUnit creates a fresh instance of your test class for every single test method. This is a deliberate design decision that eliminates a whole class of bugs caused by tests accidentally sharing state. NUnit and MSTest use [SetUp]/[TearDown] methods, which run before and after each test but on the same object — that means a dirty field from test A can corrupt test B if your setup is incomplete.
xUnit's answer is simpler: constructor and IDisposable. Anything you put in the test class constructor runs before each test. Anything you put in Dispose() runs after. No magic attributes — just C# you already know.
But sometimes setup is genuinely expensive — spinning up an in-memory database, loading a large config file — and you do not want to repeat it for every test. That is what IClassFixture<T> is for. It creates the expensive resource once per test class, shares it across all tests in that class, then disposes it when the class is done. Crucially, each test still gets a fresh test class instance — only the fixture is shared.
Here is a pattern you will see in real .NET projects using an in-memory database fixture.
// dotnet add OrderProcessing.Tests package Microsoft.EntityFrameworkCore.InMemory // ─── OrderProcessing.Core/Order.cs ─── namespace OrderProcessing.Core { public class Order { public int Id { get; set; } public string CustomerEmail { get; set; } = string.Empty; public decimal Total { get; set; } public DateTime PlacedAt { get; set; } } } // ─── OrderProcessing.Core/AppDbContext.cs ─── using Microsoft.EntityFrameworkCore; namespace OrderProcessing.Core { public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Order> Orders => Set<Order>(); } } // ─── OrderProcessing.Tests/DatabaseFixture.cs ─── // This class is created ONCE and shared across all tests in OrderRepositoryTests using Microsoft.EntityFrameworkCore; using OrderProcessing.Core; namespace OrderProcessing.Tests { public class DatabaseFixture : IDisposable { public AppDbContext Context { get; } public DatabaseFixture() { // Build an in-memory database — fast, isolated, no file system needed var options = new DbContextOptionsBuilder<AppDbContext>() .UseInMemoryDatabase(databaseName: "OrderTests_" + Guid.NewGuid()) // unique name prevents bleed-over .Options; Context = new AppDbContext(options); // Seed with known data that tests can rely on Context.Orders.AddRange( new Order { Id = 1, CustomerEmail = "alice@example.com", Total = 99.99m, PlacedAt = DateTime.UtcNow }, new Order { Id = 2, CustomerEmail = "bob@example.com", Total = 249.00m, PlacedAt = DateTime.UtcNow } ); Context.SaveChanges(); } // Called automatically by xUnit after all tests in the class have run public void Dispose() { Context.Database.EnsureDeleted(); // clean up the in-memory database Context.Dispose(); } } } // ─── OrderProcessing.Tests/OrderRepositoryTests.cs ─── using Xunit; using OrderProcessing.Core; namespace OrderProcessing.Tests { // IClassFixture<DatabaseFixture> tells xUnit: create DatabaseFixture once, share it with all tests here public class OrderRepositoryTests : IClassFixture<DatabaseFixture> { private readonly AppDbContext _context; // xUnit injects the shared fixture through the constructor public OrderRepositoryTests(DatabaseFixture fixture) { _context = fixture.Context; } [Fact] public void GetOrder_WhenIdExists_ReturnsCorrectOrder() { // Act: query the seeded in-memory database var order = _context.Orders.Find(1); // Assert: the seeded data is exactly what we expect Assert.NotNull(order); Assert.Equal("alice@example.com", order.CustomerEmail); Assert.Equal(99.99m, order.Total); } [Fact] public void GetOrder_WhenIdDoesNotExist_ReturnsNull() { // Act: look for an order that was never seeded var order = _context.Orders.Find(999); // Assert: EF Core returns null for a missing key — not an exception Assert.Null(order); } [Fact] public void GetAllOrders_ReturnsExactlySeedCount() { // The fixture seeded exactly 2 orders int orderCount = _context.Orders.Count(); Assert.Equal(2, orderCount); } } }
databaseName: "OrderTests_" + Guid.NewGuid() in the fixture. If you use a fixed name like 'TestDb', parallel test runs share the same in-memory store and corrupt each other's data. A GUID suffix costs nothing and makes your tests perfectly isolated even under parallel execution.Guid.NewGuid() for in-memory database names.Data-Driven Testing with MemberData and ClassData — Beyond InlineData
While [InlineData] is the quickest way to parameterise a test, it has limitations: you cannot reuse data across methods, and the data is hardcoded in the attribute. For real-world scenarios where test data comes from a file, a database, or a computed set, xUnit provides [MemberData] and [ClassData].
[MemberData] points to a static property or method that returns IEnumerable<object[]>. This lets you reuse the same data source across multiple test methods and even compute data dynamically (e.g., reading from a CSV file). [ClassData] points to a separate class that implements IEnumerable<object[]>. This is useful when the data source is complex enough to warrant its own class, perhaps with caching or lazy loading.
A common real-world pattern: load test data from a JSON or CSV file via a static MemberData method. This decouples test logic from data, making your tests easier to read and update without recompiling. Below is an example that reads order test cases from a static method.
using Xunit; using OrderProcessing.Core; using System.Collections.Generic; namespace OrderProcessing.Tests { public class OrderCalculatorTheoryTests { private readonly OrderCalculator _calculator = new OrderCalculator(); // MemberData: a static property returning the test cases public static IEnumerable<object[]> DiscountTestData => new List<object[]> { new object[] { 100.00m, 10m, 90.00m }, new object[] { 200.00m, 25m, 150.00m }, new object[] { 50.00m, 100m, 0.00m }, new object[] { 99.99m, 0m, 99.99m }, new object[] { 0.00m, 50m, 0.00m } }; [Theory] [MemberData(nameof(DiscountTestData))] public void CalculateTotal_WithMemberData_ReturnsCorrectTotal(decimal subtotal, decimal discountPercent, decimal expectedTotal) { decimal actualTotal = _calculator.CalculateTotal(subtotal, discountPercent); Assert.Equal(expectedTotal, actualTotal); } // MemberData can also reference a static method (useful for computed or file-backed data) public static IEnumerable<object[]> GetShippingTestData() { // In production, read from a CSV or database yield return new object[] { 50.00m, "Gold", true }; yield return new object[] { 49.99m, "Gold", false }; yield return new object[] { 100.00m, "Standard", true }; yield return new object[] { 99.99m, "Standard", false }; yield return new object[] { 500.00m, "Bronze", false }; } [Theory] [MemberData(nameof(GetShippingTestData))] public void QualifiesForFreeShipping_WithMemberData_ReturnsExpected(decimal orderTotal, string customerTier, bool expectedResult) { bool actualResult = _calculator.QualifiesForFreeShipping(orderTotal, customerTier); Assert.Equal(expectedResult, actualResult); } } // ClassData: separate class that implements IEnumerable<object[]> public class DiscountTestDataClass : IEnumerable<object[]> { public IEnumerator<object[]> GetEnumerator() { yield return new object[] { 100.00m, 10m, 90.00m }; yield return new object[] { 200.00m, 25m, 150.00m }; yield return new object[] { 50.00m, 100m, 0.00m }; } System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } public class OrderCalculatorClassDataTests { private readonly OrderCalculator _calculator = new OrderCalculator(); [Theory] [ClassData(typeof(DiscountTestDataClass))] public void CalculateTotal_WithClassData_ReturnsCorrectTotal(decimal subtotal, decimal discountPercent, decimal expectedTotal) { decimal actualTotal = _calculator.CalculateTotal(subtotal, discountPercent); Assert.Equal(expectedTotal, actualTotal); } } }
- InlineData: quick, simple, but couples data to the test method.
- MemberData: reusable, composable, supports lazy evaluation and external files.
- ClassData: best for complex or shared data sources that need their own class.
- Performance: MemberData and ClassData execute once per test class, not per method — but yield returns data on demand.
Parallel Test Execution — Why Your CI Pipeline Slows to a Crawl (And How to Fix It)
Here's the dirty secret most tutorials skip: xUnit runs your tests in parallel by default. Sounds great until your integration tests trash the same database and start failing in ways that make zero sense. The WHY is critical here — parallel execution is not free.
When xUnit fires up multiple test classes, each one gets its own collection unless you tell 'em to share. That's fine for pure unit tests with zero shared state. The moment you touch a file, a network socket, or — god forbid — a SQL database, you need to think about isolation.
The fix isn't to disable parallelism entirely (that's amateur hour). Use [CollectionDefinition] to group tests that must run sequentially. Mark slow infrastructure-heavy tests with [Trait("Category", "Integration")] and filter them out of your fast feedback loop. Your CI will thank you.
Senior Shortcut: Keep unit tests parallel. Sequentialize only the minimum needed. Your developer loop shouldn't wait on the same integration tests your build server runs.
// io.thecodeforge — csharp tutorial using Xunit; // Define a collection that forces sequential execution [CollectionDefinition("DatabaseTests", DisableParallelization = true)] public class DatabaseTestCollection { } // All test classes in this collection run one at a time [Collection("DatabaseTests")] public class OrderRepositoryTests { [Fact] public void SaveOrder_ReturnsConfirmedId() { // This test won't run while other "DatabaseTests" are running // Prevents connection pool exhaustion and row locks Assert.True(true); } } [Collection("DatabaseTests")] public class InvoiceRepositoryTests { [Fact] public void CreateInvoice_CreatesRecord() { // Same sequential guarantee Assert.True(true); } }
Custom Test Runners — When Built-in Assertions Fail You
Standard assertions are fine until they aren't. Ever debug a failed test that says "Expected: true, Actual: false" with zero context? That's not useful. That's a waste of time.
The answer is a custom test runner. Not for everything — you're not building a framework. But when you have a repetitive assertion pattern (e.g., verifying JSON response shapes, checking business rule violations), write your own extension method that returns a meaningful message.
WHY it matters: in production incidents, every second counts. A custom assertion that spits out "Expected response code 200, got 503. Response body: {\"error\":\"timeout\"}" is worth its weight in gold. Moq's callback hell? Same thing. Wrap it in a helper that tells you exactly which setup failed.
Don't abstract for the sake of it. Write one, maybe two custom assertion methods per project. If you have more, your test design is wrong. Assertions should read like plain English, not regex obfuscation.
// io.thecodeforge — csharp tutorial using Xunit.Sdk; public static class CustomAssertions { public static void HttpStatusOk(HttpResponseMessage response) { if (response.StatusCode != System.Net.HttpStatusCode.OK) { var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); throw new XunitException( $"Expected 200 OK, got {(int)response.StatusCode} {response.ReasonPhrase}.\n" + $"Response body: {body}"); } } } public class PaymentApiTests { [Fact] public async Task ProcessPayment_ReturnsOk() { var response = await new HttpClient().PostAsync("http://localhost/api/pay", null); CustomAssertions.HttpStatusOk(response); // precise failure message every time } }
Shared Context Without IClassFixture — Using Lazy and Constructor Injection
IClassFixture is great for shared setup but it comes with a catch: xUnit creates one instance of your fixture per test class. If you need a singleton across multiple test classes, you're stuck.
Explicitly: When you have a test that depends on a database connection pool or a configuration object that's expensive to build, you want it created once for the entire test run. IClassFixture doesn't do that — it creates a new instance per concrete test class that implements it.
Here's the production trick: use Lazy<T> in a static context combined with collection fixtures. Define a collection-level fixture that holds a Lazy<YourExpensiveResource>. The first test to hit it pays the cost. Every subsequent test gets the cached instance. No IClassFixture overhead.
WHY this matters: Integration test suites that take 10 minutes can drop to 2. The lazy initialization means you don't pay for what you don't use. Just make sure your resource is thread-safe (read: connection pools are fine, file handles are not).
// io.thecodeforge — csharp tutorial using Xunit; public class SharedDatabasePool { private static readonly Lazy<Database> _pool = new(() => new Database("Server=prod;Trusted_Connection=true;")); public Database Instance => _pool.Value; } [CollectionDefinition("SharedDb")] public class SharedDbCollection : ICollectionFixture<SharedDatabasePool> { } [Collection("SharedDb")] public class UserTests { private readonly Database _db; public UserTests(SharedDatabasePool pool) => _db = pool.Instance; [Fact] public void UserExists_ReturnsTrue() { Assert.True(_db.Query("SELECT 1") == 1); } } [Collection("SharedDb")] public class OrderTests { private readonly Database _db; public OrderTests(SharedDatabasePool pool) => _db = pool.Instance; // same pool, zero rebuild [Fact] public void OrderCount_MatchesExpected() { Assert.Equal(100, _db.Query("SELECT COUNT(*) FROM Orders")); } }
Why Your Test Fixtures Are Leaking State — And How to Kill It
Every senior dev has seen this mess: a test passes in isolation but fails when the whole suite runs. Nine times out of ten, it's shared state in a fixture that someone thought was read-only. xUnit reuses fixture instances across tests in the same class. If your fixture holds a mutable collection, a counter, or god forbid a static cache, you're debugging ghosts.
The fix is brutal and simple: make your fixture immutable after construction. If you must mutate, use IClassFixture with disposal that resets state. Or better yet, push mutable dependencies into mocks that you control per test. Production systems don't tolerate hidden state — neither should your test harness.
When you see a test that relies on fixture state being "just right" from a previous run, you're looking at tech debt that will bite you at 3 AM before a release. Kill it now.
// io.thecodeforge — csharp tutorial public class LeakyFixture { public List<string> Items { get; } = new(); } public class FragileTests : IClassFixture<LeakyFixture> { [Fact] public void Add_Item_First() { var fixture = new LeakyFixture(); fixture.Items.Add("A"); Assert.Single(fixture.Items); } [Fact] public void Add_Item_Second_Fails() { // ❌ This test sees "A" from Add_Item_First! var fixture = new LeakyFixture(); fixture.Items.Add("B"); Assert.Single(fixture.Items); // Fails: 2 items } }
IReadOnlyList<T> or AsReadOnly() to enforce immutability at the type level.Skipping the Fixture Factory — When IClassFixture Slows You Down
IClassFixture is great for expensive setup, but it's overkill when you just need a connection string or a config object. I've seen teams thread a fixture through every test class just to hold an IOptions<T> that never changes. That's ceremony, not architecture.
For cheap, read-only dependencies, ditch the fixture. Use Lazy<T> in a static constructor or inject your settings directly via a test constructor. No fixture registration, no IClassFixture interface, no garbage. Just a static cache that's thread-safe by default.
But watch the trap: Lazy<T> is process-wide, not test-class-wide. If your test suite runs in parallel (and it should), a Lazy<T> that initializes a database connection will fight itself. Use Lazy<T> only for truly immutable data — like config strings or compiled regex patterns. For anything that touches I/O, stick with IClassFixture and accept the minor overhead.
// io.thecodeforge — csharp tutorial public static class TestConfig { private static readonly Lazy<MySettings> _settings = new(() => new MySettings { DbConnection = "Server=test;" }); public static MySettings Instance => _settings.Value; } public class LightweightTests { [Fact] public void Config_Loads_Once() { var config = TestConfig.Instance; Assert.Equal("Server=test;", config.DbConnection); } } public class MySettings { public string DbConnection { get; set; } = string.Empty; }
Lazy<T> for config or static mocks that are read-only. For any fixture that allocates resources (DB, HTTP client), stick with IClassFixture so disposal is guaranteed.Lazy<T> will do — less code, same speed, zero ceremony.Anti-Pattern: Manually Iterating Over Test Data
Manually looping through test data inside a [Fact] test is a common anti-pattern. Developers write a foreach or for loop, run assertions inside the body, and assume all iterations pass. When the loop breaks early on the first failure, the test stops — leaving later data points untested. Worse, you lose visibility into which specific input failed because exceptions hide the iteration index. This approach violates the principle of “one assertion per test” and makes debugging a guessing game. Instead of manual iteration, use xUnit's [Theory] with [InlineData], [MemberData], or [ClassData]. Each data row runs as an independent test, giving you clear pass/fail per case, parallel execution, and immediate visibility into the exact failing input. Manual loops also prevent accurate test reporting in CI — a single red test can mask five failures. Stop looping; let the framework handle iteration with theories for reliable, granular results.
// io.thecodeforge — csharp tutorial public class CalculatorTests { // BAD: manual loop — first failure stops all [Fact] public void Add_ManualIteration_Bad() { var data = new[] { (1, 2, 3), (4, 5, 9), (0, 0, 0) }; foreach (var (a, b, expected) in data) { var result = new Calculator().Add(a, b); Assert.Equal(expected, result); } } // GOOD: each case is a separate test [Theory] [InlineData(1, 2, 3)] [InlineData(4, 5, 9)] [InlineData(0, 0, 0)] public void Add_WithTheory_Good(int a, int b, int expected) => Assert.Equal(expected, new Calculator().Add(a, b)); }
Test Passes Locally but Fails in CI — The Shared Fixture Trap
UseInMemoryDatabase("OrderTests_" + Guid.NewGuid()).
Also added a [Collection] attribute to force those tests to run sequentially, preventing any shared state bleed across unrelated tests.- Always give in-memory databases a unique name per fixture instance using
Guid.NewGuid(). - Understand xUnit's default parallel execution model — shared resources need explicit isolation.
- A test that passes solo but fails in a batch is almost always a shared-state problem, not a logic bug.
for (int i=0; i<100; i++) { RunTest(); } inside a single [Fact].
Then check for missing awaits, shared mutable state, or non-thread-safe mocks.dotnet test runs them parallel.
Add dotnet test --settings sequential.runsettings to confirm.
Fix by adding [Collection] to isolate tests that share resources.dotnet test --list-tests to verify xUnit sees your tests.
If not, rebuild the project.dotnet test --list-testsdotnet test --verbosity detailed | findstr TestClassdotnet test --settings sequential.runsettingsdotnet test --filter "FullyQualifiedName~OrderServiceTests" --no-parallel<ParallelizeTestCollections>false</ParallelizeTestCollections> and use dotnet test --settings.dotnet test --verbosity normal (shows parameters for each failed row)dotnet test --filter "TestName" --verbosity detailed[Theory(DisplayName = "...")] or use MemberData with named parameters.| Feature / Aspect | xUnit | NUnit | MSTest |
|---|---|---|---|
| Test attribute | [Fact] / [Theory] | [Test] / [TestCase] | [TestMethod] / [DataTestMethod] |
| Parameterised tests | [InlineData], [MemberData], [ClassData] | [TestCase], [TestCaseSource] | [DataRow], [DynamicData] |
| Setup per test | Constructor + IDisposable | [SetUp] / [TearDown] | [TestInitialize] / [TestCleanup] |
| Shared setup across tests | IClassFixture<T> | [OneTimeSetUp] / [OneTimeTearDown] | [ClassInitialize] / [ClassCleanup] |
| Test isolation | New class instance per test (safest) | Same instance, reset via [SetUp] | Same instance, reset via [TestInitialize] |
| Parallel execution | Enabled by default per test class | Opt-in via attribute | Opt-in via settings file |
| Microsoft .NET team uses | Yes (runtime, ASP.NET Core repos) | No | No |
| Output capture | Built-in ITestOutputHelper | Console capture built-in | Console capture built-in |
| NuGet package | xunit + xunit.runner.visualstudio | nunit + NUnit3TestAdapter | MSTest.TestFramework + MSTest.TestAdapter |
| File | Command / Code | Purpose |
|---|---|---|
| ProjectSetup.sh | mkdir OrderProcessingApp && cd OrderProcessingApp | Setting Up a Real xUnit Project |
| OrderCalculatorTests.cs | namespace OrderProcessing.Core | Fact vs Theory |
| OrderServiceTests.cs | namespace OrderProcessing.Core | Mocking Dependencies With Moq |
| OrderRepositoryTests.cs | namespace OrderProcessing.Core | Test Lifecycle with IClassFixture |
| OrderCalculatorTheoryTests.cs | using Xunit; | Data-Driven Testing with MemberData and ClassData |
| ParallelCollectionExample.cs | using Xunit; | Parallel Test Execution |
| CustomAssertionExample.cs | using Xunit.Sdk; | Custom Test Runners |
| LazySharedContext.cs | using Xunit; | Shared Context Without IClassFixture |
| LeakyFixture.cs | public class LeakyFixture | Why Your Test Fixtures Are Leaking State |
| LazyConfig.cs | public static class TestConfig | Skipping the Fixture Factory |
| AntiPatternTest.cs | public class CalculatorTests | Anti-Pattern |
Key takeaways
new you can't unit test it in isolation.Common mistakes to avoid
3 patternsNot using a separate test project
dotnet new xunit to get the correct framework dependencies.Using [Fact] for parameterised scenarios instead of [Theory]
Sharing mutable state via static fields across tests
Interview Questions on This Topic
Explain the difference between [Fact] and [Theory] in xUnit. When would you use each?
[Fact] is used for a single, unconditional test scenario — it takes no parameters. [Theory] is used for parameterised tests that run the same logic against multiple sets of data supplied via [InlineData], [MemberData], or [ClassData]. Use [Fact] for edge cases and single assertions. Use [Theory] when you want to verify behaviour across a range of inputs, like testing a discount calculator with different percentages.How does xUnit prevent test pollution compared to NUnit or MSTest?
You have a flaky test that sometimes passes and sometimes fails in a parallel test run. How do you diagnose and fix it?
<ParallelizeTestCollections>false</ParallelizeTestCollections> or run the specific test class with dotnet test --no-parallel. If it passes sequentially, the problem is shared state between test classes. Check for static mutable fields, in-memory databases with fixed names, or non-thread-safe mocks. Fix by ensuring each test class has its own isolated resources — use unique database names (GUID), avoid statics, and use [Collection] attributes to group tests that must not run in parallel with other collections.Frequently Asked Questions
Use dotnet test --filter "FullyQualifiedName~TestMethodName". You can also filter by test class name, category, or traits. In Test Explorer, right-click the test and select 'Run'.
Yes, use [Fact(Skip = "Reason")] to skip a test unconditionally. For conditional skipping, use SkipUnless or implement a custom skip condition via ITestCondition (requires third-party library like xunit.skip).
Create a runsettings file with <RunSettings><RunConfiguration><MaxCpuCount>1</MaxCpuCount></RunConfiguration></RunSettings> and pass it to dotnet test: dotnet test --settings sequential.runsettings. Alternatively, disable parallelisation per test class using [Collection] attributes.
It's xUnit's built-in output capture mechanism. Inject ITestOutputHelper into your test constructor to write messages that appear in the test output, helping debug test failures without writing to console. Example: _output.WriteLine("Processing order {0}", orderId);
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
That's Testing. Mark it forged?
9 min read · try the examples if you haven't