UNIT TESTING – TDD
TDD (Test driven development) – is an approach to software development in which the test is written first, followed by the production code undergoing that test. The cycle ends with refactoring. TDD is a discipline which means it is not something that comes naturally, because the benefits are not immediate, but they only emerge in the long run when we work out a good pattern of this approach in practice of programming.
Traditional TDD focuses on starting work with the smallest range of tests that meet the given requirements.
The smallest TDD cycle consists of three steps:
– Write a unit test that fails (Red).
– Write the simplest possible code to pass the test (Green).
– Refactoring for better code (It is the process of rebuilding existing code without changing its external behavior – Refactoring).
In the context of TDD, there are two principles: FIRST and CORRECT which encourage us to follow these steps when writing tests:
1. Fast — working fast.
2. Independent — independent of other tests.
3. Repeatable — repeatable (running multiple times will always return the same results).
4. Self-checking — stating whether the test passed or not (preferably fully automatic, e.g. after adding another branch, the tests are launched).
5. Timely — always written at the time of creating or changing a given functionality.
1. Conformance – compliance with both business requirements and compliance with the data format (e.g. expected data format in the case of an ID number).
2. Ordering – order of input as well as output (e.g. testing for list cases according to input / output order).
3. Range – control of the range of values for a given type of variables (e.g. for a maximum integer value) and use of a reasonable range of values for some variables (e.g. the age of a person does not exceed 200 years).
4. Reference – checking if the preconditions for the operation of the given functionality have been met (methodology: given / when / then).
5. Existence – checking the behavior of methods when we pass a null or zero value as a parameter, and when the values should not be set yet.
6. Cardinality – monitoring the content of the set’s elements after a specific operation has been performed on this set (e.g. data deletion).
7. Time – checking the order of calling methods and paying special attention to multithreading in the application code.
Referring to the graphical diagram of the TDD approach, we can see that we should try to allocate the same time to each stage of a given programming cycle(RED / GREEN / REFACTOR). Thanks to this, we will develop a better work pattern on the project, which will translate positively in the long run on the quality of the code we write and the reliability of our applications.
In the example below, I will try to illustrate what the work cycle with TDD may look like:
Let’s create a test class named StringCalculationsTest.
public class StringCalculationsTest {
}
Let’s write the first test method whose task will be to throw an exception in the case when the String with data will contain more than two numbers.
So we will need to create a real class for further work on which we will run tests – eg. StringCalculations. In this class, we will ultimately write a method that will add numbers sent in the String.
public class StringCalculations {
public void addNumbers(String numbers) {
}
}
Now let’s write the first test method to throw an exception when the String contains more than two numbers.
public class StringCalculationsTest {
@Test
public void shouldThrowExceptionWhenStringContainsMoreThan2Numbers() {
StringCalculations stringCalculations = new StringCalculations();
assertThrows(IllegalStateException.class, () -> stringCalculations.addNumbers("1,2,3"));
}
}
Upon execution, as expected, the test fails because addNumbers () is empty and nothing has happened yet. So we did the first part of the TDD cycle as we created a test method that fails the test (part of the RED cycle). In the next step, we will look at extending the addNumbers () method, but only until the test we wrote works. So let’s add some logic to the addNumbers() method:
public class StringCalculations {
public void addNumbers(String numbers) {
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new IllegalStateException("Only two numbers separated with comma (,) are allowed");
}
}
}
After launching, we get the test pass correctly and everything looks good from our test requirements point of view. So we did the second part of the TDD cycle as we modified the addNumbers () method to pass the test successfully (part of the GREEN cycle). In the next stage of the cycle, we should refactor our code. At the moment we have a minimal amount of code, so we can skip this step in general it is important and necessary to refactor what we wrote in this cycle (part of the REFACTORING cycle) as soon as we receive a positive test result. When we have finished refactoring, we can consider one TDD cycle complete, although the addNumbers() method is not yet complete as far as its targeted functionality, we managed to meet the requirements for the specific test “shouldThrowExceptionWhenStringContainsMoreThan2Numbers ()”.
Each subsequent TDD cycle will be similar. So let’s add another test called shouldThrowExceptionWhenNonNumberIsUsed() which will throw an exception when at least one element of String does not contain the number:
public class StringCalculationsTest {
@Test
public void shouldThrowExceptionWhenStringContainsMoreThan2Numbers() {
StringCalculations stringCalculations = new StringCalculations();
assertThrows(IllegalStateException.class, () -> stringCalculations.addNumbers("1,2,3"));
}
@Test
public void shouldThrowExceptionWhenNonNumberIsUsed() {
StringCalculations stringCalculations = new StringCalculations();
assertThrows(NumberFormatException.class, () -> stringCalculations.addNumbers("4,a"));
}
}
Then modify the addNumbers() method so that the newly written test passes:
public class StringCalculations {
public void addNumbers(String numbers) {
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new IllegalStateException();
} else {
for (String number : numbersArray) {
Integer.parseInt(number); // If it is not a number, parseInt() method will throw a NumberFormatException error
}
}
}
}
Now let’s do refactoring. We can see that in the new test method we create a stringCalculations object similar to what we did in the first test method. At the moment we can create a private object of the StringCalculations class that can be used by all test methods in the StringCalculationsTests class. Thanks to the @BeforeEach annotation, we can in a separate function (e.g. initializeStringCalculationsClassObject ()) place the very creation of this object, which will be available before running each test method in the StringCalculationsTests class:
private StringCalculations stringCalculations;
@BeforeEach
void initializeStringCalculationsClassObject(){
stringCalculations = new StringCalculations();
}
With this approach, we can remove the lines of code relating to the creation of the StringCalculations class object from our previous two tests. Let’s see what it will look like after the change:
public class StringCalculationsTest {
private StringCalculations stringCalculations;
@BeforeEach
void initializeStringCalculationsClassObject(){
stringCalculations = new StringCalculations();
}
@Test
public void shouldThrowExceptionWhenStringContainsMoreThan2Numbers() {
assertThrows(IllegalStateException.class, () -> stringCalculations.addNumbers("1,2,3"));
}
@Test
public void shouldThrowExceptionWhenNonNumberIsUsed() {
assertThrows(NumberFormatException.class, () -> stringCalculations.addNumbers("4,abc"));
}
}
In this way, we have completed the next TDD cycle. Let’s write another test in which we will verify the situation when the String is empty:
@Test
public void shouldReturnZeroIfStringIsEmpty() {
assertEquals(0, stringCalculations.addNumbers(""));
}
Next, we will modify the addNumbers() method to make the test pass. Since the addNumbers() method is a void type so far, we will need to make a change to get it to return an integer:
public int addNumbers(String numbers) {
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new IllegalStateException();
} else {
for (String number : numbersArray) {
if(!number.isEmpty()){
Integer.parseInt(number);
}
}
}
return 0;
}
The test works now, so the next step in the cycle has been completed. Now we’re going to do refactoring. In order for the addNumbers() function to always return some specific value, let’s describe it as integer result and finally return its value after calling the function.
public int addNumbers(String numbers) {
int result = 0;
String[] numbersArray = numbers.split(",");
if (numbersArray.length > 2) {
throw new IllegalStateException();
} else {
for (String number : numbersArray) {
if(!number.isEmpty()){
result += Integer.parseInt(number);
}
}
}
return result;
}
This way we closed another TDD cycle. Finally, let’s add a test that will verify if the addNumbers() method returns the expected result after sending two correct numbers:
@Test
public void whenTwoNumbersAreUsedThenReturnTheirSumValue() {
assertEquals(3+6, stringCalculations.addNumbers("3,6"));
}
Summary
We can see that the TDD approach forces us to think differently while programming. When we start working on new functionality from the test writing side, we look at the problem a little more from “what could go wrong?” point of view. Therefore we will consider a number of test cases to ensure that the new functionality works correctly and is stable under various circumstances.
Hope this article helps you understand what Test Driven Development is and will encourage you to try this approach while developing.