Home Parameterized Unit Tests in C#
Post
Cancel

Parameterized Unit Tests in C#

During one of my classes at university, we covered how to write quality unit tests. I noticed early on that some of the tests were very similar to each other and I often found myself unnecessarily repeating code or writing tests that were testing for the same output, but with different inputs. I was not satisfied with this and eagerly searched for a method to abstract away the repeated code and make my tests more readable.

I found a solution to this problem, and the term for it is “parameterized tests.” This form of testing allows the programmer to write a test that can accept multiple inputs. In this blog, I will be covering how one can write parameterized tests in C#. In a future blog I may cover this concept using Python. I find this tool to be very useful and important to know about when writing unit tests.

C# Parameterized Tests

In my example, I will be creating a simple expression validator that will check if a string is a valid mathematical expression. The implementation is not super important, and I am only using it to demonstrate how to write parameterized tests.

Set Up the Project

To start, I will create a new C# project using dotnet CLI. I am using dotnet 6.

1
2
3
4
5
6
7
8
9
dotnet new sln -n ExpressionValidator
dotnet new classlib -n ExpressionValidator
dotnet new nunit -n ExpressionValidator.Test

dotnet sln add ExpressionValidator
dotnet sln add ExpressionValidator.Test
dotnet add ExpressionValidator.Test reference ExpressionValidator

dotnet build

Create the Expression Validator

Great! Now that we have a project set up, I am going to create two ExpressionValidator classes. One will be for math formula, and the other will be for confirming an expression is a valid title. The code here is not important, we are only using it to demonstrate how to write parameterized tests.

Here is the structure:

1
2
3
4
5
|- ExpressionValidator
|  |- TitleExpressionValidator.cs
|  |- IExpressionValidator.cs
|- ExpressionValidator.Test
|  |- TitleExpressionValidatorTest.cs

Since the validator is going to be testing if an expression is valid or not, I will create an interface to define the IsValid method.

1
2
3
4
5
6
7
8
// IExpressionValidator.cs

namespace ExpressionValidator;

public interface IExpressionValidator
{
    bool IsValid(in string expression);
}

Below is the implementation of the IExpressionValidator interface.

1
2
3
4
5
6
7
8
9
10
11
12
// TitleExpressionValidator.cs

namespace ExpressionValidator;

public class TitleExpressionValidator : IExpressionValidator
{
    public bool IsValid(in string expression)
    {
        List<string> words = new(expression.Split(' '));
        return words.All(word => word.Length > 0 && word[0] == char.ToUpper(word[0]));
    }
}

Create the Tests

Now let’s write some tests. I will start with with the TitleExpressionValidator tests. The current state of the unit tests are acceptable and they are passing. However, they do not promote extensibility. If I wanted to add more tests for valid scenarios, how would I do that with the current implementation?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// TitleExpressionValidatorTest.cs

namespace ExpressionValidator.Test;

[TestFixture]
public class TitleExpressionValidatorTest
{
    private IExpressionValidator ExpressionValidator { get; set; } = default!;

    [SetUp]
    public void SetUp()
    {
        this.ExpressionValidator = new TitleExpressionValidator();
    }

    [Test]
    public void IsValid_WhenExpressionIsValid_ReturnsTrue()
    {
      Assert.IsTrue(
        this.ExpressionValidator.IsValid("Hello, World!"),
        message: $"Expression: `Hello, World!` should be valid.");
    }

    [Test]
    public void IsValid_WhenExpressionIsNotValid_ReturnsFalse()
    {
        Assert.IsFalse(
          this.ExpressionValidator.IsValid("hello world"),
          message: $"Expression: `hello world` should not be valid.");
    }
}
1
2
3
4
5
❯ dotnet test
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     2, Skipped:     0, Total:     2, Duration: 3 ms

The ability to parameterize tests in C# is actually quite simple and very cool. There are two ways to do this. The first way is to use the TestCase attribute. The second way is to use the TestCaseSource attribute. I will be covering both ways in this blog. TestCase is a great way to parameterize tests when there are not many test cases. If there are a lot of test cases, or if the test cases can be shared between multiple test classes, then TestCaseSource is the way to go.

In the improvement below, we helped make the test cases more readable and modular. Now adding an additional test case, or removing a test case, is as simple as adding or removing a line of code.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Test]
[TestCase("Hello World")]
[TestCase("Hello, World!")]
[TestCase("Greetings, From Mars")]
public void IsValid_WhenExpressionIsValid_ReturnsTrue(in string expression)
{
    Assert.IsTrue(
        this.ExpressionValidator.IsValid(expression),
        message: $"Expression: {expression} should be valid.");
}

