Table-driven tests in C#

e · l · n
Nov 2, 2019

Folks in the Go community have championed so called table-driven tests (see e.g. this post by Dave Cheney and the Go wiki) as a way to quickly and easily writing up a bunch of complete test cases with inputs and corresponding expected outputs, and looping over them to execute the function being tested. In short, the idea is to suggest a maximally short and convenient syntax to do this.

For example, given that we have a function like this in mylibrary.go:

package tabletest

// Concat concats strings
func Concat(a string, b string) string {
	return a + b
}

... then we could write a quite compact enumeration of testcases for testing this function like this (placing it in a file like mylibrary_test.go):

package tabletest

import (
	"testing"
)

// TestConcat tests the Concat function
func TestConcat(t *testing.T) {
	testCases := []struct {
		str1 string
		str2 string
		want string
	}{
		{str1: "a", str2: "b", want: "ab"},
		{str1: "b", str2: "c", want: "bc"},
		{str1: "c", str2: "d", want: "cd"},
	}
	for _, tt := range testCases {
		have := Concat(tt.str1, tt.str2)
		if have != tt.want {
			t.Fatalf("ERROR: wanted %s but have %s\n", tt.want, have)
		}
	}
}

What happens in the code above is that we are initializing an array of an anonymous struct type (we are not even giving it a name), while also initializing it with values. Then we loop over this array of structs and use the data to drive the testing. The anonymous struct initialization helps keep code short and succinct.

Anyways, given that in my current work at Savantic I have happened to write a lot of C# over the last year, I was interested in whether I could write such succinct enumerations of test cases in C# too. It turns out that with some new syntax introduced in C# 7, it is quite possible!

Below is an example of how to write such a table-dirven test in C# (using the xUnit test framework here, but should work with any test framework).

First, we assume that we have function something just like the Concat function above:

public string Concat(string s1, string s2)
{
    return s1 + s2;
}

... then, we could write a test function like so:

using System.Collections.Generic;
using Xunit;

[Fact] // <-- annotation required by xUnit
public void TestConcat()
{
    foreach (var TC in new List<(string str1, string str2, string want)> {
        (str1: "a", str2: "b", want: "ab"),
        (str1: "b", str2: "c", want: "bc"),
        (str1: "c", str2: "d", want: "cd"),
    })
    {
        Assert.Equal(TC.want, Concat(TC.str1, TC.str2));
    }
}

What happens here it that we initialize a new list of an anonymous tuple type (initialized using just parentheses), which we fill with values ... all inline in the set-up of the foreach-loop. Since we are able to combine the initialization with the foreach loop, this is actually in a way even more succinct than the corresponding code in Go!

If you are really lazy, you can actually skip the field names when filling in the "table":

using System.Collections.Generic;
using Xunit;

[Fact] // <-- annotation required by xUnit
public void TestConcat()
{
    foreach (var TC in new List<(string str1, string str2, string want)> {
        ("a", "b", "ab"),
        ("b", "c", "bc"),
        ("c", "d", "cd"),
    })
    {
        Assert.Equal(TC.want, Concat(TC.str1, TC.str2));
    }
}

Even less keystrokes. This is in fact not that far from editing plain CSV, right inside C#. Only that here the data is typesafe and compiler-checked in your editor as you type.

Now, I know frameworks for C# (NUnit, xUnit, MSTest etc) implement their own ways of enumerating test input and outputs, often via annotations on the test methods. Thus, this might not be the recommended way to write tests in everyone's books. Still, I tend to like this approach a lot. The fact that table-driven tests are normal Go/C# code means there is no extra syntax to learn, the behavior is easier to reason about, and definitely more portable between test frameworks.

The benefit of avoiding new syntax alone, has kept me writing tests in this style ever since I found out it is possible.

I have also found this technique very handy for casees when there are some smaller amounts of data (e.g. various kinds of default data) that needs to be initiated into an object structure and where you want an easy way to edit the actual data, and want to avoid repeating possibly more complex code for setting up the object structure.

Samuel @smllmp