Improving MSTest unit tests with DataRows

Writing clean efficient, clean, readable code should always a concern when programming; this extends to writing unit tests. Writing with DRY in mind we should avoid repeating what has been writing where possible.

Common Test Pattern

When we have a scenario where we want to test the same piece of code with different inputs the most straight forward option would be to write a new unit test for each option.

Say we recently added an extension method to help us truncate a string.

public static class StringExtension
{
    public static string Truncate(this string value, int maxLength)
    { 
        if (string.IsNullOrEmpty(value)) return value;
        return value.Length <= maxLength ? value : value.Substring(0, maxLength < 0 ? 0 : maxLength); 
    }
}

Our first option for testing would be to write a series of tests that covers the regular and edge cases of our code.

[TestClass]
public class StringExtensionTest
{
    [TestMethod]
    public void WhenTruncateLengthIsShorterThanStringLength()
    {
        // Arrange
        var testString = "ABCDE";

        // Act
        testString = testString.Truncate(3);

        // Assert
        Assert.AreEqual("ABC", testString);
    }

    [TestMethod]
    public void WhenTruncateLengthEqualsStringLength()
    {
        var testString = "ABCDE";

        testString = testString.Truncate(5);

        Assert.AreEqual("ABCDE", testString);
    }

    [TestMethod]
    public void WhenTruncateLengthExceedStringLength()
    {
        var testString = "ABCDE";

        testString = testString.Truncate(7);

        Assert.AreEqual("ABCDE", testString);
    }

    [TestMethod]
    public void WhenTruncateLengthIsZero() 
    {
        var testString = "ABCDE";

        testString = testString.Truncate(0);

        Assert.AreEqual("", testString);
    }

    [TestMethod]
    public void WhenTruncateLengthIsNegative()
    {
        var testString = "ABCDE";

        testString = testString.Truncate(-3);

        Assert.AreEqual("", testString);
    }

    [TestMethod]
    public void WhenStringIsNull()
    {
        string testString = null;

        testString = testString.Truncate(3);

        Assert.IsNull(testString);
    }

    [TestMethod]
    public void WhenStringIsEmpty()
    {
        var testString = "";

        testString = testString.Truncate(3);

        Assert.AreEqual("", testString);
    }
}

WOW! That's a lot of boilerplate-y test code for such a simple method in our core code.

Refactoring without DataRow

When we look at the tests they have a lot in common so an option could have been to refactor them to use a shared method in the test class.

[TestClass]
public class StringExtensionTest
{
    private void TestAndAssertTruncateString(string testString, int targetLength, string expectedValue)
    {
        testString = testString.Truncate(targetLength);

        Assert.AreEqual(expectedValue, testString);
    }

    [TestMethod]
    public void WhenTruncateLengthIsShorterThanStringLength()
    {
        TestAndAssertTruncateString("ABCDE", 3, "ABC");
    }

    [TestMethod]
    public void WhenTruncateLengthEqualsStringLength()
    {
        TestAndAssertTruncateString("ABCDE", 5, "ABCDE");
    }

    // ... Continue to update each test to use the same method
}

This is better, but it comes at the cost of having to add an extra method to the class. Not a big deal, but MS Test comes with some built in options that we can do even better. At this point our next improvement is to explore what the test class would look like if we used the [DataRow] attribute.

Improvements with DataRow

Being lazy is good. When we don't have to write code we shouldn't. This test class can be consolidated into a single test method.

[TestClass]
public class StringExtensionTest
{
    [TestMethod]
    [DataRow("ABCDE", 3, "ABC")]
    [DataRow("ABCDE", 5, "ABCDE")]
    [DataRow("ABCDE", 7, "ABCDE")]
    [DataRow("ABCDE", 0, "")]
    [DataRow("ABCDE", -3, "")]
    [DataRow("ABCDE", 3, null)]
    [DataRow("", 3, "")]
    public void TruncateStringTest(string testString, int targetLength, string expectedValue)
    {
        testString = testString.Truncate(targetLength);

        Assert.AreEqual(expectedValue, testString);
    }
}

Wow! This is a lot better we've narrow down our unit test to a single method. This was possible because our unit test was testing the same functionality and asserting on the same value or sets of values.

DataRow in Test Explorer

We did end up changing one of the unit tests to use Assert.AreEqual(null, value) instead of Assert.IsNull(value). If that is something that is important in our code we can split it into its own unit test, but in my opinion I think what we've done is a worthy tradeoff.

Something that we've lost out on is the naming of the unit tests. We now have just a single generic name for unit tests each test case is less descriptive of what it is testing. We can fix this by taking advantage of of the optional parameter in DataRow.

[TestClass]
public class StringExtensionTest
{
    [TestMethod]
    [DataRow("ABCDE", 3, "ABC", DisplayName = "When length is shorter")]
    [DataRow("ABCDE", 5, "ABCDE", DisplayName = "When length is equal")]
    [DataRow("ABCDE", 7, "ABCDE", DisplayName = "When length is greater")]
    [DataRow("ABCDE", 0, "", DisplayName = "When length is zero")]
    [DataRow("ABCDE", -3, "", DisplayName = "When length is negative")]
    [DataRow("ABCDE", 3, null, DisplayName = "When string is null")]
    [DataRow("", 3, "", DisplayName = "When string is empty")]
    public void TruncateStringTest(string testString, int targetLength, string expectedValue)
    {
        testString = testString.Truncate(targetLength);

        Assert.AreEqual(expectedValue, testString);
    }
}

DataRow in Test Explorer with DisplayName information

Now when you this test runs we will have useful information about which test cases are passing or failing from our DataRows. One nice thing about data rows vs the earlier refactoring is that we keep our setup or Arrange section of the test close to the Act and Assert sections. Read more about AAA testing here.

Limitations

Because DataRows are attribute driven it means we have limitations on what values we can test. Attributes are limited to containing values to mostly primitives, types, public enums, objects, and single dimension arrays that use one of previous options. Read more about C# Attribute specification on MSDN. So if we have a type that we want to test using DataRows that isn't supported we have to do some more more work.

Complex Type Testing with DataRows

If we had a method that accepted a complex type like a DateTime we can fudge our unit test to support testing multiple input options via DataRows by using one of valid attribute input options and massaging it to the correct type in the arrange section of our test.

[TestMethod]
[DataRow("1900-01-01", false, DisplayName = "Year divisible by 4 and 100, but not 400")]
[DataRow("2000-01-01", true, DisplayName = "Year divisible by 4, 100, and 400")]
[DataRow("2019-01-01", false, DisplayName = "Year not divisible by 4")]
[DataRow("2020-01-01", true, DisplayName = "Year divisible by 4, but not 100 or 400")]
public void TestLeapYearHelper(string dateInput, bool expectedValue)
{
    var date = DateTime.Parse(dateInput);

    var isLeapYear = date.IsLeapYear();

    Assert.AreEqual(expectedValue, isLeapYear);
}

This is necessarily ideal as we have to put more trust into our setup when there could potentially be unseen or unintended setup.

Continuing Improvements

Since DataRows don't offer native support for these complex types and working with them requires a bit of setup another option provided by MSTest is DynamicData. DynamicData can give us more options when our DataRows either require too much manipulation to support use cases where we need to validate against multiple Lists with complex objects.

Continue reading on how we can use DynamicData to further improve our unit tests for complex types with DynamicData in the second section of this series.


See the MSDN documentation on DataRow.

© Mark Peterson 2019 - 2021