Introduction
Since I started my career in software engineering, there are a myriad of skills I had to learn aside from 'writing code'.
I am primarily using C# at my work, and object-oriented coding was a foreign language for me, being that I have only done numerical calculations using other programming languages. At the time, there was no need for me to really think about classes and their relationship to other classes.
Difficulty at work
After nailing down some basic coding, it occured to me that I did not want to maintain what I have been writing due to the fact that the software was too messy and loose coupling was not achieved.
My company does not enforce code review or writing unittests, therefore the quality of the code is up to me.
This motivated me to get serious about researching on software development and increasing its quality.
The code I have been writing was in need of some serious refactoring, and I decided to start with writing unittests.
Meeting unittesting
Because my company does not enforce writing automated tests, the importance of it was lost on me. However, once I developed the habit of writing test code I could see the power of writing them, particularly when it comes to refactoring.
In this post, I will talk about how was able to grow accustomed to writing unittests. I was struggling greatly with actually applying them because I did not know that my code was not exactly testable.
There is plenty of information about unittesting online and how to do it right. So, I will simply talk about my personal experience.
One of the most basic examples provided online
The easiest unittest might be a method performing summation.
public class Arithmetic
{
public int Summation(int a,int b)
{
return a + b;
}
}
We can write a unittest for this code in NUnit in the following way.
public class Tests
{
private Arithmetic arithmetic;
[SetUp]
public void Setup()
{
arithmetic = new Arithmetic();
}
[Test]
public void SummationTest()
{
//Arrange
int a = 1;
int b = 1;
//Act
int result = arithmetic.Summation(a,b);
//Assert
Assert.AreEqual(2,result);
}
}
The code above passed the test. We can obviously write tests if the production code is simple enough like the summation example above. We have checked that 1+1 returns 2.
Different example
Consider an example of getting relative distance between a house and a cat. First, prepare a cat class and he has a house. The house instance will be generated within the cat class, so I will call it a coupled cat class.
public class House
{
public int Location { get; set; }
}
public class CoupledCat
{
private House house;
public int Location { get; set; }
public CoupledCat()
{
house = new House();
house.Location = 1;
}
public int RelativeDistance()
{
return Math.Abs(house.Location - this.Location);
}
}
We can run the code.
static void Main(string[] args)
{
//Prepare a cat with his house information
CoupledCat cat = new CoupledCat();
cat.Location = 3;
//get distance
int rel = cat.RelativeDistance();
Console.WriteLine("you need to walk "+rel+" meter(s)");
}
Test for coupled cat class
We can write a test for the CoupledCat class.
public class CoupledCatTest
{
private CoupledCat cat;
[SetUp]
public void Setup()
{
cat = new CoupledCat();
}
[Test]
public void DistanceTest()
{
//Arrange
cat.Location = 3;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(2,result);
}
}
We can write a test for this class, however it is inconvenient since the house location is fixed in the coupled cat class and we would like to move the house location.
Dependency injection
I would like to have some freedom in terms of house location. In order to resolve this dependency, we can introduce dependency injection. There are three ways to perform it, 1.Property Injection, 2.Constructor Injection, and 3.Method Injection.
1.Property Injection
The cat class has a House property. This injection is probably not so great since the code would not run if we forget to inject the house instance to the cat class.
public class Cat
{
public House House { get; set; }
public int Location { get; set; }
public int RelativeDistance()
{
return Math.Abs(House.Location - this.Location);
}
}
public class House
{
public int Location { get; set; }
}
Let's actually run the code. Before we run the code, the house instance needs to be injected to the cat's house property.
class Program
{
static void Main(string[] args)
{
//Prepare a house
House house = new House();
house.Location = 1;
//Prepare a cat with his house information
Cat cat = new Cat();
cat.House = house;
cat.Location = 3;
//get distance
int rel = cat.RelativeDistance();
Console.WriteLine("you need to walk "+rel+" meter(s)");
}
}
2.Constructor Injection
We can change the injection location from property to the constructor. The house instance needs to be prepared before creating the cat clas.
public class Cat
{
private House house;
public int Location { get; set; }
public Cat(House house)
{
this.house = house;
}
public int RelativeDistance()
{
return Math.Abs(house.Location - this.Location);
}
}
3.Method Injection
Since we need the house class only in the RelativeDistance method, so we can inject the instance in the method.
public class Cat
{
public int Location { get; set; }
public int RelativeDistance(House house)
{
return Math.Abs(house.Location - this.Location);
}
}
Writing a test for this house-cat example
I will write the test for property injection based cat class.
public class CatHouseTest
{
private House house;
private Cat cat;
[SetUp]
public void Setup()
{
house = new House();
cat = new Cat();
cat.House = house;
}
[Test]
public void DistanceTest()
{
//Arrange
house.Location = 1;
cat.Location = 3;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(2,result);
}
}
Adding flexibility to the cat class
Though we introduced dependency injection, they are still not so flexible because the cat only knows the distance between his current location and his house. They would love to wander around the city and eat some fish. Let him have some fish!
Introducing an interface
Instead of a house we can implement a building having location information. By introducing an interface, we can look at all the classes with Location property in the same way.
public interface IBuilding
{
int Location { get; set; }
}
The house class can implement IBuilding since the house has the location property.
public class House:IBuilding
{
public int Location { get; set; }
}
Let's go back to the cat class. We can replace the House property in the cat class to the building interface.
public class Cat
{
public IBuilding Building { get; set; }
public int Location { get; set; }
public int RelativeDistance()
{
return Math.Abs(Building.Location - this.Location);
}
}
We can run the code and there is no big difference between the previous code and the new code other than replacing cat.House.
class Program
{
static void Main(string[] args)
{
//Prepare a house
House house = new House();
house.Location = 1;
//Prepare a cat with his house information
Cat cat = new Cat();
cat.Building = house;
cat.Location = 3;
//get distance
int rel = cat.RelativeDistance();
Console.WriteLine("you need to walk "+rel+" meter(s)");
}
}
The great thing about interface is that we can introduce anything that implements the interface. Let him finally introduce a fish market.
public class FishMarket:IBuilding
{
public int Location { get; set; }
}
class Program
{
static void Main(string[] args)
{
//Prepare a house
House house = new House();
house.Location = 1;
//Prepare a fishmarket
FishMarket fishMarket = new FishMarket();
fishMarket.Location = 5;
//Prepare a cat with his house information
Cat cat = new Cat();
cat.Location = 2;
cat.Building = house;
//get distance
int relToHouse = cat.RelativeDistance();
Console.WriteLine("you need to walk "+relToHouse+" meter(s)");
//Set fishmarket
cat.Building = fishMarket;
//get distance
int relToFishMarket = cat.RelativeDistance();
Console.WriteLine("you need to walk "+relToFishMarket+" meter(s)");
}
}
Writing several tests
At this point, we could add some more tests and we can use TestCase attribute to perform several cases in a single method.
Let's write tests for both Cat to House distance and Cat to Fishmarket distance.
In the cat to fishmarket example, three test cases are provided. It is important to consider edge cases if necessary.
public class CatHouseTest
{
private House house;
private FishMarket fishMarket;
private Cat cat;
[SetUp]
public void Setup()
{
house = new House();
fishMarket = new FishMarket();
cat = new Cat();
}
[Test]
public void DistanceToHouseTest()
{
//Arrange
house.Location = 1;
cat.Building = house;
cat.Location = 3;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(2,result);
}
[TestCase(0,1,1)]
[TestCase(8,2,10)]
[TestCase(4,-1,3)]
public void DistanceToFishMarketTest(int expected,int catLocation,int fishMarketLocation)
{
//Arrange
fishMarket.Location = fishMarketLocation;
cat.Location = catLocation;
cat.Building = fishMarket;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(expected,result);
}
}
The result is all green, so we are very happy about the result!
Using external resource
If we need to use some data outside of the current project, our test would fail and we need to modify the test.
We can prepare the text file in the C drive and we would like to read some data from the file. The file only has a single number in the text file in this example.
public class Cat
{
public IBuilding Building { get; set; }
public int Distance { get; set; }
public int LoadLocationFromText()
{
string text = System.IO.File.ReadAllText(path+location.txt);
return Convert.ToInt32(text);
}
public int RelativeDistance()
{
int location = LoadLocationFromText();
return Math.Abs(Building.Location - location);
}
}
The test passes only when the text file exists in the computer. This means that we cannot run the test if we use someone else's computer.
Introduce a testclass
we are interested in testing the RelativeDistance method and LoadLocationFromText method depends on the text file in my computer. It means that if the test is performed on someone else's computer, the test would not work unless the text file and its path are provided. It is better if we can run the test without preparing external resource.
If we are allowed to make some modification to the class methods, we can rewrite the LoadLocationFromText method using virtual keyword. If a method is virtual then we can override the method in the test. This allows us to safely ignore the external dependency part.
Let's create another class that inherits the cat class. The LoadLocationFromText has override keyword, so we can return arbitrary number instead of reading the text file.
public class CatTest : Cat
{
private int input;
public CatTest(int input)
{
this.input = input;
}
public override int LoadLocationFromText()
{
return this.input;
}
}
public class CatHouseTest
{
private House house;
private FishMarket fishMarket;
private CatTest cat;
[SetUp]
public void Setup()
{
house = new House();
fishMarket = new FishMarket();
}
[Test]
public void DistanceToHouseTest()
{
//Arrange
cat = new CatTest(3);
house.Location = 1;
cat.Building = house;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(2,result);
}
[TestCase(0,1,1)]
[TestCase(8,2,10)]
[TestCase(4,-1,3)]
public void DistanceToFishMarketTest(int expected,int catLocation,int fishMarketLocation)
{
//Arrange
cat = new CatTest(catLocation);
fishMarket.Location = fishMarketLocation;
cat.Building = fishMarket;
//Act
int result = cat.RelativeDistance();
//Assert
Assert.AreEqual(expected,result);
}
}
Conclusion
The main goal for me was developing the habit of unittesting.
However, the biggest hurdle was understanding how to write a test. I started writing tests and performed refactoring in the following manner.
- Find the smallest class in the project. It is important to find a class with least amount of dependencies.
- Write one easy test for the class.
- If there is a huge class, break it down in smaller pieces of classes.
- Write tests for those small classes.
- Find a class with external dependency such as loading files, database connection and so on.
- If they can be modified then make the method virtual and override the method by creating a class for test purpose.
- Since the project started to have some tests, it should be easier to modify code without worrying too much about producing bugs.
- Introduce interfaces here and there and also apply design patterns if there is a suitable one. At this point the code should look way better than before and I am not afraid of writing tests for my code. The next step for me would be properly preparing a mock in my test.
Top comments (0)