Introduction to Unit Testing
Introduction to Unit Testing with XUnit
Updated: 03 September 2023
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 theCS0017 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:
- Arrange - Set up the necessary parts for out test
- Act - Executing the code under tests, we try to only have one of these
- 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
1using Xunit;2
3namespace UnitTestingXUnit4{5 public class Tests6 {7 [Fact]8 public void Should_Add_Two_Numbers()9 {10 // Arrange11 int num1 = 5;12 int num2 = 10;13 // system-under-test14 var sut = new Calculator();15
16 // Act17 var result = sut.Add(num1, num2);18
19 //Assert20 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:
1using System;2
3namespace UnitTestingXUnit4{5 internal class Calculator6 {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:
1public 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:
1public int Add(int num1, int num2)2{3 return num1 + num2;4}
We can do the same thing for a division test
1[Fact]2public void Should_Divide_Two_Numbers()3{4 // Arrange5 int num = 5;6 int divisor = 10;7 // system-under-test8 var sut = new Calculator();9
10 // Act11 var result = sut.Divide(num, divisor);12
13 //Assert14 Assert.Equal(0.5, result);15}
And then the division code with:
1public 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: 242 Duration: 11 ms3
4 Message:5 Assert.Equal() Failure6 Expected: 0.57 Actual: 08 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]2Failed UnitTestingXUnit.Tests.Should_Divide_Two_Numbers3Error Message:4 Assert.Equal() Failure5Expected: 0.56Actual: 07Stack Trace:8 at UnitTestingXUnit.Tests.Should_Divide_Two_Numbers() in C:\Repos\UnitTestingXUnit\Tests.cs:line 369
10Total tests: 2. Passed: 1. Failed: 1. Skipped: 0.11Test Run Failed.12Test 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
1public 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)]3public void Should_Add_Two_Numbers(int num1, int num2, int expected)4{5 // system-under-test6 var sut = new Calculator();7
8 // Act9 var result = sut.Add(num1, num2);10
11 //Assert12 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)]5public void Should_Not_Add_Nulls(int? num1, int? num2)6{7 // system-under-test8 var sut = new Calculator();9
10 //Assert11 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:
1public 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
1using System;2using Xunit;3
4namespace UnitTestingXUnit5{6 public class Tests7 {8 [Theory]9 [InlineData(5, 10, 15)]10 public void Should_Add_Two_Numbers(int num1, int num2, int expected)11 {12 // system-under-test13 var sut = new Calculator();14
15 // Act16 var result = sut.Add(num1, num2);17
18 //Assert19 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-test27 var sut = new Calculator();28
29 //Assert30 Assert.Throws<ArgumentNullException>(() => sut.Add(num1, num2));31 }32
33 [Fact]34 public void Should_Divide_Two_Numbers()35 {36 // Arrange37 int num = 5;38 int divisor = 10;39 // system-under-test40 var sut = new Calculator();41
42 // Act43 var result = sut.Divide(num, divisor);44
45 //Assert46 Assert.Equal(0.5, result);47 }48 }49}
Calculator.cs
1using System;2
3namespace UnitTestingXUnit4{5 public class Calculator6 {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 anIEnumerable
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
1Exception ex = Record.Exception(() => ThrowAnError())2Assert.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
1public class MyTests : IDisposable2{3 public MyTests()4 {5 // General Setup Stuff6 }7
8 public void Dispose()9 {10 // General Teardown stuff11 }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
1private readonly ITestOutputHelper _output;2
3public MyTests(ITestOutputHelper output)4{5 _output = output;6}7
8[Fact]9public void MyTest()10{11 _output.WriteLine("Hello");12}