Test Driven Development (TDD)

List of items:

  1. What is it and what is it for?
  2. Sample code…
  3. Stub, Spy and Mock
  4. Advantages and disadvantages of TDD
  5. Summary

TDD – is a software development technique where the emphasis is on writing test cases before writing the actual function/code part in our project. It combines building and testing. It guarantees high code quality and maximum test coverage of the largest possible part of the application, not only ensuring its correctness, but also indirectly developing the architecture of a given project.

It is built on three phases: Red –> Green –> Refactor

First, we write a test that fails (Red).
We enter the code that makes the test pass (Green).
If necessary (and usually it is) – we improve the code of the tested method and test (Refactor).
We repeat the whole process (returning to the Red phase) until we finish implementing the given piece of code.

For this way of testing applications, programmers use unit tests. It consists in separating a smaller part of it (unit) and testing it separately. It can be a single class or even a single method.

Unit test features:

  • should test only one functionality of our application,
  • should be isolated from the rest of our tests (act independently),
  • repeatability (stable results with each subsequent run).

Unit test structure:
Given – preparation of all input data,
When – running the tested method,
Then – verification of the obtained results.

Note – the use of the above structure is not a technical requirement, but only a good practice to improve the readability of our tests.

Unit tests can be written without external libraries, but it is cumbersome. In this article, I used the JUnit library (there are others, e.g. TestNG, csUnit and NUnit, Rspec).


Sample code:

public class Calculator {

	public int sum(int a, int b) {
		return a+b;
	}
}

It seems like a very simple method – just add. However, in order to test even such a short piece of code well, we must take into account many conditions.

Let’s start with the correct test – the so-called happy path. For our class ‘Calculator’ it could look like this:

class CalculatorTest {

    Calculator calculator = new Calculator();

    @Test
    void sumTest() {
        //given
        int a = 10;
        int b = 20;

        //when
        int result = calculator.sum(a, b);

        //then
        assertEquals(30, result);
    }
}

Then we will move on to more problematic cases, i.e. boundary conditions, e.g.:

– validation for passing incorrect arguments to the method (to the first argument, to the second and to both at the same time),
– what happens when a null value is passed to the method,
– what will happen if we pass very large values, exceeding the range of Integer type,
– etc. etc.

In order to be sure that our code will behave as we expect in certain cases, we need to set these conditions, and then prepare a test with appropriate assertions. Thanks to assertions, we are able to verify our assumptions about the code:
– starting with very simple ones like checking if a method returned a specific numeric value (as in our correct test above),
– to more complex ones, such as checking whether the returned object is of a given type,
– or finally checking if the method threw the expected exception.


Stub, Spy and Mock

In this part of the article, we’ll look at three types of objects used in unit tests. These are Stubs, Mocks and Spy.

Note – for better clarity in the code part I have omitted used imports!

Stub

It is an object containing an example implementation of some code whose behavior it imitates. We use stub when we do not have access to a real method or when we do not want to involve objects that return real data, which could have unforeseen side effects (e.g. modification of data in the database). It is worth noting that stub can return values ​​defined by us. It will also not throw an error if we have decided not to define a given state (e.g. void methods are empty, and the values ​​returned by the components are default for a given type or defined [“hard-coded”]).

Below is an example where we are testing a method that returns a list of customer names starting with a specific letter. For this example, we’ll create an interface and two classes. ‘Service’ is an interface with the function ‘getCustomers()’ which returns a list of type String.

public interface Service {
    List<String> getCustomers();
}

We create a ‘JavaExample’ class that implements the actual code from which the data will be returned. We instantiate the ‘Service’ interface in the class and then initialize it in the constructor. To return the data, we create a function ‘getCustomersWhoseNamesStartsWithA()’, which returns a list of type String. Inside the method we initialize a new list of ‘customers’. Now we retrieve the list of customers with ‘service.getCustomers()’ and in a loop we check if our list contains a String starting with the letter “A”, and if so, we add it to the list of customers. Finally, we return the list.

public class JavaExample {
    Service service;

    public JavaExample(Service service) {
        this.service = service;
    }

    public List<String> getCustomersWhoseNamesStartsWithA() {
        List<String> customers = new ArrayList<>();
        for (String customerName : service.getCustomers()) {
            if (customerName.contains("A")) {
                customers.add(customerName);
            }
        }
        return customers;
    }
}

Then we create a class containing all test cases. In this class, we’ll create a stub class called ‘StubService’ that implements the ‘Service’ interface, and in the class, we’ll use the ‘getCustomers()’ method to create a list with some sample names that we’ll return. Then we create a ‘whenCallServiceIsStubbed()’ test method. Inside the method, we create an object of class ‘JavaExample’ and pass the class ‘StubService’ as its argument in the constructor. We test the result returned by the ‘getCustomersWhoseNamesStartsWithA()’ function using the appropriate assertions. In the first assertion we check the size of the returned list, and in the second we check if the first item in the list is the name “Adam”. As you can easily guess – the test was successful.

class JavaExampleTest {

    @Test
    public void whenCallServiceIsStubbed() {
        JavaExample service = new JavaExample(new StubService());

        assertEquals(3, service.getCustomersWhoseNamesStartsWithA().size());
        assertEquals("Adam", service.getCustomersWhoseNamesStartsWithA().get(0));
    }

    static class StubService implements Service {
        public List<String> getCustomers() {
            return Arrays.asList("Adam", "Katarzyna", "Bogdan", "Agnieszka", "Adrian", "Mateusz");
        }
    }
}

