Parameterized Testing in .NET: DataRow, TestCase, and Theory Compared
Writing the same test with different inputs ten times is a code smell. Parameterized tests — data-driven tests — solve this by separating test logic from test data. One test method, many scenarios.
All three major .NET frameworks support parameterized tests, with different syntax and capabilities. This guide covers all three and when to use each approach.
Why Parameterized Tests
Consider testing a discount calculator:
// Without parameterization (repetitive)
[Fact]
public void Discount_Tier1Customer_Gets5Percent()
{
Assert.Equal(0.95m, _calculator.Apply(1.00m, CustomerTier.Tier1));
}
[Fact]
public void Discount_Tier2Customer_Gets10Percent()
{
Assert.Equal(0.90m, _calculator.Apply(1.00m, CustomerTier.Tier2));
}
[Fact]
public void Discount_Tier3Customer_Gets20Percent()
{
Assert.Equal(0.80m, _calculator.Apply(1.00m, CustomerTier.Tier3));
}
// With parameterization (DRY)
[Theory]
[InlineData(CustomerTier.Tier1, 0.95)]
[InlineData(CustomerTier.Tier2, 0.90)]
[InlineData(CustomerTier.Tier3, 0.80)]
public void Discount_AppliesCorrectPercentage(CustomerTier tier, decimal expected)
{
Assert.Equal(expected, _calculator.Apply(1.00m, tier));
}The parameterized version is shorter, covers the same cases, and adding a new tier is one line, not a whole method. When the test fails, the runner shows which tier caused the failure.
MSTest: DataRow and DataTestMethod
MSTest uses [DataTestMethod] with [DataRow] attributes:
[DataTestMethod]
[DataRow(1, 1, 2)]
[DataRow(-1, 1, 0)]
[DataRow(0, 0, 0)]
[DataRow(int.MaxValue, 1, int.MinValue)] // Overflow
public void Add_TwoIntegers_ReturnsExpectedSum(int a, int b, int expected)
{
var calculator = new Calculator();
Assert.AreEqual(expected, calculator.Add(a, b));
}MSTest also supports [DynamicData] for more complex cases:
public static IEnumerable<object[]> OrderValidationData
{
get
{
yield return [new Order(), false, "Order must have items"];
yield return [new Order { Items = [new Item { Price = -1m }] }, false, "Item price must be positive"];
yield return [new Order { Items = [new Item { Price = 10m }] }, true, null];
}
}
[DataTestMethod]
[DynamicData(nameof(OrderValidationData))]
public void ValidateOrder_ReturnsExpectedResult(Order order, bool isValid, string errorMessage)
{
var result = new OrderValidator().Validate(order);
Assert.AreEqual(isValid, result.IsValid);
if (errorMessage != null)
CollectionAssert.Contains(result.Errors.ToList(), errorMessage);
}MSTest limitations:
[DataRow]only supports types that are valid attribute arguments: primitives, strings, enums, and arrays of these. No custom objects.[DynamicData]usesobject[]— no type safety- Test names show parameter values but can't be customized per row
NUnit: TestCase, TestCaseSource, and Values
NUnit has the most complete parameterized test system of the three frameworks.
TestCase — Inline Data
[TestCase(1, 2, ExpectedResult = 3)]
[TestCase(0, 0, ExpectedResult = 0)]
[TestCase(-5, 5, ExpectedResult = 0)]
public int Add_ReturnsSum(int a, int b)
{
return new Calculator().Add(a, b);
}ExpectedResult eliminates the need for an assertion — NUnit compares the return value automatically.
Named test cases:
[TestCase("hello", "world", TestName = "Concatenate two strings")]
[TestCase("", "test", TestName = "Concatenate empty and non-empty")]
[TestCase(null, "value", TestName = "Concatenate null and string")]
public void Concat_ReturnsExpectedResult(string a, string b)
{
var result = StringHelper.Concat(a, b);
Assert.That(result, Is.Not.Null);
}Named cases appear as individual test names in the runner, making failure identification easier.
TestCaseSource — External Data
For complex objects, large datasets, or shared test data:
private static readonly object[] ValidEmails =
{
new object[] { "user@example.com", true },
new object[] { "user+tag@example.co.uk", true },
new object[] { "user.name@subdomain.example.com", true }
};
private static readonly object[] InvalidEmails =
{
new object[] { "", false },
new object[] { "notanemail", false },
new object[] { "@nodomain.com", false }
};
[TestCaseSource(nameof(ValidEmails))]
[TestCaseSource(nameof(InvalidEmails))]
public void ValidateEmail_ReturnsExpected(string email, bool expectedValid)
{
Assert.That(EmailValidator.IsValid(email), Is.EqualTo(expectedValid));
}Or with TestCaseData for named cases and additional metadata:
private static IEnumerable<TestCaseData> PaymentScenarios()
{
yield return new TestCaseData(100m, "USD", "card")
.Returns(true)
.SetName("Standard card payment");
yield return new TestCaseData(0m, "USD", "card")
.Returns(false)
.SetName("Zero amount rejected")
.SetDescription("Payments of $0 should be rejected by gateway");
yield return new TestCaseData(1_000_000m, "USD", "card")
.Returns(false)
.SetName("Exceeds transaction limit");
}
[TestCaseSource(nameof(PaymentScenarios))]
public bool ProcessPayment_ReturnsExpected(decimal amount, string currency, string method)
{
return _paymentService.Process(amount, currency, method).Success;
}Values — Combinatorial
[Test]
public void ProcessOrder_AllCombinations_Succeed(
[Values("standard", "express", "overnight")] string shippingMethod,
[Values("USD", "EUR", "GBP")] string currency,
[Values(1, 10, 100)] int quantity)
{
// Runs 3 × 3 × 3 = 27 times
var result = _orderService.Process(shippingMethod, currency, quantity);
Assert.That(result.Success, Is.True);
}[Range] generates numeric sequences:
[Test]
public void Discount_ValidPercentages_DoNotExceedTotal(
[Range(1, 100, 5)] int discountPercent, // 1, 6, 11, ..., 96
[Values(10.00, 99.99, 1000.00)] decimal price)
{
var discounted = _calculator.Apply(price, discountPercent);
Assert.That(discounted, Is.LessThanOrEqualTo(price));
Assert.That(discounted, Is.GreaterThanOrEqualTo(0));
}Warning: Combinatorial tests grow exponentially. 4 parameters with 5 values each = 625 tests. Use [Sequential] instead of combinatorial when you want pairwise matching instead of Cartesian product:
[Test, Sequential]
public void ProcessOrder_SequentialCases(
[Values("standard", "express", "overnight")] string shipping,
[Values("USD", "EUR", "GBP")] string currency)
{
// Runs 3 times: (standard,USD), (express,EUR), (overnight,GBP)
}xUnit: Theory, InlineData, MemberData, ClassData
xUnit uses [Theory] for parameterized tests with multiple data source options.
InlineData — Simple Cases
[Theory]
[InlineData("", false)]
[InlineData("x", false)]
[InlineData("password", false)]
[InlineData("P@ssw0rd!", true)]
[InlineData("Str0ng&Secure#99", true)]
public void ValidatePassword_ReturnsExpected(string password, bool expected)
{
Assert.Equal(expected, PasswordValidator.IsValid(password));
}[InlineData] is limited to attribute-compatible types. No new objects, no List<T>, no custom classes.
MemberData — Complex Objects
public static IEnumerable<object[]> OrderScenarios =>
[
[new Order { Items = [] }, false, "Items cannot be empty"],
[new Order { Items = [new Item { Price = -1m }] }, false, "Price must be positive"],
[new Order { Items = [new Item { Price = 10m }], Total = 10m }, true, null]
];
[Theory]
[MemberData(nameof(OrderScenarios))]
public void ValidateOrder_ReturnsExpected(Order order, bool isValid, string? expectedError)
{
var result = new OrderValidator().Validate(order);
Assert.Equal(isValid, result.IsValid);
if (expectedError != null)
Assert.Contains(expectedError, result.Errors);
}TheoryData — Type-Safe Alternative
TheoryData<T> provides compile-time type safety that object[] doesn't:
public class DiscountTestData : TheoryData<decimal, int, decimal>
{
public DiscountTestData()
{
Add(100m, 10, 90m); // 10% off $100 = $90
Add(50m, 25, 37.50m); // 25% off $50 = $37.50
Add(200m, 50, 100m); // 50% off $200 = $100
Add(0m, 100, 0m); // 100% off $0 = $0
}
}
[Theory]
[ClassData(typeof(DiscountTestData))]
public void ApplyDiscount_CalculatesCorrectly(decimal price, int percentOff, decimal expected)
{
var result = DiscountCalculator.Apply(price, percentOff);
Assert.Equal(expected, result);
}ClassData — External Data Class
public class EmailValidationData : IEnumerable<object[]>
{
private static readonly List<object[]> Data =
[
["user@example.com", true],
["invalid", false],
["", false],
["user@", false],
["user@example.co.uk", true]
];
public IEnumerator<object[]> GetEnumerator() => Data.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(EmailValidationData))]
public void IsValidEmail_ReturnsExpected(string email, bool expected)
{
Assert.Equal(expected, EmailValidator.IsValid(email));
}Data From Files and Databases
For tests driven by external data:
From CSV (xUnit with CsvHelper):
public class CsvOrderData : TheoryData<string, decimal, bool>
{
public CsvOrderData()
{
using var reader = new StreamReader("TestData/orders.csv");
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
foreach (var record in csv.GetRecords<OrderTestRecord>())
{
Add(record.OrderId, record.Total, record.IsValid);
}
}
}From JSON:
public static IEnumerable<object[]> LoadFromJson()
{
var json = File.ReadAllText("TestData/scenarios.json");
var scenarios = JsonSerializer.Deserialize<List<TestScenario>>(json);
return scenarios.Select(s => new object[] { s.Input, s.Expected });
}
[Theory]
[MemberData(nameof(LoadFromJson))]
public void ProcessScenario(string input, string expected)
{
Assert.Equal(expected, _processor.Process(input));
}Organizing Test Data
As parameterized test data grows, organization matters:
Option 1: Nested static classes
public class OrderValidatorTests
{
public static class ValidOrders
{
public static IEnumerable<object[]> Data => [ ... ];
}
public static class InvalidOrders
{
public static IEnumerable<object[]> Data => [ ... ];
}
[Theory]
[MemberData(nameof(ValidOrders.Data), MemberType = typeof(ValidOrders))]
public void Validate_ValidOrder_ReturnsSuccess(Order order) { ... }
[Theory]
[MemberData(nameof(InvalidOrders.Data), MemberType = typeof(InvalidOrders))]
public void Validate_InvalidOrder_ReturnsError(Order order, string error) { ... }
}Option 2: Separate TestData project
For large suites, a Tests.Data project with data factories and test case builders keeps test data organized and reusable across test projects.
Pitfalls
Pitfall 1: Parameter type mismatch
// Fails at runtime: InlineData passes int, test expects long
[Theory]
[InlineData(1000000000000)] // Too large for int
public void LargeNumber_Handled(long number) { ... }
// Fix: use L suffix or cast
[Theory]
[InlineData(1000000000000L)]
public void LargeNumber_Handled(long number) { ... }Pitfall 2: Shared mutable state in theory data
// Bad: same list object shared across test runs
private static readonly List<string> SharedList = ["a", "b", "c"];
public static IEnumerable<object[]> BadData =>
[[SharedList]]; // Tests can mutate SharedList
// Good: create new instance per test case
public static IEnumerable<object[]> GoodData =>
[[new List<string> { "a", "b", "c" }]];Pitfall 3: Combinatorial explosion
NUnit's [Values] and xUnit's combinatorial approach can generate thousands of tests. Profile test execution time before using combinatorial testing extensively.
Which Approach to Use
| Scenario | Best Choice |
|---|---|
| Simple primitive values | [InlineData] (xUnit) or [TestCase] (NUnit) |
| Complex objects | MemberData/ClassData (xUnit) or TestCaseSource (NUnit) |
| Named test cases | TestCaseData (NUnit) |
| Type-safe test data class | TheoryData<T> (xUnit) |
| Combinatorial coverage | [Values] + [Test] (NUnit) |
| External file data | Custom data class in any framework |
The right tool depends on your framework and data complexity. Start simple ([InlineData] or [TestCase]) and escalate to MemberData or TestCaseSource when inline data becomes unwieldy.
Running Parameterized Tests
All three frameworks integrate with dotnet test:
# Run all parameterized tests for a specific method
dotnet test --filter "FullyQualifiedName~ValidateEmail"
# Run a specific data case (value appears in test name)
dotnet test --filter "user@example.com"
# Run with verbose output to see all parameters
dotnet test --logger "console;verbosity=detailed"Summary
Parameterized tests are one of the highest-leverage improvements you can make to a test suite. One method covers what would otherwise require 10 identical methods. Adding coverage for a new case is one line of data.
The frameworks differ in expressiveness: NUnit's TestCaseSource and named TestCaseData give the most readable failure output. xUnit's TheoryData<T> adds type safety. MSTest's [DataRow] is the simplest to learn but the least flexible.
Start with whatever your framework provides natively. When that's not enough, the external data options in all three frameworks can handle arbitrarily complex test scenarios.