Software Development

10 Tips for Writing Good Unit Tests

In this article, we provide 10 Tips for Writing Good Unit Tests.

Testing is a very important aspect of software development and largely determines the fate of an application. Adequate testing helps to identify potential issues early during the development lifecycle. The cost of finding and fixing a bug is higher as we move across the phases from design till production.

To just put the importance of testing in context, Michael C. Feathers in his book Working Effectively with Legacy Code says:

To me, legacy code is simply code without tests.

Michael C. Feathers

There are three main types of software testing: unit testing, functional testing, and integration testing. In this article, we will cover specifically about unit tests.

1. Introduction

Unit tests test individual code components and ensure that code works the way it was intended to. Unit tests are written and executed by developers. Test cases are typically written at a method level covering the functionality to the maximum level possible.

There are numerous benefits to writing unit tests; they help with regression, provide documentation, and facilitate the good design. However, hard to read and brittle unit tests can wreak havoc on any codebase. The below sections describe some best practices regarding unit test design with examples in Java language.

2. Best Practices

In this section, we will see some of the best practices one by one

2.1 Test one thing at a time

Unit tests should be standalone and should have no dependencies on external factors such as a file system or database. They should be designed to test a unit of functionality in isolation. Let us understand this with an example:

FileUtil.java

public class FileUtil {
 
    public List<String> read(String filePath) throws IOException {
        return Files.readAllLines(Paths.get(filePath));
    }
}

This is a simple class which reads all the lines from a specified file and returns a list of strings.

Utility.java

public class Utility {
 
    FileUtil fileUtil;
 