Stubs work well for simple methods and examples, but with more test conditions they can grow quite large and be quite complicated to maintain. Therefore, mocks are better for larger, more complex methods.

Mock

These are objects that simulate the operation of real objects. They allow us to determine what interactions we expect during the tests, and then verify whether they actually took place. In other words – mocks give full control over the behavior of simulated objects, offering the possibility of complete verification of specific method calls (whether they were called, how many times, in what order, with what parameters, etc.). These objects are most often created using a library or a specific framework (e.g. Mockito, JMock and EasyMock). They are used to test a large set of tests where the use of stubs is not enough. What’s more, they can be created dynamically at runtime. Mocks are the most powerful and flexible version of unit testing.

To create a mock, we will use the ‘mock()’ function, which takes the name of the class we want to simulate as an argument. We will see how it works in the example below. First, we’ll create a ‘User’ class with sample fields and a constructor:

public class User {
    private int id;
    private String firstName;
    private String lastName;

    public User(int id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }
}

Then the ‘DbUsers’ interface, containing the ‘getUserById()’ method, which extracts the username (name and surname) from the database:

public interface DbUsers {
    User getUserById(int userId);
}

We also create a ‘UserService’ class that implements our ‘DbUsers’ interface:

public class UserService implements DbUsers {
    private DbUsers dbUsers;

    public UserService(DbUsers dbUsers) {
        this.dbUsers = dbUsers;
    }

    @Override
    public User getUserById(int userId) {
        return dbUsers.getUserById(userId);
    }
}

To test our ‘UserService’ class now, we need to mock our interface and set its behavior. In the “given” section, we create our mock object, then a test ‘User’ object that we will use later. The ‘when()’ and ‘thenReturn()’ methods simulated such an operation, in which if we call the ‘getUserById()’ method on our mock with the argument “1”, it should return the ‘User’ object. The next step is to create a ‘UserService’ object with the argument ‘dbUsers’ (our mock object). Then we call the ‘getUserById()’ method of the ‘UserService’ object with the argument “1”. Finally, we verify (using an assertion) whether our result is equal to the test object ‘User’:

class UserServiceTest {

    @Test
    public void testGetUserById() {
        //given
        DbUsers dbUsers = mock(DbUsers.class);
        User testUser = new User(1, "Jan", "Nowak");
        
        when(dbUsers.getUserById(1)).thenReturn(testUser);

        //when
        UserService userService = new UserService(dbUsers);
        User result = userService.getUserById(1);

        //then       
        assertEquals(testUser, result);
    }
}

Spy

These are hybrid objects – something between a real object and a mock object (also called Partial Mock). When using it, the real object remains unchanged and we just “spy” on its specific methods. In other words, we take an existing (real) object and replace or “spy” only some of its methods. Spy objects are useful when we want to use the true behavior of specific methods in an object or when we want to be able to verify method calls while preserving their true behavior. The Mockito framework allows us to create Spy objects using the ‘spy()’ method. Thanks to it, we can call the normal methods of the real object. The following code snippet shows how to use the ‘spy()’ method.

Suppose we have a class like this:

public class Shopping {
    private int price;
    private int quantity;
 
    public Shopping() {
    }
 
    public int getQuantity() {
        return quantity;
    }
 
    public int getPrice() {
        return price;
    }
 
    int sumPrice() {
        return getPrice() * getQuantity();
    }
}

Now, using the Spy object, we would like to validate the cost summation method:

class ShoppingTest {

    @Test
    void testTotalPrice() {
        //given
        Shopping shopping = spy(Shopping.class);
        given(shopping.getPrice()).willReturn(5);
        given(shopping.getQuantity()).willReturn(3);

        //when
        int result = shopping.sumPrice();

        //then
        then(shopping).should().getPrice();
        then(shopping).should().getQuantity();
        assertThat(result, equalTo(15));
    }
}

Advantages/disadvantages of TDD

Let’s look at the advantages of TDD:

  • reducing the number of defects in the production code,
  • reduction of workload in the final stages of projects,
  • emphasis on refactoring leads to better project quality in the source code,
  • because the resulting code is technically sound, TDD enables faster innovation,
  • the code is flexible and extensible (it can be refactored or moved with little risk of breaking it),
  • the tests themselves are tested as developers check if each new test fails as part of the TDD process,
  • your time and effort will not be wasted as we only write code for the function needed to meet the specific requirements.

We will now look at some of the shortcomings of TDD (it is worth noting that these are the result of not following TDD best practices rather than flaws in its approach). Because the programmer is also human, so he can make mistakes, e.g.:

  • forget about running tests frequently,
  • writing too many tests at once,
  • will create too extensive tests,
  • compose tests that are too trivial,
  • write tests for trivial code.

But there are other objections to TDD practices. One is that TDD can lead to disregarding large or medium-scale projects because software design is too focused on the function level. Also, some argue that TDD is an ideal that is unsuitable for real problems in software development. These problems may include:
– large, complex code bases,
– code that needs to work with legacy systems,
– processes running under strict real-time, memory, networking, or performance constraints.


Summary

In recent years, tests appear in more and more projects and more and more programmers/developers incorporate them into their code. Applying TDD practices can only help improve test application. And although their writing allows you to detect errors at the earliest possible stage (because during writing the program code), there are disadvantages to this approach. It is worth remembering that manual testing is tedious, time-consuming and tedious work. It’s very easy to make mistakes here. In addition, in IT projects, requirements often change, so tests must also be carried out frequently. Also, for most programmers, it’s not natural to write a test for code that doesn’t exist yet. However, any decent programmer should test the code they write. Because you have to be aware that when putting the code into use, you should be sure that it works as it should.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *