Creating Data-Driven Tests With xUnit (.NET 10)
A friendly .NET 10 guide to data-driven tests in xUnit: Theory, InlineData, MemberData, ClassData, strongly typed TheoryData, and xUnit v3 tips.
The exam paper analogy
Imagine your maths teacher wants to check if you understand addition. She does not write one question and stop. She writes a single rule at the top of the worksheet: "Add the two numbers." Then she gives you many rows: 2 and 3, 10 and 5, 100 and 1, and so on. The rule is the same every time. Only the numbers change.
You solve each row separately. If you get one row wrong, the teacher circles just that row. The other rows still count as correct.
A data-driven test works exactly like that worksheet. You write the rule once, as a single test method. Then you hand it many rows of input. xUnit runs the method once for each row and marks each row as pass or fail on its own. This saves you from writing the same test ten times with only the numbers changed.
This guide shows you how to build these tests in xUnit on .NET 10, step by step, in plain language.
Fact vs Theory: the first idea
In xUnit, the smallest test is a [Fact]. A Fact takes no input. It checks one fixed thing.
public class CalculatorTests
{
[Fact]
public void Add_TwoAndThree_ReturnsFive()
{
var calculator = new Calculator();
int result = calculator.Add(2, 3);
Assert.Equal(5, result);
}
}That is fine for one case. But what if you want to check ten different pairs of numbers? Writing ten near-identical Facts is boring and easy to get wrong. This is where a [Theory] helps. A Theory is a test that accepts parameters and runs once for each row of data you give it.
Here is the same idea as a quick comparison.
| Attribute | Takes input? | Runs how many times? | Best for |
|---|---|---|---|
[Fact] | No | Once | A single, fixed case |
[Theory] | Yes | Once per data row | The same check across many inputs |
The big win of a Theory is reporting. Each row shows up as its own result in your test runner. If row 7 fails, you see "row 7 failed" while rows 1 to 6 still pass. You learn exactly which input broke your code.
InlineData: the simplest way to feed data
The easiest data source is [InlineData]. You write the values right above the test method, one attribute per row.
public class NumberTests
{
[Theory]
[InlineData(2)]
[InlineData(4)]
[InlineData(100)]
public void IsEven_EvenNumbers_ReturnsTrue(int number)
{
bool result = MathHelper.IsEven(number);
Assert.True(result);
}
}This single method now runs three times. The value inside each [InlineData] is passed into the number parameter in order. You can pass more than one value per row too, and the order must match the method parameters.
[Theory]
[InlineData(2, 3, 5)]
[InlineData(10, 5, 15)]
[InlineData(-1, 1, 0)]
public void Add_VariousInputs_ReturnsSum(int a, int b, int expected)
{
var calculator = new Calculator();
int result = calculator.Add(a, b);
Assert.Equal(expected, result);
}Notice the last value is the expected answer. A common and clean pattern is to pass the inputs plus the expected output on each row. The test then becomes a tiny table of "given these inputs, expect this result".
How one InlineData row becomes a test case
Steps
Read row
Take one [InlineData]
Fill params
Map values to parameters
Run body
Execute the test
Report
Pass or fail that row
InlineData is perfect when your data is small and you can type it by hand: numbers, short strings, booleans. But it has limits. You can only use compile-time constant values. You cannot put a DateTime, a new object, or a result from a calculation inside an attribute. When you hit that wall, you move to MemberData or ClassData.
MemberData: data from a property or method
[MemberData] lets your test data come from a static property, field, or method in your test class. This is the answer when your data must be calculated, built from objects, or shared between several tests.
The cleanest way is to use the strongly typed TheoryData<> container. You tell it the types of your parameters, then add rows.
public class OrderTests
{
public static TheoryData<int, decimal, decimal> DiscountCases =>
new()
{
{ 1, 100m, 100m }, // no discount for 1 item
{ 5, 100m, 95m }, // small discount
{ 20, 100m, 80m }, // bigger discount
};
[Theory]
[MemberData(nameof(DiscountCases))]
public void Total_AppliesDiscount(int quantity, decimal price, decimal expected)
{
var order = new Order(quantity, price);
decimal total = order.GetUnitTotal();
Assert.Equal(expected, total);
}
}Two things make this nice. First, the data lives in one named place, so several tests can share DiscountCases. Second, because we used TheoryData<int, decimal, decimal>, the compiler checks that every row has an int, a decimal, and a decimal. If you accidentally put a string in there, your project will not even build. That is much safer than the old style of returning IEnumerable<object[]>, where mistakes only showed up at run time.
In xUnit v3 the analyzers actively nudge you toward TheoryData<> (or the newer TheoryDataRow<>) for exactly this reason. A rule called xUnit1050 flags untyped data sources and suggests the typed version instead.
xUnit v3 also added a quiet but useful upgrade: a MemberData method can be async. It may return a Task<> or ValueTask<>, so you can fetch data from a file or build it with async work before the tests run.
ClassData: data in its own reusable class
Sometimes a data set is large, or you want to reuse it across many different test classes. For that, [ClassData] lets you move the data into its very own class. The class must implement IEnumerable<>, and the strongly typed way is to inherit from TheoryData<>.
public class PrimeTestData : TheoryData<int, bool>
{
public PrimeTestData()
{
Add(2, true);
Add(3, true);
Add(4, false);
Add(9, false);
Add(13, true);
}
}
public class PrimeTests
{
[Theory]
[ClassData(typeof(PrimeTestData))]
public void IsPrime_ReturnsExpected(int number, bool expected)
{
bool result = MathHelper.IsPrime(number);
Assert.Equal(expected, result);
}
}Now PrimeTestData is a clean, reusable bundle of test cases. Any test in any file can point at it with [ClassData(typeof(PrimeTestData))]. This keeps your test methods short and your data tidy, which matters a lot once data sets grow past a handful of rows.
Choosing where your test data lives
Steps
InlineData
Small, constant values
MemberData
Calculated or shared in class
ClassData
Reusable across files
Which one should I pick?
Beginners often freeze here, but the choice is simple once you see it as a ladder. Start at the top. Move down only when the top no longer fits.
| Source | Use it when | Strongly typed option |
|---|---|---|
[InlineData] | Values are simple constants you can type by hand | Not needed; values are inline |
[MemberData] | Data is calculated, built from objects, or shared in one class | TheoryData<> |
[ClassData] | Data is big or reused across many test classes | Inherit from TheoryData<> |
A good habit: reach for [InlineData] first. It keeps the data and the test together, so anyone reading the file sees everything at a glance. Only graduate to MemberData or ClassData when InlineData genuinely cannot hold your values, or when the same data must be shared.
A peek at xUnit v3 extras
xUnit v3 (the current major version) brought a few helpers that are worth knowing as you grow.
TheoryDataRow<> lets you describe a single row with extra metadata. For example, you can skip a row without deleting it:
public static IEnumerable<TheoryDataRow<int, bool>> Cases()
{
yield return new TheoryDataRow<int, bool>(2, true);
yield return new TheoryDataRow<int, bool>(3, true);
yield return new TheoryDataRow<int, bool>(4, false)
{
Skip = "Bug #123: edge case not handled yet",
};
}That Skip message shows up in the test runner, so the row is parked, not forgotten. There is also MatrixTheoryData, which combines two to five sets of values into every possible pairing. If you have three sizes and two colours, it builds all six combinations for you, instead of you typing them out.
You do not need these on day one. But it is good to know the toolbox is deep when your tests get more demanding.
Common mistakes to avoid
A few small slip-ups trip up almost everyone at the start. Keep these in mind.
- Wrong parameter order. The values in a row map to parameters left to right. If your method is
(int a, int b, int expected), then[InlineData(2, 3, 5)]meansa=2,b=3,expected=5. Mixing the order gives confusing failures. - Putting objects in InlineData. You cannot write
[InlineData(new Order())]. Attributes only accept constants. Move to MemberData or ClassData for anything built withnew. - Forgetting
static. A MemberData property or method must bestatic. If it is not, xUnit cannot read it before the test runs. - Returning untyped data. Returning
IEnumerable<object[]>still works, but you lose compile-time safety. PreferTheoryData<>so the compiler catches type mismatches for you. - One giant Theory for everything. If two checks are truly different ideas, keep them as separate tests. Data-driven does not mean cramming unrelated cases into one method.
A complete example to copy
Here is a small but realistic test class that pulls the ideas together. It checks a PasswordValidator against several inputs.
public class PasswordValidatorTests
{
public static TheoryData<string, bool> PasswordCases =>
new()
{
{ "abc", false }, // too short
{ "abcdefgh", false }, // no digit
{ "abcdef12", true }, // long enough, has a digit
{ "Str0ngPass", true }, // valid
{ "", false }, // empty
};
[Theory]
[MemberData(nameof(PasswordCases))]
public void IsValid_ReturnsExpected(string password, bool expected)
{
var validator = new PasswordValidator();
bool result = validator.IsValid(password);
Assert.Equal(expected, result);
}
}Read it like the maths worksheet from the start of this guide. There is one rule, IsValid, and a tidy table of inputs with their expected answers. Add a new row, and you have a new test case in seconds. Remove a row, and that case is gone. The test logic never changes.
Quick recap
- A
[Fact]runs once. A[Theory]runs once for each row of data you give it. Each row is its own pass or fail. [InlineData]is the simplest source: write small constant values right above the test.[MemberData]reads data from a static property, field, or method. Use it for calculated or shared data.[ClassData]keeps data in its own reusable class. Use it for big or widely shared data sets.- Prefer the strongly typed
TheoryData<>so the compiler checks your data types for you. xUnit v3 analyzers push you toward this. - xUnit v3 adds extras like
TheoryDataRow<>withSkip, async MemberData, andMatrixTheoryDatafor combinations. - Start with InlineData and only move down the ladder when you truly need to.
References and further reading
- What's New in v3 — xUnit.net official docs
- Creating parameterised tests in xUnit with InlineData, ClassData, and MemberData — Andrew Lock
- Creating Data-Driven Tests With xUnit — Milan Jovanović
- How to use InlineData, MemberData and ClassData in xUnit — Round The Code
- xUnit1050 analyzer rule (prefer typed theory data) — xUnit.net
Related Posts
ASP.NET Core Integration Testing Best Practices (.NET 10)
A friendly .NET 10 guide to ASP.NET Core integration testing: WebApplicationFactory, real databases with Testcontainers, clean test isolation, and CI tips.
5 Architecture Tests You Should Add to Your .NET Projects
Five simple architecture tests for .NET using NetArchTest. Protect layers, naming, and dependencies with code that fails the build when rules break.
Simplify Assertions in Unit and Integration Tests With Verify in .NET
A friendly .NET 10 guide to Verify snapshot testing: cut long assertions, check big objects and JSON, and keep tests clean in xUnit, NUnit, and MSTest.
Testcontainers: Integration Testing Using Docker in .NET
A friendly .NET 10 guide to Testcontainers: spin up real databases in Docker for trustworthy integration tests, with xUnit, WebApplicationFactory, and clean cleanup.
Testcontainers Best Practices for .NET Integration Testing (.NET 10)
A friendly .NET 10 guide to Testcontainers: real databases in Docker, shared container fixtures, clean test isolation, faster CI, and the mistakes to avoid.
How to Test API Integrations Using WireMock.Net in .NET 10
A beginner-friendly .NET 10 guide to testing API integrations with WireMock.Net: stub HTTP responses, simulate errors and delays, and write reliable tests.