Today we’ll do some testing in C# using arrays and theories.
What is a Theory?
Have you ever worked with a theory before? It’s a pretty straightforward testing setup. You create a method with input parameters, which will be tagged as a Theory. The most straightforward way to do this is to use InlineData, like so:
[Theory]
[InlineData("Eastern Standard Time", "3:59 PM")]
[InlineData("Central Standard Time", "2:59 PM")]
public void ShouldConvertUtcTimeAsUtcTimeZoneToUserTimeZone(
string timeZone,
string expectedTime
)
{
// Set up your test and asserts here
}
When this test is run, it will be run twice. Once for each InlineData item. The first execution will run with timeZone set to “Eastern Standard Time”, and the expectedTime will be “3:59 PM”. The second execution will run with timeZone set to “Central Standard Time”, and the expectedTime will be “2:59 PM”. This allows us to only set up one test, but run for multiple cases without duplicating any of the setup or asserts. The key to note with Theories is that the only thing that will change between the runs are the parameters that are passed in, so your test must be able to be set up and run independent of the inputs.
However, this inline method can get a little limited if you have a lot of data to input or a lot of edge cases. The other way to run a Theory is to provide a MemberData method.
[Theory]
[MemberData(nameof(TimeZoneData))]
public void ShouldConvertUtcTimeAsUtcTimeZoneToUserTimeZone(
string timeZone,
string expectedTime
)
{
// Set up your tests and asserts here
}
Here, our Theory will look for a method called TimeZoneData, which will return an Enumerable object. That Enumerable will hold our parameters for the test. The Theory will run once for each Enumerable item that it is passed. It’s really up to the TimeZoneData method to determine how many times we want to run the Theory.
Here’s what our TimeZoneData method might look like.
public static IEnumerable<object[]> TimeZoneData()
{
yield return new object?[] { "Eastern Standard Time", "3:59 PM" };
yield return new object?[] { "Central Standard Time", "2:59 PM" };
}
Our Theory will run the same as when we used the InlineData, and the test will be run twice. Each separate run will be executed with the different pairs of parameters that are passed in. It may not look all that much like an improvement, but now we have room before our return statement to set up more complicated test data.
Setting Up a Real Example
Next, let’s work with a use case. Let’s say we have a production method called CalculateBalanceWithoutRestitution. Who knows why we need to list restitution on a separate line, but here we are. We want to work with an array of financial orders, which will look something like this:
{
Description: string?,
OrderAmount: double?
}
Our description will tell us if the type of the financialOrder is a restitution, assessment, or something else. I’m going to recommend our first step be to think about our test cases. You may have noticed an annoying one already: All of our attributes are nullable. Which means we have to handle null inputs without crashing. We’ve been told to ignore any nulls that we find.
Here’s a list of test cases we might want to think about:
// Test for : Are null amounts ignored?
// Expected behavior: Does not crash, returns correct total
// Test for : Are null descriptions ignored?
// Expected Behavior: Does not crash, returns correct total
// Test for : Are restitutions ignored?
// Expected Behavior: Amounts not included in total
// Test for : When all attributes are null, are they ignored?
// Expected Behavior: Does not crash, returns correct total
// Test for : No restitution included in array and no null values?
// Expected Behavior: Nothing is ignored
Array Manipulation for Fun and Profit
Now it’s time for our next trick: Array manipulation. We don’t want to have to set up a new test array for each and every test case that we have, so we’re going to take the easy way out.
Let’s begin by setting up a single array of financial orders at the start of our GetFinancialOrders method, followed by our test case notes. For this next part, since it’s a lot of numbers, I would recommend that you use a spreadsheet program like Excel or Google Sheets to keep track of your test cases and sum values. You can use a random number function here to generate some financial order amounts. Of course, your mileage may vary if you’re not testing a method that uses a lot of numbers.
public static IEnumerable<object?[]> GetFinancialOrders()
{
var financialOrders = new List<FinancialOrder>
{
/* 0 */ new("ASSESSMENT", null),
/* 1 */ new("FINE", 305.03),
/* 2 */ new("ATTORNEY FEES", 301.57),
/* 3 */ new("RESTITUTION", 14.63),
/* 4 */ new("RESTITUTION", 64.26),
/* 5 */ new("ASSESSMENT", 499.74),
/* 6 */ new("FINE", 324.16),
/* 7 */ new("ATTORNEY FEES", 293.51),
/* 8 */ new(null, 150.26),
/* 9 */ new(null, null),
/* 10 */ new("RESTITUTION", null)
};
// Test for : Are null amounts ignored?
// Expected behavior: Does not crash, returns correct total
// Test for : Are null descriptions ignored?
// Expected Behavior: Does not crash, returns correct total
// Test for : Are restitutions ignored?
// Expected Behavior: Amounts not included in total
// Test for : When all attributes are null, are they ignored?
// Expected Behavior: Does not crash, returns correct total
// Test for : No restitution included in array and no null values?
// Expected Behavior: Nothing is ignored
}
Currently, we’re unable to compile, since we’re not actually returning anything in our method. We also don’t have our test set up yet, so why don’t we knock that out next. It’s going to be fairly plain looking.
[Theory]
[MemberData(nameof(GetFinancialOrders))]
public void ServiceShould_CalculateBalanceWithoutRestitutionCorrectly(
List<FinancialOrder> financialOrders,
string expectedValue
)
{
var result = Service.CalculateBalanceWithoutRestitution(financialOrders);
result.Should().Be(expectedValue);
}
See? Pretty boring. All we need to do is check that our CalculateBalanceWithoutRestitution method sums up our financial orders array correctly. We’re going to compare that balance to our expectedValue parameter to make sure we’re all in alignment.
Let’s fill in a test case:
// Test for : When all attributes are null, are they ignored?
// Expected Behavior: Does not crash, returns correct total
Let’s also write in the test case right underneath the associated comment. This can help us keep track of our intentions. If we just want to check that we can run the method with null inputs, we can use the full test array.
// Test for : When all attributes are null, are they ignored?
// Expected Behavior: Does not crash, returns correct total
yield return new object?[] { financialOrders, "1224.27" };
This is a decent test case, since it includes the full array. It includes financial orders with null amounts and null descriptions. Both of these should be ignored in our total.
Let’s try one more,
// Test for : Are null amounts ignored?
// Expected behavior: Does not crash, returns correct total
This time, let’s test specifically with data that always contains a description but not an amount. We will use a subset of our array to our advantage, and only test with a few financial orders. Instead of setting up a new array, we can use GetRange().
// Test for : Are null amounts ignored?
// Expected behavior: Does not crash, returns correct total
yield return new object?[] { financialOrders.GetRange(0, 4), "606.60" };
GetRange takes in a starting index (0) and a count (4), so this will send the following array into our theory:
{
/* 0 */ new("ASSESSMENT", null),
/* 1 */ new("FINE", 305.03),
/* 2 */ new("ATTORNEY FEES", 301.57),
/* 3 */ new("RESTITUTION", 14.63)
}
Final Result
Using these subsets, we can fill in the rest of our test cases! Here’s what our final code will look like:
public static IEnumerable<object?[]> GetFinancialOrders()
{
var financialOrders = new List<FinancialOrder>
{
/* 0 */ new("ASSESSMENT", null),
/* 1 */ new("FINE", 305.03),
/* 2 */ new("ATTORNEY FEES", 301.57),
/* 3 */ new("RESTITUTION", 14.63),
/* 4 */ new("RESTITUTION", 64.26),
/* 5 */ new("ASSESSMENT", 499.74),
/* 6 */ new("FINE", 324.16),
/* 7 */ new("ATTORNEY FEES", 293.51),
/* 8 */ new(null, 150.26),
/* 9 */ new(null, null),
/* 10 */ new("RESTITUTION", null)
};
// Test for : Are null amounts ignored?
// Expected behavior: Does not crash, returns correct total
yield return new object?[] { financialOrders.GetRange(0, 4), "606.60" };
// Test for : Are null descriptions ignored?
// Expected Behavior: Does not crash, returns correct total
yield return new object?[] { financialOrders.GetRange(4, 5), "1117.41" };
// Test for : Are restitutions ignored?
// Expected Behavior: Amounts not included in total
yield return new object?[] { financialOrders.GetRange(2, 4), "801.31" };
// Test for : When all attributes are null, are they ignored?
// Expected Behavior: Does not crash, returns correct total
yield return new object?[] { financialOrders, "1724.01" };
// Test for : No restitution included in array and no null values?
// Expected Behavior: Nothing is ignored
yield return new object?[] { financialOrders.GetRange(1, 2), "606.60" };
}
[Theory]
[MemberData(nameof(GetFinancialOrders))]
public void ServiceShould_CalculateBalanceWithoutRestitutionCorrectly(
List<FinancialOrder> financialOrders,
string expectedValue
)
{
var result = Service.CalculateBalanceWithoutRestitution(financialOrders);
result.Should().Be(expectedValue);
}