How To Come Up with Test Cases for Unit Tests
Two simple approaches to coming up with test cases for unit tests with examples in C#.
Unit tests are an important part of the development process that help to reassure you that your code is doing what it's supposed to be doing. A unit test tests a specific "unit" (as the name implies) of your code, which in my experience ends up being a specific method or function. If you are able to verify all of the individual units of your application are operating as expected, you can feel a bit safer about your application as a whole.
A single method could have any number of valid test cases; it ultimately depends on the structure of the method. To help determine the test cases required to test a method, there are two simple approaches:
I will help demonstrate these approaches by providing real code examples. All code snippets will be written in C#, and tests will make use of my favourite package for unit testing, Fluent Assertions.
We will be coming up with tests cases for the following method:
public static bool ContainsNamelessItems(this List<Item> items)
{
return items.Any(item => item.Name.IsNullOrEmpty())
}
This method takes in a collection of items as a parameter. It goes through the list of items and for each Item
it is checked whether it has a Name
that is null or empty. If there are any items without a Name
defined, we return True
, otherwise we return False
.
Creating Test Cases Using the Parameter Approach
The Parameter Approach to coming up with test cases involves thinking about what values the parameters for a method can be.
Looking at the parameters of the method ContainsNamelessItems
, we have a single parameter of type List<Item>
called items
. This parameter could have several possible values:
items
is emptyitems
contains at least 1Item
that has aName
property that is not defineditems
does not contain an item that has an undefinedName
propertyitems
isnull
Each of these possibile values can be used to come up with a test case. To come up with our assertions for the test case, we need to consider what we expect to happen when the parameters have each value.
Here are some possible test cases and a corresponding test that would verify the test case:
1) When List<Item>
is empty, we expect the return value to be False
because there are no items in the List<Item>
that are nameless.
public void WhenItemsIsEmpty_ReturnFalse()
{
var items = new List<Item>();
var result = items.ContainsNamelessItems();
result.Should()
.BeFalse("because an empty collection cannot contain nameless items");
}
2) When List<Item>
contains at least 1 nameless Item
, we expect the return value to be True
because there is a nameless item.
public void WhenItemsContainsANamelessItem_ReturnTrue()
{
var items = new List<Item>
{
{ new Item { Name = "Item1" },
{ new Item { Name = string.Empty } // nameless item
};
var result = items.ContainsNamelessItems();
result.Should()
.BeTrue("because there is a nameless item in the collection");
}
3) When List<Item>
does not contain any items that are nameless, we expect the return value to be False
because all items have a name.
public void WhenItemsDoesNotContainANamelessItem_ReturnFalse()
{
var items = new List<Item>
{
{ new Item { Name = "Item1" },
{ new Item { Name = "Item2" }
};
var result = items.ContainsNamelessItems();
result.Should()
.BeFalse("because there are no nameless items in the collection");
}
4) When List<Item>
is null
, we expect an ArgumentNullException
to be thrown
(This will occur becase we are calling items.Any()
when items is null)
public void WhenItemsIsNull_ThrowArgumentException()
{
List<Item> items = null;
Action act = () => items.ContainsNamelessItems();
act.Should()
.Throw<ArgumentNullException>("because the collection is null");
}
Creating Test Cases Using the Execution Path Approach
The Path Approach to creating test cases involves going through the method under test and finding all of the different paths of execution.
The method we defined above has a single path of execution as there are no conditions driving the path anywhere but straight to the end of the method. To alter the path, we would need to introduce some kind of condition, whether through an if...else
, a switch
, or atry/catch
statement. Inside these conditional blocks, there is an opportunity to return or throw in a position that is not the end of the method.
Let's demonstrate this by introducing a new path into the method ContainsNamelessItems
.
Say we don't like the ArgumentNullException
that is thrown when we call items.Any()
when items
is null, and instead we want to throw an ArgumentException
with our own custom message. To do this, we will have to add a condition to the method that checks if the list of items is null.
Here is a flow diagram illustrating the effect of introducing this condition:
Now instead of making it to the end of the method, there is a possibility of exiting earlier if items is null, thereby creating a new path of execution.
Here is what that would look like if it was implemented:
public static bool ContainsNamelessItems(List<Item> items)
{
if (items == null)
throw new ArgumentException("The collection of items should not be null.");
return items.Any(item => item.Name.IsNullOrEmpty())
}
And a corresponding test for this test cased would look something like this:
public void WhenItemCollectionIsNull_ThrowArgumentException()
{
List<Item> items = null;
Action act = () => items.ContainsNamelessItems();
act.Should().Throw<ArgumentException>()
.WithMessage("The collection of items should not be null.");
}
Something to note about the introduction of this new conditional statement is that the test that verified that an ArgumentNullException
was thrown given a null collection will now fail since we handle null items in the new if block instead of when we call .Any()
.
public static bool ContainsNamelessItems(List<Item> items)
{
...
// we no longer arrive at this line with a null collection
return items.Any(item => item.Name.IsNullOrEmpty())
}
Wrap up...
By using both the Parameter and Path-based approaches together, you should be able to come up with a good number of test cases that will help you verify that your methods are functioning as expected. There will definitely be more cases that you should cover that are more application-specific, but these approaches will give you good starting point for testing the individual units of your application.
Do you have any other ways you come up with test cases for your unit tests? I'd love to know, so be sure to leave a comment below.
Thanks for reading, now go build some stuff!