[Test]
[TestCase("hello world")]
[TestCase("hello, world!")]
[TestCase("Greetings, From mars")]
public void IsValid_WhenExpressionIsNotValid_ReturnsFalse(in string expression)
{
    Assert.IsFalse(
        this.ExpressionValidator.IsValid(expression),
        message: $"Expression: {expression} should not be valid.");
}
1
2
3
4
5
6
❯ dotnet test
  Determining projects to restore...
  Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 4 ms

For this example, stopping here seems perfectly fine. However, let’s continue to try and abstract away at the test cases. Let’s say the implementation of TitleExpressionValidator is much more complex and there are a lot of test scenarios to cover. It would be nice to group the test scenarios together if the list of test cases grows. This is where TestCaseSource comes in.

I found the cleanest way for me to store the test cases is to create a TestCaseSources class containing all of the test cases for the method under test. Once this is done, we can use the TestCaseSource attribute to reference the test cases.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// TestCaseSources/ExpressionValidatorTestCaseSources.cs

namespace ExpressionValidator.Test.TestCaseSources;

public static class ExpressionValidatorTestCaseSources
{
    public static IEnumerable<TestCaseData> IsValid_WhenExpressionIsValid_ReturnsTrue
    {
        get
        {
            yield return new TestCaseData("Hello World").Returns(true);
            yield return new TestCaseData("Hello, World!").Returns(true);
            yield return new TestCaseData("Greetings, From Mars").Returns(true);
        }
    }

    public static IEnumerable<TestCaseData> IsValid_WhenExpressionIsNotValid_ReturnsFalse
    {
        get
        {
            yield return new TestCaseData("hello world").Returns(false);
            yield return new TestCaseData("hello, world!").Returns(false);
            yield return new TestCaseData("Greetings, From mars").Returns(false);
        }
    }
}

Modified Test Cases:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[Test]
[TestCaseSource(
    typeof(ExpressionValidatorTestCaseSources),
    nameof(ExpressionValidatorTestCaseSources.IsValid_WhenExpressionIsValid_ReturnsTrue))]
public bool IsValid_WhenExpressionIsValid_ReturnsTrue(in string expression)
{
    return this.ExpressionValidator.IsValid(expression);
}

[Test]
[TestCaseSource(
    typeof(ExpressionValidatorTestCaseSources),
    nameof(ExpressionValidatorTestCaseSources.IsValid_WhenExpressionIsNotValid_ReturnsFalse))]
public bool IsValid_WhenExpressionIsNotValid_ReturnsFalse(in string expression)
{
    return this.ExpressionValidator.IsValid(expression);
}
1
2
3
4
5
6
❯ dotnet test
  Determining projects to restore...
  Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 8 ms

Optimal Solution

This new design of utilizing TestCaseSource is a very powerful feature in C#. Looking at the last implementation, we can combine the two test methods into one. Taking advantage of the TestCaseData class, we can declare the expected result of the test case.

Here is the final product:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// TestCaseSources/ExpressionValidatorTestCaseSources.cs

namespace ExpressionValidator.Test.TestCaseSources;

public static class TitleExpressionValidatorTestCaseSources
{
    public static IEnumerable<TestCaseData> IsValid
    {
        get
        {
            yield return new TestCaseData("Hello World").Returns(true);
            yield return new TestCaseData("Hello, World!").Returns(true);
            yield return new TestCaseData("Greetings, From Mars").Returns(true);

            yield return new TestCaseData("hello world").Returns(false);
            yield return new TestCaseData("hello, world!").Returns(false);
            yield return new TestCaseData("greetings, from mars").Returns(false);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// TitleExpressionValidatorTest.cs

using ExpressionValidator.Test.TestCaseSources;

namespace ExpressionValidator.Test;

[TestFixture]
public class TitleExpressionValidatorTest
{
    private IExpressionValidator ExpressionValidator { get; set; } = default!;

    [SetUp]
    public void SetUp()
    {
        this.ExpressionValidator = new TitleExpressionValidator();
    }

    [Test]
    [TestCaseSource(typeof(TitleExpressionValidatorTestCaseSources), nameof(TitleExpressionValidatorTestCaseSources.IsValid))]
    public bool IsValidTest(in string expression)
    {
        return this.ExpressionValidator.IsValid(expression);
    }
}
1
2
3
4
5
6
❯ dotnet test
  Determining projects to restore...
  Starting test execution, please wait...
A total of 1 test files matched the specified pattern.

Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6, Duration: 7 ms

And there we go. We have improved how we can write our test cases. Learning these new features of NUnit and how C# handles tests has been a great learning opportunity and I hope you enjoyed reading this as much as I enjoyed writing it.

This post is licensed under CC BY 4.0 by the author.