It’s surprisingly easy to write and maintain unit tests. All you have to do is the following:
- Use a dependency injection
- Separate functions executor from function holder classes
- Functions in functions holder class should not have dependencies among them
1. Use a dependency injection
In a few words a dependency injection is a passing object to another object. For example:
class Receiver(IDependcyClass dependencyClass){
}
It makes unit tests much easier as using this design pattern makes it possible to inject mocked behavior into the class under the test. If you want to understand dependency injection better, I recommend that you read James Shore’s post http://www.jamesshore.com/v2/blog/2006/dependency-injection-demystified
2. Separate functions executor from function holder classes
Inject the functions into the class that executes the functions.
class A(){
private readonly IB _b;
public A(IB b){
_b = b;
}
void ExecuteTheFlow(){
_b.A();
_b.B();
_b.C();
}
}
3. Functions in a functions holder class should not have dependencies among them.
void Func1(){
var list = GetList();
}
Should be
void Func1(List<..> list){
}
The following examples explain how to design code for the unit to be more easily testable.
All the examples are created with the C# language and the XUnit testing framework.
Here is the class we want to test. Interface IDependency was injected as an example of a dependency injection.
public class ClassToTest
{
private readonly IDependency _dependency;
public ClassToTest(IDependency dependency)
{
_dependency = dependency;
}
public int? GetResult(int a)
{
var result = _dependency.ReturnValue(a);
var subtracted = Subtract(result);
if (subtracted < 2)
{
_dependency.SomeFunctionA();
}
else
{
_dependency.SomeFunctionB();
}
return subtracted;
}
public int? Subtract(int? value)
{
if (value < 4)
{
return value - 1;
}
return null;
}
}
We want to ensure that if the function GetResult received a value lower than 4, then 1 subtracted from the value else returned a null. And if the value is lower than 2 after subtraction, call the function A. Otherwise call the function B.
Here are the tests that ensure it:
public class TestClassTests
{
[Fact]
public void Sutructed_1_IfTheValueIsLowerThan4()
{
var value = 1;
var expected = 0;
var dependencyA = new Mock<IDependency>();
dependencyA.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(value);
var target = new ClassToTest(dependencyA.Object);
var result = target.GetResult(value);
Assert.Equal(expected, result);
}
[Theory]
[InlineData(4)]
[InlineData(5)]
public void Returned_Null_IfTheValueIsGreaterOrEqualTo4(int value)
{
int? expected = null;
var dependencyA = new Mock<IDependency>();
dependencyA.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(value);
var target = new ClassToTest(dependencyA.Object);
var result = target.GetResult(value);
Assert.Equal(expected, result);
}
[Fact]
public void EnsureTheFlow_SomeFunctionA_CalledIfReturnedValueLowerThan2()
{
var dependency = new Mock<IDependency>();
dependency.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(2);
var target = new ClassToTest(dependency.Object);
var result = target.GetResult(It.IsAny<int>());
dependency.Verify(x => x.SomeFunctionA(), Times.Once());
}
[Theory]
[InlineData(3)]
[InlineData(4)]
public void EnsureTheFlow_SomeFunctionB_CalledIfSubtractedValueGreaterOrEqualTo2(int value)
{
var dependency = new Mock<IDependency>();
dependency.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(value);
var target = new ClassToTest(dependency.Object);
var result = target.GetResult(It.IsAny<int>());
dependency.Verify(x => x.SomeFunctionB(), Times.Once());
}
}
The tests will work so what’s the problem with the tests in the example?
We want to achieve 2 goals — unit tests that are easier to 1. write and 2. maintain. The problem in the code design above is that if somebody refactors and by mistake replace if (value < 4) with if (value > 4) in the function Subtract some of the tests will fail without clear indication of where the problem is. At that moment the developer will start to debug the code in an attempt to find the reason for the unit tests failing. Remember the 60 / 60 rule: 60% of the life cycle costs of software systems come from maintenance (average). Here we start to mess with rules as we create code that is more difficult to maintain, so the cost of maintenance will increase proportionally to the nerves of the developer on finding the problem and why the tests fail.
Code is usually, much more complicated and functions call to functions that call to functions… One regression can fail tens of tests and not one of them will clearly point to the source of the problem.
Let’s improve this. Look at the example of the class to test below.
public class ClassToTest
{
private readonly IDependency _dependency;
public ClassToTest(IDependency dependency)
{
_dependency = dependency;
}
public int? GetResult(int a)
{
var result = _dependency.ReturnValue(a);
var subtracted = _dependency.Subtract(result);
if(subtracted < 2)
{
_dependency.SomeFunctionA();
}
else
{
_dependency.SomeFunctionB();
}
return subtracted;
}
}
Here are the tests
public class TestClassTestsRight
{
[Fact]
public void Sutructed_1_IfTheValueIsLowerThan4()
{
var expected = 0;
var value = 1;
var target = new Dependency();
var actual = target.Subtract(value);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData(4)]
[InlineData(5)]
public void Returned_Null_IfTheValueIsGreaterOrEqualTo4(int value)
{
var target = new Dependency();
var actual = target.Subtract(value);
Assert.Null(actual);
}
[Fact]
public void EnsureTheFlow_SomeFunctionA_CalledIfReturnedValueLowerThan2()
{
var dependency = new Mock<IDependency>();
dependency.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(1);
dependency.Setup(x => x.Subtract(It.IsAny<int>())).Returns(1);
var target = new ClassToTest(dependency.Object);
var result = target.GetResult(It.IsAny<int>());
dependency.Verify(x => x.SomeFunctionA(), Times.Once());
}
[Theory]
[InlineData(2)]
[InlineData(3)]
public void EnsureTheFlow_SomeFunctionB_CalledIfReturnedValueGreaterOrEqualTo2(int value)
{
var dependency = new Mock<IDependency>();
dependency.Setup(x => x.ReturnValue(It.IsAny<int>())).Returns(value);
dependency.Setup(x => x.Subtract(It.IsAny<int>())).Returns(value);
var target = new ClassToTest(dependency.Object);
var result = target.GetResult(It.IsAny<int>());
dependency.Verify(x => x.SomeFunctionB(), Times.Once());
}
}
The function Subtract moved to class Dependency and tested separately. The flow in the GetResult function tested separately too. It means that if somebody replaces if (value < 4) with if (value > 4) tests related to the particular function will fail, but the flow test will succeed as the flow has no regression. The developer can see where is the problem and why things fail. If something is changed in the GetResult function, only tests related to that function will fail and point to issues with the flow.
You can download the examples from https://github.com/genichm/unit-tests-example. They were created with .NET Core 3.1 and use the XUnit testing framework.
Top comments (0)