UPDATE (8 Jun 2021): This series has been upgraded to .NET 5.0. It previously used ASP.NET Core 3.0.

In this final part of my unit testing series, we're going to take a single extension method and show how we can use XUnit's Theory attribute and InlineData attribute to quickly write a bunch of tests for that method.

Math exam
Oh don't worry, our test won't even be this hard. Photo by Chris Liverani / Unsplash

Sample Project

Don't forget: there's a sample project on GitHub that you might want to use to following along with this post.

exceptionnotfound/XUnitMockTestsDemo
Contribute to exceptionnotfound/XUnitMockTestsDemo development by creating an account on GitHub.

The Extension Method

Let's take a look at the extension method (and its associated enumeration):

public enum CIEqualsOption : short
{
    Normal,
    NullEqualsEmpty
}

/// <summary>
/// Case-Insensitive Equals. Returns true if 
/// the two strings are equal (regardless of case).
/// </summary>
public static bool CIEquals(this string first, 
                            string second, 
                            CIEqualsOption option = CIEqualsOption.Normal)
{
    if (option == CIEqualsOption.NullEqualsEmpty)
        return String.Equals(first ?? "", 
                             second ?? "", 
                             StringComparison.InvariantCultureIgnoreCase);
                             
    else return String.Equals(first, 
                              second, 
                              StringComparison.InvariantCultureIgnoreCase);
}

This method is a case-insensitive string equality method; it should return true if two strings are equal, ignoring casing. There is also an option to treat null strings as if they are empty.

Using the methodology from earlier posts in this series, we can determine four unit test scenarios:

  1. Strings ARE equal, CIEqualsOption.None used.
  2. Strings ARE NOT equal, CIEqualsOption.None used.
  3. Strings ARE equal, CIEqualsOption.NullEqualsEmpty used.
  4. Strings ARE NOT equal, CIEqualsOption.NullEqualsEmpty used.

There's a problem here. From these four scenarios, we might assume that we only need to write four tests using XUnit's [Fact] attribute. But consider this: in Scenario 2, what happens if the first string is empty but the second string is null? Shouldn't we also test what happens when the two strings are the same, with only one character different?

In Scenario 1, if the first string is "Test" and the second string is "Test 2", that should fail, and it should ALSO fail if the second string is null. There are a lot of possible combinations of tests, and if we write them using only [Fact], we will end up with a lot of unnecessary code.

Why is it unnecessary, you ask? Because XUnit provides a way to do this kind of testing much more concisely using the [Theory] and [InlineData] attributes.

XUnit's [Fact] and [Theory] Unit Tests

A Fact, in XUnit tests, is by definition a test method that has no inputs. Consequently, it is run as a single test: arrange once, act once, assert once.

In contrast, a Theory in XUnit attribute specifies that a test method can have inputs, and that the method needs to be tested for many different combinations of inputs. How we get those combinations of inputs can be done in several ways.

The first way, and they way we are going to demonstrate in this post, is using the InlineData attribute. [InlineData] allows us to specify that a separate test is run on a particular Theory method for each instance of [InlineData]. We can have as many [InlineData] attributes on a given method as we please; the only constraint is that the values passed into the method by [InlineData] must be runtime constants (this is true of data passed using any attribute, not just this one).

Let's take the unit test code for Scenario #1 and break down what it is doing.

Scenario #1: Strings Equal, CIEqualsOption.None Used

[Theory]
[InlineData("TeSt CaSe", "tEsT cAsE")]
[InlineData(null, null)]
[InlineData("", "")]
public void StringExtensions_CIEquals_TrueCases(string first, string second)
{
    var result = first.CIEquals(second);
    Assert.True(result);
}

The test code itself it very simple: it merely runs the compare method and calls Assert.True() on the result.

In fact, this test will be run four times, one time for each of the [InlineData] attributes. We can see, just by looking at the inputs in each [InlineData], that indeed each of them should be case-insensitive equal to the other. But why didn't we just write a single [Fact] test for this? Because the four test cases are testing for something slightly different.

The first and second test cases are merely testing the base functionality of the method; namely, that two strings with different cases but the same letters are equal. However, the third case tests if two null strings are equal (indeed, they are), and the fourth tests if two empty strings are equal. Each test case is different, and covers a different aspect of the method.Using this same logic, let's write the unit tests for Scenario #2.

Scenario #2: Strings Not Equal, CIEqualsOption.None Used

[Theory]
[InlineData("Test Case", "test case ")]
[InlineData("TeSt CaSe", "tEsT cAsE 2")]
[InlineData("Test Case", null)]
[InlineData("", "Test Case")]
public void StringExtensions_CIEquals_FalseCases(string first, string second)
{
    var result = first.CIEquals(second);
    Assert.False(result);
}

As with the first scenario, the tests here are testing many different aspects of the test method. We also include comparisons to null and an empty string for completeness.

Now let's write up the test code for the remaining scenarios.

Scenario #3: Strings Equal, NullEqualsEmpty Used

[Theory]
[InlineData(null, "")]
[InlineData("", null)]
[InlineData(null, null)]
[InlineData("", "")]
public void StringExtensions_CIEquals_NullEqualsEmpty_TrueCases(string first, string second)
{
    var result = first.CIEquals(second, CIEqualsOption.NullEqualsEmpty);
    Assert.True(result);
}

Scenario #4: Strings Not Equal, NullEqualsEmpty Used

[Theory]
[InlineData("Test Case", "test case ")]
[InlineData("TeSt CaSe", "tEsT cAsE 2")]
[InlineData("Test Case", null)]
[InlineData("", "Test Case")]
public void StringExtensions_CIEquals_FalseCases(string first, string second)
{
    var result = first.CIEquals(second);
    Assert.False(result);
}

As with the earlier scenarios, each group of unit tests are testing various aspects of the scenario they are written for.  

How Many Tests Do We Have?

Remember that, as far as XUnit is concerned, each instance of [InlineData] comprises a separate test. From the unit tests above, we have written a total of four methods, for a grand total of fifteen (15) unit tests. Imagine trying to write out each of these scenarios as a [Fact] method, and how much code that would be!

If you want even more of a reason why the Theory and InlineData attributes are so useful, imagine trying to write these tests for even a small production-ready application. I wouldn't be surprised if there were hundreds or thousands of tests, and trying to write each one individually takes a LOT of time.

[Theory] and [InlineData] (along with sister attributes [ClassData] and [MemberData], see the below blog post by Andrew Lock for more on them) save developers a lot of time when trying to write closely-related groups of unit tests.

Creating parameterised tests in xUnit with [InlineData], [ClassData], and [MemberData]
In this post I describe how to create parameterised tests using xUnit’s [Theory], [InlineData], [ClassData], and [MemberData] attributes.

Summary

Test methods marked with the [Theory] attribute can have input parameters, and have values passed to them by using the [InlineData] attribute. In this way, a single test method can support many tests, and developers save significant time writing tests by using these attributes. In our example, we wrote four methods but have fifteen (15) tests using them.

Do you have other ways of using the XUnit [Theory] attribute, or the [InlineData] attribute? Has this series helped you out in some way? I want to know about it! Please share in the comments.

Don't forget to check out the sample project on GitHub, and thanks for reading this series!

Happy Testing!