    public int getNumberOfTokens(String filePath) {
        List<String> tokens = new ArrayList<>();
        try {
            List<String> lines = fileUtil.read(filePath);
            for (String line : lines) {
                tokens.addAll(Arrays.asList(line.split(" ")));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return tokens.size();
    }
}

The method getNumberOfTokens uses the FileUtil to read the file and then splits the line based on space to return the number of words in a file. This is the behavior we are interested in testing. While testing this method, we are only interested in the count of tokens and not how we actually read the file. A good test for this scenario is detailed below

UtilityTest.java

@RunWith(MockitoJUnitRunner.class)
public class UtilityTest {
    @InjectMocks
    Utility utility;

    @Mock
    FileUtil fileUtilMock;

    @Test
    public void shouldReturnTokenLengthBasedOnFile() throws IOException {
        final String FILE_PATH = "temp.txt";
        List<String> results = new ArrayList<>();
        results.add("This is line 1");
        results.add("This is line 2");
        results.add("This is line 3");
        when(fileUtilMock.read(FILE_PATH)).thenReturn(results);
        int tokens = utility.getNumberOfTokens(FILE_PATH);
        verify(fileUtilMock, times(1)).read(FILE_PATH);
        Assert.assertEquals(12,tokens);
    }
}
  • We are using Mockito and JUnit to explain the testing practices
  • FileUtil has been mocked and read method is stubbed to return a specified list for the input provided.
  • For the generated list, the number of words/tokens must be 12 which is asserted using JUnit assert.
  • Mock call verification of read is also asserted.

2.2 Tests should finish quickly

Unit tests should take very little time to run. It is very common for big projects to have a lot of unit tests. Hence, tests should run ideally within milliseconds. It is advisable to mock out external dependencies so that tests ran very quickly.

Considering the above test in section 2.1, if we waited for the actual filesystem to return the file contents test will take longer to finish. Since the external call is mocked out, the test would return the result within a short time span.

2.3 Tests should be deterministic

Some methods do not have a deterministic result, i.e. the output of the method can vary each time the test is run. But tests should be deterministic which gives the confidence the application is working as expected.

DateUtil.java

public class DateUtil {

    public int compareWithToday(Date date){
        Calendar today = Calendar.getInstance();
        today.set(Calendar.HOUR_OF_DAY, 0);
        return date.compareTo(today.getTime());
    }
}

Utility.java

public boolean isValidDate(Date date) {
        int result = dateUtil.compareWithToday(date);
        if (result > 0) {
            return false;
        }
        return true;
    }

The above method actually checks if the date is a valid date or not. For this purpose, it validates whether it is a future date or not. If it is a future date it responds with false. It leverages DateUtil for this purpose.

We cannot run the test with today’s date. It might give an unexpected result based on the date. Hence, it is recommended to mock the DateUtil to provide a result for testing.

  @Test
    public void shouldReturnValidIfLesserThanToday() {
        Calendar calendar = Calendar.getInstance();
        Date input = calendar.getTime();
        when(dateUtilMock.compareWithToday(input)).thenReturn(-1);
        Assert.assertTrue(utility.isValidDate(input));
    }

    @Test
    public void shouldReturnInValidIfGreaterThanToday() {
        Calendar calendar = Calendar.getInstance();
        Date input = calendar.getTime();
        when(dateUtilMock.compareWithToday(input)).thenReturn(1);
        Assert.assertFalse(utility.isValidDate(input));
    }

These tests will deterministically run and pass irrespective of the time it is run. This mocks the DateUtil and provides a mocked response based on our setup. This helps us to test positive and negative flows in a deterministic manner. Tests should also not be flaky i.e. all tests should pass every time giving 100% confidence. Flaky tests should be fixed at the first instant possible.

2.4 Use Assertions

Tests should be self-checking i.e. they clearly should indicate success or failure. The test result should not be left for inference from developers or test results.

 @Test
    public void shouldReturnInValidIfGreaterThanToday() {
        Calendar calendar = Calendar.getInstance();
        Date input = calendar.getTime();
        when(dateUtilMock.compareWithToday(input)).thenReturn(1);
        System.out.println(utility.isValidDate(input));
    }

The above test is a bad test as it just logs the output to the console. This does not indicate whether the test passed or failed. It is better to practice to use Assert.assertFalse instead of System.out.println.

2.5 Avoid logic in tests

The purpose of a test is to verify the actual code. If logic or manipulation (if, while, etc) is added to test, there is a possibility of a bug in test code as well. This will result in less confidence in the test and the entire test suite. Let us look at a simple example of this.

public int addList(List<Integer> numbers) {
        int sum = 0;
        for (int number : numbers) {
            sum += number;
        }
        return sum;
    }

This is a simple method that sums up the input elements passed to it as part of the list. A better way to test this method is to pass the input and assert it against the expected result.

  @Test
    public void shouldReturnSumOfPassedList() {
        List<Integer> input = new ArrayList<>();
        IntStream.range(1, 4).forEach(x -> input.add(x));
        Assert.assertEquals(6, utility.addList(input));
    }

Instead of calculating the result in the test, we just pass the expected value for an assertion. This is a simple example but will be compounded when we have complex logic built in the tested module.

2.6 Anatomy of a test

This is a good practice to follow the AAA pattern. Arrange, Act, Assert is the expansion for AAA and it is very commonly used in unit testing. As the name implies, it consists of three main actions:

  • Arrange your inputs/mocks/stubs, creating and setting them up as necessary.
  • Act on the module/object/function to be tested.
  • Assert that return value matches the expected value.

All our tests have been following the pattern but let’s break down one of the test with comments.

 @Test
    public void shouldReturnTokenLengthBasedOnFile() throws IOException {
        //Arrange
        final String FILE_PATH = "temp.txt";
        List<String> results = new ArrayList<>();
        results.add("This is line 1");
        results.add("This is line 2");
        results.add("This is line 3");
        when(fileUtilMock.read(FILE_PATH)).thenReturn(results);
        //Act
        int tokens = utility.getNumberOfTokens(FILE_PATH);
        //Assert
        verify(fileUtilMock, times(1)).read(FILE_PATH);
        Assert.assertEquals(12, tokens);
    }
  • We have set up the input followed by the file mock to return a response.
  • We act on the object to obtain the result
  • The final step is to assert the result returned by the actual object.

2.7 It’s all in the name

Tests must have clear meaningful names. Tests are not only used for checking the accuracy of the application but also serves as live documentation. For example the test method in previous section indicates there is a method which returns the number of tokens in a file when a file path is passed as input to it. An unclear name would hinder future developers in understanding the code. A good practice to name a test should follow these three steps

  • The name of the method being tested.
  • The scenario under which it’s being tested.
  • The expected behaviour when the scenario is invoked

2.8 One logical assertion per test

This relates to the first practice we discussed. Tests must be short but another practice to keep in mind is that we must test one particular flow i.e. check only one output/side effect of the tested method. Coupled with the previous tip of proper names we can easily identify what failed in our code by just looking at the name.

@Test
    public void shouldValidateIfDateIsToday() {
        Calendar calendar = Calendar.getInstance();
        Date input = calendar.getTime();
        when(dateUtilMock.compareWithToday(input)).thenReturn(-1);
        Assert.assertTrue(utility.isValidDate(input));
        when(dateUtilMock.compareWithToday(input)).thenReturn(1);
        Assert.assertFalse(utility.isValidDate(input));
    }

The above is an antipattern. We have used two logical flows and if the test fails we cannot identify the scenario it fails. It is better to split them as two separate tests and have one logical assertion per test.

2.9 Externalize test data in tests

Prior to JUnit4, the data for which the test case was to be run has to be hardcoded into the test case. This created a restriction that in order to run the test with different data, the test case code had to be modified. However, JUnit4 as well as TestNG support externalizing the test data so that the test cases can be run for different datasets without having to change the source code.

ParameterizedUtilityTest.java

@RunWith(Parameterized.class)
public class ParameterizedUtilityTest {

    private List<Integer> numbers;
    private int result;

    public ParameterizedUtilityTest(List<Integer> numbers, int result) {
        this.numbers = numbers;
        this.result = result;
    }


    @Parameterized.Parameters
    public static Collection input() {
        return Arrays.asList(new Object[][]{
                {IntStream.range(1, 4).boxed().collect(Collectors.toList()), 6},
                {IntStream.range(2, 5).boxed().collect(Collectors.toList()), 9},
                {IntStream.range(-2, 3).boxed().collect(Collectors.toList()), 0},
        });
    }


    @Test
    public void shouldReturnSumOfPassedList() {
        Assert.assertEquals(result, new Utility().addList(numbers));
    }

2.10 TDD helps for better design

Test-driven development (TDD) is a software development process in which tests are written first before the actual implementation. Since there is no code it will fail. The next step is writing the smallest amount of code to pass the test. Then the code is refactored for better design and optimization. This is followed by ensuring the tests pass and writing the next test for a small requirement. This workflow has been termed as Red-Green-Refactor

By following TDD, we can cover the requirements serving as specification for the code eventually written. TDD is great as it leads to simple modular and maintainable code. This leads to increased productivity and development speed.

But TDD needs to be utilized with care. There are places where TDD doesn’t seem to be a good fit. Refer to the article here. TDD is a technique that provides huge benefits when used well.

3. Download the Source Code

Download
You can download the full source code of this example here: 10 Tips for Writing Good Unit Tests

Rajagopal ParthaSarathi

Rajagopal works in software industry solving enterprise-scale problems for customers across geographies specializing in distributed platforms. He holds a masters in computer science with focus on cloud computing from Illinois Institute of Technology. His current interests include data science and distributed computing.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button