Introduction to Unit Testing
Introduction to Unit Testing with XUnit
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:
<PropertyGroup>
...
<GenerateProgramFile>false</GenerateProgramFile>
</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
using Xunit;
namespace UnitTestingXUnit
{
public class Tests
{
[Fact]
public void Should_Add_Two_Numbers()
{
// Arrange
int num1 = 5;
int num2 = 10;
// system-under-test
var sut = new Calculator();
// Act
var result = sut.Add(num1, num2);
//Assert
Assert.Equal(15, result);
}
}
}
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:
using System;
namespace UnitTestingXUnit
{
internal class Calculator
{
public Calculator()
{
}
public int Add(int num1, int num2)
{
throw new NotImplementedException();
}
}
}
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:
public int Add(int num1, int num2)
{
return 15;
}
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:
public int Add(int num1, int num2)
{
return num1 + num2;
}
We can do the same thing for a division test
[Fact]
public void Should_Divide_Two_Numbers()
{
// Arrange
int num = 5;
int divisor = 10;
// system-under-test
var sut = new Calculator();
// Act
var result = sut.Divide(num, divisor);
//Assert
Assert.Equal(0.5, result);
}
And then the division code with:
public int Divide(int num, int divisor)
{
return num / divisor;
}
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
<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:
Source: Tests.cs line: 24
Duration: 11 ms
Message:
Assert.Equal() Failure
Expected: 0.5
Actual: 0
Stack Trace:
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
[xUnit.net 00:00:02.56] UnitTestingXUnit.Tests.Should_Divide_Two_Numbers [FAIL]
Failed UnitTestingXUnit.Tests.Should_Divide_Two_Numbers
Error Message:
Assert.Equal() Failure
Expected: 0.5
Actual: 0
Stack Trace:
at UnitTestingXUnit.Tests.Should_Divide_Two_Numbers() in C:\Repos\UnitTestingXUnit\Tests.cs:line 36
Total tests: 2. Passed: 1. Failed: 1. Skipped: 0.
Test Run Failed.
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
public double Divide(int num, int divisor)
{
return num / (double) divisor;
}
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:
[Theory]
[InlineData(5, 10, 15)]
public void Should_Add_Two_Numbers(int num1, int num2, int expected)
{
// system-under-test
var sut = new Calculator();
// Act
var result = sut.Add(num1, num2);
//Assert
Assert.Equal(expected, result);
}
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
[Theory]
[InlineData(null, 10)]
[InlineData(10, null)]
[InlineData(null, null)]
public void Should_Not_Add_Nulls(int? num1, int? num2)
{
// system-under-test
var sut = new Calculator();
//Assert
Assert.Throws<ArgumentNullException>(() => sut.Add(num1, num2));
}
Which is a new case that we will need to handle, which we can do as follows:
public int Add(int? num1, int? num2)
{
if (!num1.HasValue || !num2.HasValue)
{
throw new ArgumentNullException();
}
return num1.Value + num2.Value;
}
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
using System;
using Xunit;
namespace UnitTestingXUnit
{
public class Tests
{
[Theory]
[InlineData(5, 10, 15)]
public void Should_Add_Two_Numbers(int num1, int num2, int expected)
{
// system-under-test
var sut = new Calculator();
// Act
var result = sut.Add(num1, num2);
//Assert
Assert.Equal(expected, result);
}
[Theory]
[InlineData(null, 10)]
public void Should_Not_Add_Nulls(int? num1, int? num2)
{
// system-under-test
var sut = new Calculator();
//Assert
Assert.Throws<ArgumentNullException>(() => sut.Add(num1, num2));
}
[Fact]
public void Should_Divide_Two_Numbers()
{
// Arrange
int num = 5;
int divisor = 10;
// system-under-test
var sut = new Calculator();
// Act
var result = sut.Divide(num, divisor);
//Assert
Assert.Equal(0.5, result);
}
}
}
Calculator.cs
using System;
namespace UnitTestingXUnit
{
public class Calculator
{
public Calculator(){ }
public int Add(int? num1, int? num2)
{
if (!num1.HasValue || !num2.HasValue)
{
throw new ArgumentNullException();
}
return num1.Value + num2.Value;
}
public double Divide(int num, int divisor)
{
return num / (double) divisor;
}
}
}
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
Exception ex = Record.Exception(() => ThrowAnError())
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
public class MyTests : IDisposable
{
public MyTests()
{
// General Setup Stuff
}
public void Dispose()
{
// General Teardown stuff
}
}
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
private readonly ITestOutputHelper _output;
public MyTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void MyTest()
{
_output.WriteLine("Hello");
}