Introduction to Unit Testing

Introduction to Unit Testing with XUnit

Updated: 03 September 2023

Part 1

Unit testing is about testing code to do what it is expected to do

We try to test a specific piece of code without testing everything connected to it, we may have to mock specific elements such as when a test would require database or API call

In test-driven development we start off by defining the test and thereafter work out our actual code

Installing Prereqs

Create a new Console App and add the following packages from Nuget:

  • xunit
  • xunit.runner.visualstudio

You will also need to enter the following into your .csproj file so that the tests can run, or if you get the CS0017 Program has more than one entry point defined. Compile with /main to specify the type that contains the entry point. error:

1
<PropertyGroup>
2
...
3
<GenerateProgramFile>false</GenerateProgramFile>
4
</PropertyGroup>

Writing a Test

When we are talking about unit tests we make use of the following three stages:

  1. Arrange - Set up the necessary parts for out test
  2. Act - Executing the code under tests, we try to only have one of these
  3. Assert - Make sure that what happened did happen

We can define a testing class named UnitTest1 that will test a Calculator class before defining the calculator’s implementation by defining a test to add two numbers, and having define a Calculator.Add method which will take in two numbers and add them

1
using Xunit;
2
3
namespace UnitTestingXUnit
4
{
5
public class Tests
6
{
7
[Fact]
8
public void Should_Add_Two_Numbers()
9
{
10
// Arrange
11
int num1 = 5;
12
int num2 = 10;
13
// system-under-test
14
var sut = new Calculator();
15
16
// Act
17
var result = sut.Add(num1, num2);
18
19
//Assert
20
Assert.Equal(15, result);
21
}
22
}
23
}

In the above code, a [Fact] is used for a normal test that does not take in any inputs

We can use Visual Studio to generate the Calculator class and placeholder Add methods, or create a Calculator.cs file with the following:

1
using System;
2
3
namespace UnitTestingXUnit
4
{
5
internal class Calculator
6
{
7
public Calculator()
8
{
9
}
10
11
public int Add(int num1, int num2)
12
{
13
throw new NotImplementedException();
14
}
15
}
16
}

At this point the test will trow a NotImplementedException if it is run, our next step is to write the minimum necessary code to make the test pass, that would be the following:

1
public int Add(int num1, int num2)
2
{
3
return 15;
4
}

That method would result in a test that passes, at this point we have not really done anything practical but we have flushed out the basics of this API, this is just to ensure that we have the API defined correctly, this is essentially a spec for our code

In this specific case the implementation would be obvious, however it may not always be an obvious operation

Realistically the implementation of this function would be:

1
public int Add(int num1, int num2)
2
{
3
return num1 + num2;
4
}

We can do the same thing for a division test

1
[Fact]
2
public void Should_Divide_Two_Numbers()
3
{
4
// Arrange
5
int num = 5;
6
int divisor = 10;
7
// system-under-test
8
var sut = new Calculator();
9
10
// Act
11
var result = sut.Divide(num, divisor);
12
13
//Assert
14
Assert.Equal(0.5, result);
15
}

And then the division code with:

1
public int Divide(int num, int divisor)
2
{
3
return num / divisor;
4
}

You can then view the Test Explorer from the Top Menu Tests > Test Explorer

In the Test Explorer you can run the tests by right clicking on the Tests group

If when trying to run the Tests for a Console App you get a “error, you will need to add the following to your<PropertyGroup>section on your.csproj file

1
<GenerateProgramFile>false</GenerateProgramFile>

Thereafter, you should be able to run the tests, you will notice that the Should_Divide_Two_Numbers test will fail, clicking on that test will show you the following output:

1
Source: Tests.cs line: 24
2
Duration: 11 ms
3
4
Message:
5
Assert.Equal() Failure
6
Expected: 0.5
7
Actual: 0
8
Stack Trace:
9
at Tests.Should_Divide_Two_Numbers() in Tests.cs line: 36

You can also run the test with the dotnet test command from the application directory, which will yield something like the following output

1
[xUnit.net 00:00:02.56] UnitTestingXUnit.Tests.Should_Divide_Two_Numbers [FAIL]
2
Failed UnitTestingXUnit.Tests.Should_Divide_Two_Numbers
3
Error Message:
4
Assert.Equal() Failure
5
Expected: 0.5
6
Actual: 0
7
Stack Trace:
8
at UnitTestingXUnit.Tests.Should_Divide_Two_Numbers() in C:\Repos\UnitTestingXUnit\Tests.cs:line 36
9
10
Total tests: 2. Passed: 1. Failed: 1. Skipped: 0.
11
Test Run Failed.
12
Test execution time: 3.9877 Seconds

From this we can see that the test yielded an actual output of 0 but expected 0.5, this is because the function returns an int and makes use of integer division

If we instead coerce the divisor to a double and change our function to return that, like below, the tests will pass

1
public double Divide(int num, int divisor)
2
{
3
return num / (double) divisor;
4
}

A [Theory] is a test which takes in different params and will allow us to parametrize a test, such as the following Add test with the [InlineData] set:

