Improving complex MSTest unit tests with DynamicData

Sometimes using DataRow isn't sufficient to write consice clear unit tests that test our core code. When we have situations where we want to test complex types or multiple lists it can get messy attempting to stuff all of the required information inline in an attribute.

DynamicData Attribute

The MSTest [DyanmicData] attribute gives us a way to specify multiple test conditions for a unit test without comprmising on the type limitations of inline attributes definitions.

In first section of this series on unit testing we looked at an example of a unit test for a IsLeapYear method.

[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 = new DateTime(dateInput);

    var isLeapYear = date.IsLeapYear();

    Assert.AreEqual(expectedValue, isLeapYear);
}

Because C# attributes limit what types of values are able to be stored. Storing a regular DateTime wasn't possible. This forced us to setup the test to have the Arrange section with a parsing of the string representation of the DateTime that we wanted to test.

While it wouldn't matter for the leap year unit test there is the possiblity that things like machine configuration and implied date formatting could be different between users running the code.

If we had setup a test like this then it'd be possible that our test would succeed for some developers and fail for others.

[TestMethod]
[DataRow("01-07-2019", 1, DisplayName = "January 14, 2019 is in the week of the month")]
[DataRow("01-12-2019", 2, DisplayName = "January 14, 2019 is in the second week of the month")]
public void TestWeekNumber(string dateInput, int expectedValue)
{
    var date = new DateTime(dateInput);
    //...
}

If your machine configuration for dates follows the MM-DD-YYYY would default that to read it as the DisplayName implies then you'd be fine running the test; however, if your machine configuration follows dd-MM-YYYY then it'd fail.

We do have the option of changing the test to allow multiple parameters for each date part since there is an overload of DateTime constructor that allows it.

[TestMethod]
[DataRow(2019, 1, 7, 1, DisplayName = "January 14, 2019 is in the week of the month")]
[DataRow(2019, 1, 12, 2, DisplayName = "January 14, 2019 is in the second week of the month")]
public void TestWeekNumber(int year, int month, int day, int expectedValue)
{
    var date = new DateTime(year, month, day);
}

We won't always be as lucky as to have a type with as many overloads as DateTime. Custom types often aren't as generous.

Type flexibility of DynamicData

With MSTest's DynamicData attribute we can specify a helper property or method to retrieve test data from. C# treats both of those sources as first class citizens and we have much more flexibility in how we define our test objects.

[TestMethod]
[DynamicData(nameof(GetTestWeekNumberData), DynamicDataSourceType.Method)]
public void TestWeekNumber(DateTime date, int expectedValue)
{
    var weekNumber = date.GetWeekNumber();

    Assert.AreEqual(expectedValue, weekNumber);
}

private static IEnumerable<object[]> GetTestWeekNumberData()
{
    yield return new object[]
    {
        new DateTime(2019, 01, 07),
        1
    };

    yield return new object[]
    {
        new DateTime(2019, 01, 12),
        2
    };

    // Continue for each required test.
}

We now are able to keep our DateTime as a DateTime througout the test rather than relying on parsing or other tweaking of the data in the setup.

The syntax of yield return new object[] {/*...*/}; is a bit odd, but we can break down what is happening in sections.

  • new object[]: Each element in the array will be used by MSTest to be plugged into the parameters array at the same index. Since each parameter has the potential to be its own type we have to use object. I like to think about this like the params keyword in c# (See MSDN on params).
    • So new DateTime(...) is plugged into the zero index parameter which lines up with DateTime date parameter in the test method.
    • The 1 or 2 lines up with index one parameter int expectedValue.
  • return type of IEnumerable<object[]> allows us to pass back multiple test cases from the setup method.
  • yield return works out to be a way to return an enumerable of test cases from the single method.

The attribute syntax has the first parameter as string dynamicDataSource and the second optional parameter as DynamicDataSourceType dynamicDataSourceType which defaults to DynamicDataSourceType.Property. The first parameter could technically accept a string like "GetTestWeekNumberData" but I'd recommend always using the nameof(...) method incase the target method name ever changes it makes the renaming easier and provides references of where it is used.

My personal prefrence is to use methods for the test data. This signature for the DynamicData source method will work for any test you can come up with and I generally will default to this.

Going further

[TestMethod]
[DynamicData](nameof(GetTestWeekNumberData), DynamicDataSourceType.Method)]
public void TestWeekNumber(DateTime date, int expectedValue)
{
    var weekNumber = date.GetWeekNumber();

    Assert.AreEqual(expectedValue, weekNumber);
}

private static object[] GetTestWeekNumberData()
{
    // First week of January tests
    for (var day = 1; day <= 7; day++)
    {
        yield return new GetTestWeekNumberData2Type
        {
            date = new DateTime(2019, 1, day),
            expectedValue = 1
        }.ToObjectArray();
    }
}

private struct GetTestWeekNumberData2Type
{
    public DateTime date { get; set; }
    public int expectedValue { get; set; }

    public object[] ToObjectArray()
    {
        return new object[] { date, expectedValue };
    }
}

DynamicData will error if you pass it a method whose return type is not a IEnumerable<object[]>, but working with yield return object[] has the potential to get confusing with which parameter you are currently typing since pretty much anything is acceptable for each option until you attempt to run it.

The option that I've shown here is to make a struct for the unit test to represent the parameters that we use in the TestMethod. This gives us a way to get a little bit better intellisense while typing out the object and should make it more obvious to the reader what each part of the object[] will get mapped to.

We do have to get it back into the object[] format for the return type so we add a simple method to the struct to setup the object[] with the order that our test is expecting.

I wouldn't always recommend spending time to setup the test like this, but for a critical or complex code it might be worth it.

Wrapping Up

We've looked at MSTest both DataRow and DynamicData. We should now have more tools available to use when writing unit tests.

I've personally been able to use DataRow and DynamicData to rip out hundreds of unit tests from classes simplifying them and providing more clarity on what cases are tested. Having a team familar with DataRow and DynamicData can mean that they can write more test quicker and should result in increasing the total coverage of your code.


See the MSDN documentation on DynamicData.

© Mark Peterson 2019 - 2021