Unit tests are great, they help us catch bugs early and make refactoring much safer. A well-tested project is not only more reliable but also easier to maintain.
One common challenge with unit tests is redundant test data. Repeating similar test cases with slight variations can quickly become tedious, especially when that data needs to change.
That’s where parameterized tests come in.
Instead of duplicating test logic for every data variation, parameterized tests allow us to supply multiple inputs from external sources.
Parameterized Tests
Parameterized tests allow you to run the same test logic multiple times with different sets of input data.
They help keep your test code clean, expressive and easy to maintain. It's supported by every relevant testing framework:
- xUnit
- NUnit
- MSTest
In this post, I’ll focus on xUnit to demonstrate how parameterized tests can simplify repetitive test cases.
If you're new to unit testing, be sure to check out this guide: Unit Testing with xUnit
Here’s a simple validator class that checks whether a product name is valid:
public static class ProductValidator
{
public static bool IsValid(string productName)
{
return !string.IsNullOrWhiteSpace(productName);
}
}
The method returns false if the name is null, an empty string, or whitespace. Here’s how you could write tests for each invalid case using [Fact]:
public class ProductValidatorTests
{
[Fact]
public void IsValid_ShouldReturnFalse_WhenNameIsNull()
{
var result = ProductValidator.IsValid(null);
result.ShouldBeFalse();
}
[Fact]
public void IsValid_ShouldReturnFalse_WhenNameIsEmpty()
{
var result = ProductValidator.IsValid(string.Empty);
result.ShouldBeFalse();
}
[Fact]
public void IsValid_ShouldReturnFalse_WhenNameIsWhitespace()
{
var result = ProductValidator.IsValid(" ");
result.ShouldBeFalse();
}
}
This works, but it’s repetitive and clutters your tests. With parameterized tests the goal is to replace this block of code with something like this:
public class ProductValidatorTests
{
[Theory]
public void IsValid_ShouldReturnFalse_WhenNameIsInvalid(string productName)
{
var result = ProductValidator.IsValid(productName);
result.ShouldBeFalse();
}
}
[Theory] is an attribute that allows you to write parameterized tests.
To make this work, the last piece is supplying input values and xUnit offers you multiple ways to do that:
- InlineData Attribute
- MemberData Attribute
- ClassData Attribute
InlineData Attribute
[InlineData] is the simplest way to supply input values directly to a [Theory] test in xUnit.
It allows you to define one or more sets of parameters, right above your test method and xUnit will run the test once for each set.
public class ProductValidatorTests
{
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_ShouldReturnFalse_WhenNameIsInvalid(string input)
{
var result = ProductValidator.IsValid(input);
result.ShouldBeFalse();
}
}
InlineData works best when you have just a few test cases and the inputs are simple, like in our example.
MemberData Attribute
[MemberData] is an attribute that allows you to pass test data to a [Theory] test using a property, field or method with type of IEnumerable<object[]>.
Here's an example of test using MemberData attribute:
public class ProductValidatorTests
{
public static IEnumerable<object[]> InvalidProductNames =>
new List<object[]>
{
new object[] { null },
new object[] { "" },
new object[] { " " }
};
[Theory]
[MemberData(nameof(InvalidProductNames))]
public void IsValid_ShouldReturnFalse_ForInvalidProductNames(string productName)
{
var result = ProductValidator.IsValid(productName);
result.ShouldBeFalse();
}
}
This is an interesting alternative when you want to reuse the data inside the same class or if the data is generated programmatically.
ClassData Attribute
Saving the best for last, the [ClassData] attribute is your best bet when you want full control over your test data.
It allows you to move your test data into a separate class, where you can structure it clearly and generate it dynamically. Here's an example of test using ClassData:
public class InvalidProductNamesData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { null };
yield return new object[] { "" };
yield return new object[] { " " };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class ProductValidatorTests
{
[Theory]
[ClassData(typeof(InvalidProductNamesData))]
public void IsValid_ShouldReturnFalse_WhenNameIsInvalid(string input)
{
var result = ProductValidator.IsValid(input);
result.ShouldBeFalse();
}
}
To get the most out of [ClassData], I personally love combining it with TheoryData to take advantage of strongly typed test data:
public class InvalidProductNamesTheoryData : TheoryData<string>
{
public InvalidProductNamesTheoryData()
{
Add(null);
Add("");
Add(" ");
}
}
public class ProductValidatorTests
{
[Theory]
[ClassData(typeof(InvalidProductNamesTheoryData))]
public void IsValid_ShouldReturnFalse_WhenNameIsInvalid(string input)
{
var result = ProductValidator.IsValid(input);
result.ShouldBeFalse();
}
}
TheoryData is a helper class provided by xUnit that makes it easier to provide strongly typed test data for [Theory] tests.
Conclusion
Parameterized tests are a powerful way to reduce repetition and improve clarity in your unit tests.
xUnit provides several ways to feed data into your [Theory] tests, each suited for different use cases:
Use InlineData for quick, simple inputs.
Use MemberData when your data comes from a static method or property.
Use ClassData, optionally combined with TheoryData, when you need fully structured or dynamically generated test data.
By leveraging these features, you can keep your test suite clean, scalable and easy to maintain.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!
