Theory and InlineData — Parameterized Tests in xUnit
Write parameterized tests with xUnit Theory: InlineData, MemberData, ClassData, TheoryData, and the data-driven testing patterns that eliminate repetitive test boilerplate.
Why Parameterized Tests
A single code path tested with 10 different inputs requires 10 [Fact] methods — identical structure, different data. [Theory] with data attributes collapses this into one test method plus a data set.
// Before — 4 identical tests
[Fact] public void Dosage_zero_amount_should_fail() { ... }
[Fact] public void Dosage_negative_amount_should_fail() { ... }
[Fact] public void Dosage_invalid_unit_should_fail() { ... }
[Fact] public void Dosage_null_unit_should_fail() { ... }
// After — one Theory
[Theory]
[InlineData(0, "mg", "Dosage.AmountMustBePositive")]
[InlineData(-1, "mg", "Dosage.AmountMustBePositive")]
[InlineData(100, "pints", "Dosage.InvalidUnit")]
[InlineData(100, null, "Dosage.UnitRequired")]
public void Dosage_with_invalid_values_should_fail(
decimal amount, string? unit, string expectedCode) { ... }[InlineData] — Inline Literal Data
[Theory]
[InlineData("", "Patient.NameRequired")]
[InlineData(" ", "Patient.NameRequired")] // whitespace-only
[InlineData(null, "Patient.NameRequired")]
[InlineData("X", "Patient.NameTooShort")] // 1 char
[InlineData("A very very long name that exceeds the allowed maximum of 100 characters in our system boundary validation rules", "Patient.NameTooLong")]
public void Create_with_invalid_name_should_fail(string? name, string expectedCode)
{
var dob = new DateOnly(1985, 3, 15);
var result = Patient.Create(name!, dob, "MRN-001");
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be(expectedCode);
}Limitations of [InlineData]:
- Only supports constants (no
new DateTime(), no objects) - Parameters must be literal values: strings, ints, bools, enums
[MemberData] — Data from Static Members
// Test class with MemberData property
public class PrescriptionTests
{
public static IEnumerable<object[]> InvalidDosages =>
[
[0m, "mg", "Dosage.AmountMustBePositive"],
[-10m, "mg", "Dosage.AmountMustBePositive"],
[100m, "pints", "Dosage.InvalidUnit"],
];
[Theory]
[MemberData(nameof(InvalidDosages))]
public void Dosage_invalid_should_fail(
decimal amount, string unit, string expectedCode)
{
var result = Dosage.Create(amount, unit);
result.Error.Code.Should().Be(expectedCode);
}
}
// Data in a separate class (reusable)
public static class TestData
{
public static IEnumerable<object[]> ValidPatients =>
[
[new DateOnly(1985, 3, 15), "John Smith", "MRN-001"],
[new DateOnly(1990, 7, 22), "Jane Doe", "MRN-002"],
[new DateOnly(1960, 11, 1), "Robert Jones","MRN-003"],
];
}
[Theory]
[MemberData(nameof(TestData.ValidPatients), MemberType = typeof(TestData))]
public void Create_valid_patient_should_succeed(
DateOnly dob, string name, string mrn) { /* ... */ }[ClassData] — Data from an IEnumerable Class
// ClassData class — implements IEnumerable<object[]>
public class ValidWardCodes : IEnumerable<object[]>
{
private static readonly string[] Codes =
[
"WARD-A1", "WARD-B2", "ICU-01", "ED-TRAUMA", "PEDS-03"
];
public IEnumerator<object[]> GetEnumerator()
=> Codes.Select(c => new object[] { c }).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(ValidWardCodes))]
public void Ward_code_valid_format_should_be_accepted(string code)
{
var result = WardCode.Create(code);
result.IsSuccess.Should().BeTrue();
}TheoryData — Type-Safe Theory Data (xUnit v2.5+)
// TheoryData<T1, T2> is type-safe — no object[] boxing
public static TheoryData<decimal, string, string> DosageData =>
new()
{
{ 0m, "mg", "Dosage.AmountMustBePositive" },
{ -1m, "mg", "Dosage.AmountMustBePositive" },
{ 100m, "pints", "Dosage.InvalidUnit" },
};
[Theory]
[MemberData(nameof(DosageData))]
public void Dosage_invalid_should_fail(
decimal amount, string unit, string expectedCode)
{
// Type-safe — decimal, string, string guaranteed by TheoryData
var result = Dosage.Create(amount, unit);
result.Error.Code.Should().Be(expectedCode);
}TheoryData is preferred over object[] — type mismatches are compile-time errors.
Testing Boundary Values
// Boundary value testing: exact limits + just outside
public static TheoryData<int, bool> AgeBoundaries =>
new()
{
{ -1, false }, // below minimum (unborn)
{ 0, true }, // minimum valid (newborn)
{ 17, true }, // just below adult threshold
{ 18, true }, // adult threshold
{ 150, false }, // maximum (above known human lifespan)
{ 151, false }, // above maximum
};
[Theory]
[MemberData(nameof(AgeBoundaries))]
public void Patient_age_boundary_validation(int years, bool expectedValid)
{
var dob = DateOnly.FromDateTime(DateTime.UtcNow.AddYears(-years));
var result = Patient.Create("Test Patient", dob, "MRN-TEST");
if (expectedValid)
result.IsSuccess.Should().BeTrue();
else
result.IsFailure.Should().BeTrue();
}Skip Individual Cases
[Theory]
[InlineData("valid-case", true)]
[InlineData("edge-case", true, Skip = "Known bug — see issue #123")]
[InlineData("invalid-case",false)]
public void Theory_with_skipped_case(string input, bool expected) { ... }Naming Theory Tests in Output
xUnit names theory test runs as MethodName(param1, param2) in the test output. With [InlineData]:
DosageTests.Dosage_with_invalid_values_should_fail(amount: 0, unit: "mg", expectedCode: "Dosage.AmountMustBePositive")
DosageTests.Dosage_with_invalid_values_should_fail(amount: -1, unit: "mg", expectedCode: "Dosage.AmountMustBePositive")Make parameter names descriptive — they appear in CI output on failure.
Red Flag / Green Answer
Red Flag: "We have a single [Fact] test that loops through an array of test cases and asserts inside the loop."
A loop inside a test is a hidden parameterized test. If case 3 fails, the test stops — you do not see whether case 4 and 5 also fail.
[Theory]runs each case independently, reports each failure separately, and continues through all cases.
Green Answer:
[Theory]with[InlineData]orTheoryData. Each case runs independently. All failures are reported. Test output names each case by its parameters.
Key Takeaway
[Theory]with data attributes collapses repeated test methods into a single parameterized test. Use[InlineData]for literal constants,[MemberData]for complex objects or reusable data sets,[ClassData]for encapsulated enumerable data, andTheoryData<T>for type-safe parameters withoutobject[]boxing. Run boundary values, null inputs, and edge cases as theory rows — not as separate[Fact]methods.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.