1
[Theory]
2
[InlineData(5, 10, 15)]
3
public void Should_Add_Two_Numbers(int num1, int num2, int expected)
4
{
5
// system-under-test
6
var sut = new Calculator();
7
8
// Act
9
var result = sut.Add(num1, num2);
10
11
//Assert
12
Assert.Equal(expected, result);
13
}

We can make use of different possible input combinations and this can drive the way we develop the API, such as using an input set of (null, 10, 15) which means we need to update the API to do something like make use of nullable ints

We can also handle the case where we want our code to throw an exception such as when an argument is null and we can test for this by combining the act and assert portions

1
[Theory]
2
[InlineData(null, 10)]
3
[InlineData(10, null)]
4
[InlineData(null, null)]
5
public void Should_Not_Add_Nulls(int? num1, int? num2)
6
{
7
// system-under-test
8
var sut = new Calculator();
9
10
//Assert
11
Assert.Throws<ArgumentNullException>(() => sut.Add(num1, num2));
12
}

Which is a new case that we will need to handle, which we can do as follows:

1
public int Add(int? num1, int? num2)
2
{
3
if (!num1.HasValue || !num2.HasValue)
4
{
5
throw new ArgumentNullException();
6
}
7
8
return num1.Value + num2.Value;
9
}

Summary

When doing TDD with Xunit we usually define our test cases and scenarios and then go about writing the code that will satisfy those tests. We can use tests which have no params labelled with the [Facts] annotation, and [Theory] which allows us to provide parameters such as [InlineData(1,5,6)]

Additionally tests can be run using the Visual Studio Test Runner or the dotnet test command

The code that defines our tests, and satisfies them is in the Tests.cs and Calculator.cs files respectively:

Tests.cs

1
using System;
2
using Xunit;
3
4
namespace UnitTestingXUnit
5
{
6
public class Tests
7
{
8
[Theory]
9
[InlineData(5, 10, 15)]
10
public void Should_Add_Two_Numbers(int num1, int num2, int expected)
11
{
12
// system-under-test
13
var sut = new Calculator();
14
15
// Act
16
var result = sut.Add(num1, num2);
17
18
//Assert
19
Assert.Equal(expected, result);
20
}
21
22
[Theory]
23
[InlineData(null, 10)]
24
public void Should_Not_Add_Nulls(int? num1, int? num2)
25
{
26
// system-under-test
27
var sut = new Calculator();
28
29
//Assert
30
Assert.Throws<ArgumentNullException>(() => sut.Add(num1, num2));
31
}
32
33
[Fact]
34
public void Should_Divide_Two_Numbers()
35
{
36
// Arrange
37
int num = 5;
38
int divisor = 10;
39
// system-under-test
40
var sut = new Calculator();
41
42
// Act
43
var result = sut.Divide(num, divisor);
44
45
//Assert
46
Assert.Equal(0.5, result);
47
}
48
}
49
}

Calculator.cs

1
using System;
2
3
namespace UnitTestingXUnit
4
{
5
public class Calculator
6
{
7
public Calculator(){ }
8
9
public int Add(int? num1, int? num2)
10
{
11
if (!num1.HasValue || !num2.HasValue)
12
{
13
throw new ArgumentNullException();
14
}
15
16
return num1.Value + num2.Value;
17
}
18
19
public double Divide(int num, int divisor)
20
{
21
return num / (double) divisor;
22
}
23
}
24
}

Attributes

  • [Fact] - A Test with no inputs, can have additional information such as a name and whether or not it should be skipped with: [Fact(DisplayName = "I am a Test", Skip = "I should be skipped")]. Skip will cause a specific test to be ignored
  • [Theory - A Test with some params, defined by [InlineData(1,2,3)]
  • [MemberData(nameof(TestData))] - Allows you to define a method which will map the relevant values to the test params, kind of like InlineData but will get the data in a more dynamic way
  • [ClassData] - Works like above but will deliver an IEnumerable of input items such as above

By default xUnit runs tests in Parallel. All tests in a single class run in Series but accross classes run in Parallel

Additionally you can create a custom collection of tests to run in series with the [Collection("MySeriesStuff")], all classes with the MySeriesStuff collection will be run in series

Testing for Exceptions

When testing for exceptions we can do this using the Arange, Act, Assert method with a class property such as _customMessage and then testing if the exception matches that

1
Exception ex = Record.Exception(() => ThrowAnError())
2
Assert.Equal(_customMessage, ex.Message)

Setup and Teardown

We can make use of a Constructor and Dispose pattern which will be used before and after each test

1
public class MyTests : IDisposable
2
{
3
public MyTests()
4
{
5
// General Setup Stuff
6
}
7
8
public void Dispose()
9
{
10
// General Teardown stuff
11
}
12
}

We can also create a class fixture which will run before and after the entire series is done

Collections

In a test you can use the ITestOutputHelper which will write to any standard outputs

1
private readonly ITestOutputHelper _output;
2
3
public MyTests(ITestOutputHelper output)
4
{
5
_output = output;
6
}
7
8
[Fact]
9
public void MyTest()
10
{
11
_output.WriteLine("Hello");
12
}