C# tutorials > Testing and Debugging > Unit Testing > Data-driven tests (`[Theory]` with inline or member data)

Data-driven tests (`[Theory]` with inline or member data)

This tutorial explores data-driven unit tests in C# using xUnit.net. Data-driven tests allow you to run the same test multiple times with different input values, improving test coverage and reducing code duplication. We will cover the use of [Theory] attribute along with [InlineData] and [MemberData] attributes.

Introduction to Data-Driven Testing

Data-driven tests are a powerful technique in unit testing where a single test method is executed multiple times with different sets of input data. This is particularly useful when testing the same logic with various scenarios, such as boundary conditions, edge cases, or different types of valid and invalid input.

xUnit.net provides the [Theory] attribute to mark a test method as a data-driven test. Data is then supplied using attributes like [InlineData] or [MemberData].

Using `[Theory]` and `[InlineData]`

The [InlineData] attribute allows you to provide data directly as arguments to the test method. Each [InlineData] attribute represents a single test case. In the example above, the Add_TwoNumbers_ReturnsSum test method is executed three times with different sets of input values (a, b, expected).

The [Theory] attribute marks the method as a theory, signaling to xUnit that it should be executed with data provided by attributes like [InlineData].

using Xunit;

public class StringCalculatorTests
{
    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    public void Add_TwoNumbers_ReturnsSum(int a, int b, int expected)
    {
        // Arrange
        StringCalculator calculator = new StringCalculator();

        // Act
        int actual = calculator.Add(a, b);

        // Assert
        Assert.Equal(expected, actual);
    }
}

public class StringCalculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}

Using `[Theory]` and `[MemberData]`

The [MemberData] attribute allows you to provide data from a static property or method within your test class. This is useful when the data is complex or requires some computation to generate. The property or method must return an IEnumerable, where each object[] represents a single test case.

In the example above, the ValidEmails and InvalidEmails properties provide sets of valid and invalid email addresses respectively. The IsValidEmail_ValidEmails_ReturnsTrue and IsValidEmail_InvalidEmails_ReturnsFalse test methods use these properties to test the IsValidEmail method.

using Xunit;
using System.Collections.Generic;

public class StringValidatorTests
{
    public static IEnumerable<object[]> ValidEmails()
    {
        yield return new object[] { "test@example.com" };
        yield return new object[] { "john.doe@subdomain.example.co.uk" };
    }

    public static IEnumerable<object[]> InvalidEmails()
    {
        yield return new object[] { "invalid-email" };
        yield return new object[] { "missing@domain" };
        yield return new object[] { "@missing-username.com" };
    }

    [Theory]
    [MemberData(nameof(ValidEmails))]
    public void IsValidEmail_ValidEmails_ReturnsTrue(string email)
    {
        // Arrange
        StringValidator validator = new StringValidator();

        // Act
        bool actual = validator.IsValidEmail(email);

        // Assert
        Assert.True(actual);
    }

    [Theory]
    [MemberData(nameof(InvalidEmails))]
    public void IsValidEmail_InvalidEmails_ReturnsFalse(string email)
    {
        // Arrange
        StringValidator validator = new StringValidator();

        // Act
        bool actual = validator.IsValidEmail(email);

        // Assert
        Assert.False(actual);
    }
}

public class StringValidator
{
    public bool IsValidEmail(string email)
    {
        try
        {
            var addr = new System.Net.Mail.MailAddress(email);
            return addr.Address == email;
        }
        catch
        {
            return false;
        }
    }
}

Concepts Behind the Snippet

The core concept is to avoid repeating the same test setup and assertion logic multiple times. Data-driven tests promote code reusability and make your tests more maintainable. By externalizing the data, you can easily add, modify, or remove test cases without altering the test method itself.

Real-Life Use Case

Consider a function that validates user input for a web form. You might want to test various scenarios, such as valid usernames, invalid usernames (too short, containing special characters), valid passwords, and invalid passwords (missing a number, not long enough). Data-driven tests are ideal for this scenario because you can define all the test cases in a data source (e.g., a static property) and run the validation logic against each one.

Best Practices

  • Keep Test Cases Independent: Each test case should be independent of the others. Avoid dependencies between them.
  • Use Descriptive Names: Name your test methods and data sources descriptively so it's clear what each test case is testing.
  • Provide Clear Error Messages: Make sure your assertions provide meaningful error messages when a test fails, so it's easy to identify the root cause.
  • Avoid Overly Complex Data Sources: If your data source becomes too complex, consider refactoring it into smaller, more manageable chunks.

Interview Tip

When discussing unit testing in an interview, be prepared to explain the benefits of data-driven tests and when they are most appropriate. Demonstrate your understanding of [Theory], [InlineData], and [MemberData]. Be ready to discuss the trade-offs between data-driven tests and traditional unit tests.

When to Use Them

Use data-driven tests when you need to test the same logic with a variety of inputs. They are particularly useful for:

  • Testing boundary conditions and edge cases.
  • Validating input parameters.
  • Testing mathematical functions with different values.
  • Verifying business rules with various scenarios.

Memory Footprint

The memory footprint of data-driven tests can vary depending on how the data is stored and the complexity of the data. [InlineData] generally has a smaller memory footprint because the data is directly embedded in the test method. [MemberData] might have a larger footprint if the data source is large or requires significant computation to generate. Be mindful of memory usage when dealing with very large datasets.

Alternatives

The main alternative to data-driven tests is to create separate, individual unit tests for each scenario. While this approach provides more granular control over each test case, it can lead to code duplication and increased maintenance effort, especially when testing the same logic repeatedly.

Pros

  • Reduced Code Duplication: Avoid repeating the same test setup and assertion logic.
  • Increased Test Coverage: Easily test a wide range of scenarios with different inputs.
  • Improved Maintainability: Centralized data source makes it easier to add, modify, or remove test cases.
  • Enhanced Readability: The data source clearly defines the test scenarios.

Cons

  • Complexity: Data sources can become complex, especially when dealing with large or dynamically generated data.
  • Debugging: Debugging can be more challenging when a data-driven test fails because you need to identify which specific test case caused the failure.
  • Overhead: There is some overhead involved in setting up and managing the data source.

FAQ

  • What is the difference between `[InlineData]` and `[MemberData]`?

    [InlineData] allows you to provide data directly as arguments to the test method. [MemberData] allows you to provide data from a static property or method, which is useful when the data is complex or requires computation to generate.

  • Can I use different data types with `[InlineData]`?

    Yes, you can use different data types with [InlineData], as long as the types match the parameters of your test method.

  • How do I debug a data-driven test?

    You can set a breakpoint inside your test method and use the debugger to step through the execution for each test case. The debugger will show you the input values for each iteration.