Skip to main content
SEMastery
Testingbeginner

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.

11 min readUpdated March 4, 2026

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.

A Fact runs once. A Theory runs once for every row of data you supply.

Here is the same idea as a quick comparison.

AttributeTakes input?Runs how many times?Best for
[Fact]NoOnceA single, fixed case
[Theory]YesOnce per data rowThe 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

Read row
Fill params
Run body
Report

Steps

1

Read row

Take one [InlineData]

2

Fill params

Map values to parameters

3

Run body

Execute the test

4

Report

Pass or fail that row

xUnit reads each attribute, fills the parameters, runs the body, and reports a result.

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.

MemberData reads rows from a shared static member, then runs the test once per row.

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

InlineData
MemberData
ClassData

Steps

1

InlineData

Small, constant values

2

MemberData

Calculated or shared in class

3

ClassData

Reusable across files

Start simple with InlineData and only move outward when you need to.

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.

SourceUse it whenStrongly typed option
[InlineData]Values are simple constants you can type by handNot needed; values are inline
[MemberData]Data is calculated, built from objects, or shared in one classTheoryData<>
[ClassData]Data is big or reused across many test classesInherit 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.

MatrixTheoryData pairs every value from one set with every value from another.

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)] means a=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 with new.
  • Forgetting static. A MemberData property or method must be static. 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. Prefer TheoryData<> 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<> with Skip, async MemberData, and MatrixTheoryData for combinations.
  • Start with InlineData and only move down the ladder when you truly need to.

References and further reading

Related Posts