Creating Data-Driven Tests With xUnit

Creating Data-Driven Tests With xUnit

Data-driven testing is a testing method where test data is provided through some external source. Hence it's also known as parameterized testing.

A popular testing library in .NET that supports parameterized testing is xUnit. It uses attributes to define test methods. The Fact attribute defines a simple test, and the Theory attribute defines a parameterized test.

In this week's newsletter, I'm going to show you four ways to write parameterized tests with xUnit:

  • InlineData

  • MemberData

  • ClassData

  • TheoryData

And I'll discuss which approach I think is the best.

Writing Parameterized Tests With InlineData

The simplest way to write parameterized tests with xUnit is using the InlineData attribute. You provide test data by passing in values to the InlineData constructor.

Here's how that would look like:

[Theory]
[InlineData("test@test.com", "test.com")]
[InlineData("milan@milanjovanovic.tech", "milanjovanovic.tech")]
public void EmailParser_Should_Return_Domain(string email, string expectedDomain)
{
    // Arrange
    var parser = new EmailParser();

    // Act
    var domain = parser.ParseDomain(email);

    // Assert
    Assert.Equal(domain, expectedDomain);
}

In this example, we provide two string values to the InlineData attribute, which represent the email and expectedDomain parameters in the test. We can specify the InlineData attribute as many times as we want, to introduce more test cases.

The downside of this approach is that it becomes very verbose when we have many test cases. And we are limited to only using constant data for the parameters.

Writing Parameterized Tests With MemberData

With the MemberData attribute we have the ability to programmatically provide the test data. You can load the test data from a static property or member of a type.

Here's an example of using the MemberData attribute to load test data from a property:

[Theory]
[MemberData(nameof(EmailTestData))]
public void EmailParser_Should_Return_Domain(string email, string expectedDomain)
{
    // Arrange
    var parser = new EmailParser();

    // Act
    var domain = parser.ParseDomain(email);

    // Assert
    Assert.Equal(domain, expectedDomain);
}

public static IEnumerable<object[]> EmailTestData => new List<object>
{
    new object[] { "test@test.com", "test.com" },
    new object[] { "milan@milanjovanovic.tech", "milanjovanovic.tech" }
};

You specify the name of the member in the MemberData attribute, and it's a best practice to use the nameof operator so that you can rename the property (or method) in the future without breaking your test.

The one constraint is that the property (or method) has to return IEnumerable<object[]>, so there is no strong typing.

Writing Parameterized Tests With ClassData

The ClassData attribute allows you to extract test data into its own class. This is helpful for organizing your test data separately from your tests, and it allows for easier reuse. You load the test from a class the inherits from IEnumerable<object[]> and implements the GetEnumerator method.

Here's an example of using the ClassData attribute to load test data from a class:

[Theory]
[ClassData(typeof(EmailTestData))]
public void EmailParser_Should_Return_Domain(string email, string expectedDomain)
{
    // Arrange
    var parser = new EmailParser();

    // Act
    var domain = parser.ParseDomain(email);

    // Assert
    Assert.Equal(domain, expectedDomain);
}

public class EmailTestData : IEnumerable<object[]>
{

    public IEnumerable<object[]> GetEnumerator()
    {
        yield return new object[] { "test@test.com", "test.com" };
        yield return new object[] { "milan@milanjovanovic.tech", "milanjovanovic.tech" };
    }

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
};

Unfortunately, this approach is complicated because you have to implement the IEnumerable interface.

It almost defeats the purpose of separating test data from the actual tests.

And we still suffer from lack of type-safety.

Is there a better solution?

Writing Parameterized Tests With TheoryData

Let me introduce you to TheoryData, which is my preferred way of providing test data for parameterized tests. Using the TheoryData class you can implement a class to provide test data while having the benefit of type-safety.

Here's an example of using TheoryData in combination with ClassData:

[Theory]
[ClassData(typeof(EmailTestData))]
public void EmailParser_Should_Return_Domain(string email, string expectedDomain)
{
    // Arrange
    var parser = new EmailParser();

    // Act
    var domain = parser.ParseDomain(email);

    // Assert
    Assert.Equal(domain, expectedDomain);
}

public class EmailTestData : TheoryData<string, string>
{
    public EmailTestData()
    {
        Add("test@test.com", "test.com");
        Add("milan@milanjovanovic.tech", "milanjovanovic.tech");
    }
};

How does TheoryData work?

It's a generic class that allows us to specify the types for our parameterized test.

You just call the Add method in the EmailTestData constructor to provide test data for a single test case. And introducing more test cases comes down to calling the Add method multiple times.

You can also use TheoryData in combination with MemberData, and return TheoryData from a property or method.

Which Approach Should You Use?

I showed you four approaches to write parameterized tests with xUnit:

  • InlineData

  • MemberData

  • ClassData

  • TheoryData

So which one should you use?

Here's my personal preference that you can follow if you want:

  • InlineData for simple test cases

  • TheoryData using ClassData for complex test cases

Thank you for reading, and have a wonderful Saturday.


P.S. Whenever you’re ready, there are 2 ways I can help you:

  1. Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 950+ students here.

  2. Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 820+ engineers here.