xUnit runs tests via dotnet test or 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]
Plain-English First
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.
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.
ProjectSetup.shCSHARP
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
# 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
Output
Build succeeded.
Test run for OrderProcessing.Tests.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.x
Name your test classes to mirror the class they 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.
Production Insight
Forgetting to add xunit.runner.visualstudio breaks Test Explorer.
dotnet test works via CLI, but Visual Studio needs the adapter.
Rule: Always include the runner package in your test project.
Key Takeaway
Keep tests in a separate project.
Mirror namespaces for instant navigation.
Always add the Visual Studio test 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.
OrderCalculatorTests.csCSHARP
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// OrderProcessing.Core/OrderCalculator.cs — the class we are testingnamespaceOrderProcessing.Core
{
publicclassOrderCalculator
{
// Applies a percentage discount to the subtotal and returns the final pricepublicdecimalCalculateTotal(decimal subtotal, decimal discountPercent)
{
if (subtotal < 0)
thrownewArgumentOutOfRangeException(nameof(subtotal), "Subtotal cannot be negative.");
if (discountPercent < 0 || discountPercent > 100)
thrownewArgumentOutOfRangeException(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 shippingpublicboolQualifiesForFreeShipping(decimal orderTotal, string customerTier)
{
return customerTier == "Gold" && orderTotal >= 50m
|| customerTier == "Standard" && orderTotal >= 100m;
}
}
}
// ─────────────────────────────────────────────────────────// OrderProcessing.Tests/OrderCalculatorTests.csusingXunit;
usingOrderProcessing.Core;
namespaceOrderProcessing.Tests
{
publicclassOrderCalculatorTests
{
// Create one instance of the calculator — it has no state, so it's safe to shareprivatereadonlyOrderCalculator _calculator = newOrderCalculator();
// ── [Fact] tests: single, unconditional scenarios ──
[Fact]
publicvoid CalculateTotal_WhenSubtotalIsNegative_ThrowsArgumentOutOfRangeException()
{
// Arrange: a clearly invalid subtotaldecimal negativeSubtotal = -10.00m;
decimal validDiscount = 10m;
// Act + Assert: xUnit's Assert.Throws captures the exception cleanlyvar exception = Assert.Throws<ArgumentOutOfRangeException>(
() => _calculator.CalculateTotal(negativeSubtotal, validDiscount)
);
// Also verify the exception message mentions the right parameter nameAssert.Equal("subtotal", exception.ParamName);
}
[Fact]
publicvoid CalculateTotal_WhenDiscountIsZero_ReturnFullSubtotal()
{
// Arrangedecimal subtotal = 200.00m;
decimal noDiscount = 0m;
// Actdecimal result = _calculator.CalculateTotal(subtotal, noDiscount);
// Assert: no discount means we pay full priceAssert.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 zeropublicvoid CalculateTotal_WithVariousDiscounts_ReturnsCorrectTotal(
decimal subtotal,
decimal discountPercent,
decimal expectedTotal)
{
// Act: run the same logic for every [InlineData] row abovedecimal actualTotal = _calculator.CalculateTotal(subtotal, discountPercent);
// Assert.Equal on decimals — xUnit compares value, not referenceAssert.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 qualifiespublicvoid QualifiesForFreeShipping_WithVariousTiersAndTotals_ReturnsExpected(
decimal orderTotal,
string customerTier,
bool expectedResult)
{
bool actualResult = _calculator.QualifiesForFreeShipping(orderTotal, customerTier);
Assert.Equal(expectedResult, actualResult);
}
}
}
Output
Test run for OrderProcessing.Tests.dll (.NETCoreApp,Version=v8.0)
Interview Gold: Why xUnit Uses [Fact] and [Theory] Instead of [Test]
xUnit's authors deliberately chose different attribute names from NUnit/MSTest to make the intent of the test explicit in the code. A [Fact] is a universal truth. A [Theory] is a hypothesis you are validating across a range of inputs. This naming forces developers to think about what they are actually claiming, which leads to better test design.
Production Insight
A [Theory] with only one InlineData is just a verbose [Fact].
The real value comes from edge cases: zero, null, boundary.
Rule: Every [Theory] should have at least 3 data sets including failure cases.
Key Takeaway
Use [Fact] for single scenarios, [Theory] for data-driven.
Descriptive test names are your first failure clue.
Test boundary conditions, not just happy paths.
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 EmailService() internally, you cannot mock it. This is why dependency injection is not just an architectural nicety — it is a testability requirement.
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.
OrderServiceTests.csCSHARP
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// First, add Moq to the test project:// dotnet add OrderProcessing.Tests package Moq// ─── OrderProcessing.Core/IEmailService.cs ───namespaceOrderProcessing.Core
{
publicinterfaceIEmailService
{
// Sends a confirmation to the given address and returns true on successboolSendOrderConfirmation(string recipientEmail, int orderId);
}
}
// ─── OrderProcessing.Core/OrderService.cs ───namespaceOrderProcessing.Core
{
publicclassOrderService
{
privatereadonlyIEmailService _emailService;
privatereadonlyOrderCalculator _calculator;
// Dependencies injected through constructor — this is what makes mocking possiblepublicOrderService(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 failspublicdecimalPlaceOrder(string customerEmail, decimal subtotal, decimal discountPercent)
{
if (string.IsNullOrWhiteSpace(customerEmail))
thrownewArgumentException("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)
thrownewInvalidOperationException("Order confirmation email could not be sent.");
return finalTotal;
}
}
}
// ─── OrderProcessing.Tests/OrderServiceTests.cs ───usingXunit;
usingMoq;
usingOrderProcessing.Core;
namespaceOrderProcessing.Tests
{
publicclassOrderServiceTests
{
// Moq creates a fake IEmailService — no real emails, no SMTP server neededprivatereadonlyMock<IEmailService> _mockEmailService;
privatereadonlyOrderService _orderService;
publicOrderServiceTests()
{
_mockEmailService = newMock<IEmailService>();
var calculator = new OrderCalculator(); // real calculator — no need to mock pure math// Inject the MOCK email service into OrderService
_orderService = newOrderService(_mockEmailService.Object, calculator);
}
[Fact]
publicvoid 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 addressIt.IsAny<int>())) // match any order ID
.Returns(true); // always say 'yes, email was sent'// Act: place a real order through the real OrderServicedecimal 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 addressIt.IsAny<int>()), // any order ID is fineTimes.Once() // called exactly once — not zero, not twice
);
}
[Fact]
publicvoid 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 failedvar exception = Assert.Throws<InvalidOperationException>(
() => _orderService.PlaceOrder("bob@example.com", 200.00m, 0m)
);
Assert.Contains("confirmation email", exception.Message);
}
[Fact]
publicvoid PlaceOrder_WhenEmailIsEmpty_ThrowsArgumentException_BeforeCallingEmailService()
{
// Act + Assert: an empty email should fail immediatelyAssert.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
);
}
}
}
Output
Test run for OrderProcessing.Tests.dll (.NETCoreApp,Version=v8.0)
Moq can mock concrete classes, but only if their methods are marked 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.
Production Insight
Mocking concrete classes without virtual methods leads to false positives.
Moq silently calls the real method if a method is not virtual.
Rule: Depend on interfaces, never concrete classes for mocking.
Key Takeaway
Constructor injection enables mocking.
Use Moq's Setup and Verify for behaviour testing.
Always verify the mock's interactions after Act.
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.
OrderRepositoryTests.csCSHARP
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// dotnet add OrderProcessing.Tests package Microsoft.EntityFrameworkCore.InMemory// ─── OrderProcessing.Core/Order.cs ───namespaceOrderProcessing.Core
{
publicclassOrder
{
publicintId { get; set; }
publicstringCustomerEmail { get; set; } = string.Empty;
publicdecimalTotal { get; set; }
publicDateTimePlacedAt { get; set; }
}
}
// ─── OrderProcessing.Core/AppDbContext.cs ───usingMicrosoft.EntityFrameworkCore;
namespaceOrderProcessing.Core
{
publicclassAppDbContext : DbContext
{
publicAppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
publicDbSet<Order> Orders => Set<Order>();
}
}
// ─── OrderProcessing.Tests/DatabaseFixture.cs ───// This class is created ONCE and shared across all tests in OrderRepositoryTestsusingMicrosoft.EntityFrameworkCore;
usingOrderProcessing.Core;
namespaceOrderProcessing.Tests
{
publicclassDatabaseFixture : IDisposable
{
publicAppDbContextContext { get; }
publicDatabaseFixture()
{
// Build an in-memory database — fast, isolated, no file system neededvar options = newDbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: "OrderTests_" + Guid.NewGuid()) // unique name prevents bleed-over
.Options;
Context = newAppDbContext(options);
// Seed with known data that tests can rely onContext.Orders.AddRange(
newOrder { Id = 1, CustomerEmail = "alice@example.com", Total = 99.99m, PlacedAt = DateTime.UtcNow },
newOrder { 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 runpublicvoidDispose()
{
Context.Database.EnsureDeleted(); // clean up the in-memory databaseContext.Dispose();
}
}
}
// ─── OrderProcessing.Tests/OrderRepositoryTests.cs ───usingXunit;
usingOrderProcessing.Core;
namespaceOrderProcessing.Tests
{
// IClassFixture<DatabaseFixture> tells xUnit: create DatabaseFixture once, share it with all tests herepublicclassOrderRepositoryTests : IClassFixture<DatabaseFixture>
{
privatereadonlyAppDbContext _context;
// xUnit injects the shared fixture through the constructorpublicOrderRepositoryTests(DatabaseFixture fixture)
{
_context = fixture.Context;
}
[Fact]
publicvoid GetOrder_WhenIdExists_ReturnsCorrectOrder()
{
// Act: query the seeded in-memory databasevar order = _context.Orders.Find(1);
// Assert: the seeded data is exactly what we expectAssert.NotNull(order);
Assert.Equal("alice@example.com", order.CustomerEmail);
Assert.Equal(99.99m, order.Total);
}
[Fact]
publicvoid GetOrder_WhenIdDoesNotExist_ReturnsNull()
{
// Act: look for an order that was never seededvar order = _context.Orders.Find(999);
// Assert: EF Core returns null for a missing key — not an exceptionAssert.Null(order);
}
[Fact]
publicvoid GetAllOrders_ReturnsExactlySeedCount()
{
// The fixture seeded exactly 2 ordersint orderCount = _context.Orders.Count();
Assert.Equal(2, orderCount);
}
}
}
Output
Test run for OrderProcessing.Tests.dll (.NETCoreApp,Version=v8.0)
Pro Tip: Give Each In-Memory Database a Unique Name
Notice 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.
Production Insight
In-memory databases with fixed names cause parallel test corruption.
Each test class or fixture needs a unique database name.
Rule: Use Guid.NewGuid() for in-memory database names.
Key Takeaway
IClassFixture shares one instance across tests.
Use IDisposable for cleanup.
Unique names prevent parallel test pollution.
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.
Mental Model: Data Sources Are Just Test Iterators
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.
Production Insight
Using InlineData with dozens of rows makes test files bloated and hard to review.
MemberData allows data to live in a separate static method or even an external file.
Rule: If you need more than 5 data sets, switch to MemberData.
Key Takeaway
InlineData for quick cases, MemberData for reusable logic.
ClassData for data sources worth a separate abstraction.
Yielding test data lazily keeps memory usage low.
Choosing the Right Data Source for Theory Tests
IfYou have 1-5 fixed data sets used only in this test method
→
UseUse [InlineData] — simplest and most readable.
IfData is reused across multiple test methods or computed at runtime
→
UseUse [MemberData] with a static property or method.
IfData source is complex (e.g., file parsing, caching) and deserves its own class
→
UseUse [ClassData] with a dedicated class implementing IEnumerable<object[]>.
● Production incidentPOST-MORTEMseverity: high
Test Passes Locally but Fails in CI — The Shared Fixture Trap
Symptom
A test that checks the count of orders in an in-memory database passes when run alone but fails when the full test suite runs in CI. Error: "Expected: 2. Actual: 3."
Assumption
Developers assumed the in-memory database fixture was isolated per class. They had named it "TestDb" without a unique suffix.
Root cause
The DatabaseFixture used a fixed in-memory database name. xUnit runs test classes in parallel by default; two instances of the fixture shared the same database name, causing tests from different classes to see each other's seeded data.
Fix
Changed the in-memory database name to include a GUID: UseInMemoryDatabase("OrderTests_" + Guid.NewGuid()).
Also added a [Collection] attribute to force those tests to run sequentially, preventing any shared state bleed across unrelated tests.
Key lesson
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.
Production debug guideDiagnose and fix the most common test reliability issues4 entries
Symptom · 01
Test passes on my machine but fails in CI
→
Fix
Check for shared static state, file path differences, and database name collisions.
Add a [Collection] attribute to force sequential execution and see if the failure pattern changes.
Symptom · 02
Test fails intermittently — sometimes passes, sometimes fails
→
Fix
Likely a race condition or timing issue.
Run the test in a loop: 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.
Symptom · 03
Test fails when run with dotnet test but passes in Test Explorer
→
Fix
Test Explorer often runs tests sequentially by default, while dotnet test runs them parallel.
Add dotnet test --settings sequential.runsettings to confirm.
Fix by adding [Collection] to isolate tests that share resources.
Symptom · 04
Test output shows 'Test Discovery Skipped'
→
Fix
Check that the test project references both xunit and xunit.runner.visualstudio packages.
Run dotnet test --list-tests to verify xUnit sees your tests.
If not, rebuild the project.
★ Quick xUnit Debug Cheat SheetCommands and actions for the most common xUnit test debugging scenarios
dotnet test --verbosity detailed | findstr TestClass
Fix now
Rebuild the test project. If still missing, remove and re-add the packages.
Test fails only in parallel run+
Immediate action
Force sequential execution to confirm
Commands
dotnet test --settings sequential.runsettings
dotnet test --filter "FullyQualifiedName~OrderServiceTests" --no-parallel
Fix now
Create a runsettings file with <ParallelizeTestCollections>false</ParallelizeTestCollections> and use dotnet test --settings.
Theory test shows generic failure message+
Immediate action
Find which data row failed
Commands
dotnet test --verbosity normal (shows parameters for each failed row)
dotnet test --filter "TestName" --verbosity detailed
Fix now
Add a custom display name using [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
Key takeaways
1
xUnit's fresh class instance per test eliminates state leak bugs
embrace it, don't fight it.
2
Mocking requires constructor injection and interfaces
if your code uses new you can't unit test it in isolation.
3
IClassFixture shares expensive setup without sharing mutable state; always use unique names for database fixtures.
4
MemberData and ClassData separate test data from test logic and scale better than hundreds of InlineData rows.
5
Run tests sequentially first if you suspect flakiness; then inspect shared resources for concurrent access issues.
Common mistakes to avoid
3 patterns
×
Not using a separate test project
Symptom
Test dependencies (Moq, xunit) are packaged into the production build; the shipping binary is larger and may expose internal test-only types.
Fix
Always create a separate .Tests project that references the production project. Use dotnet new xunit to get the correct framework dependencies.
×
Using [Fact] for parameterised scenarios instead of [Theory]
Symptom
You end up writing five identical test methods that differ only in input values, making the test file long and hard to maintain.
Fix
Use [Theory] with [InlineData] for multiple inputs. If the data set is large, switch to [MemberData] or [ClassData].
×
Sharing mutable state via static fields across tests
Symptom
Tests that pass individually fail when run together. Debugging reveals that static state from one test leaks into another.
Fix
Avoid static mutable fields in test classes. Use constructor + instance fields instead. For shared resources, use IClassFixture<T> but ensure the fixture is immutable or provides isolated copies.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
Explain the difference between [Fact] and [Theory] in xUnit. When would ...
Q02SENIOR
How does xUnit prevent test pollution compared to NUnit or MSTest?
Q03SENIOR
You have a flaky test that sometimes passes and sometimes fails in a par...
Q01 of 03JUNIOR
Explain the difference between [Fact] and [Theory] in xUnit. When would you use each?
ANSWER
[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.
Q02 of 03SENIOR
How does xUnit prevent test pollution compared to NUnit or MSTest?
ANSWER
xUnit creates a new instance of the test class for every single test method. In NUnit and MSTest, the same test class instance is reused across tests (with [SetUp]/[TearDown] called per test). This means mutable instance fields in NUnit can carry state from one test to another if the setup doesn't reset them properly. xUnit's model eliminates that class of bugs entirely — each test starts from a clean slate.
Q03 of 03SENIOR
You have a flaky test that sometimes passes and sometimes fails in a parallel test run. How do you diagnose and fix it?
ANSWER
First, confirm it's a parallel issue: run the test sequentially using a runsettings file with <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.
01
Explain the difference between [Fact] and [Theory] in xUnit. When would you use each?
JUNIOR
02
How does xUnit prevent test pollution compared to NUnit or MSTest?
SENIOR
03
You have a flaky test that sometimes passes and sometimes fails in a parallel test run. How do you diagnose and fix it?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
How do I run a specific test method with xUnit?
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'.
Was this helpful?
02
Can I skip a test conditionally?
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).
Was this helpful?
03
How do I ensure tests run sequentially and not in parallel?
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.
Was this helpful?
04
What is the ITestOutputHelper for?
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);