Mastodon

Unit Tests vs Integration Tests

This article will highlight the differences between unit tests and integration tests. It’s widely known that writing tests to increase the quality of software is a good idea. There are many tools and best practices, for example Test Driven Development (TDD) and the Spring Test Slices. In my experience, however, the software developing community did not reach the point where everyone can write great tests yet. Maybe the growing number of tools and concepts makes writing good tests harder for young developers instead of more intuitive. To enhance this situation, I want to describe two important types of tests here: unit test and integration test.

Example Scenario and Code Overview

To illustrate the differences between unit tests and integration tests, I came up with a simple application. The only goal of this application is to create a list of movie recommendations based on an Excel list of movies.

For example, this excel:

NameYearRanking
Matrix19993
The Big Lebowski19982
Lord of the Rings20011

creates this output:

This is your personal movie recommendation. Watch the following films in that order:
The Big Lebowski from 1998,
Lord of the Rings from 2001,
Matrix from 1999

There are two major steps to execute: Parsing the Excel file and creating the result string.

The application doesn’t have a user interface or API. Even running the main class does nothing. The whole application comprises the two services MovieImportService (to parse the Excel file) and MovieService (to create the string and do some simple business logic). There’s also a POJO Movie.

You can find the code here.

Unit Tests

Unit tests are the smallest tests you can write. In some universities or trainings, the topic “testing applications” ends with writing a couple of unit test for a trivial example. Unit tests are the important basic form of tests and there should be a higher number of unit test than any other kind of test. This is the reason anyone should master writing them.

Unit tests also play a crucial role for Test Driven Development (TDD) where you incrementally write short and focused tests first, and implement tiny bits of behavior afterwards.

Let’s have a look at the unit test for our test project. MovieImportService is only meant to read the Excel file, without applying any business logic. That is why as an example code base, this class has no tests and plays only a minor role. In a real project, however, this too should be tested. Let’s focus on MovieService, which includes all the business logic.

The method MovieService.retrieveMovieRecommendation describes the flow of the execution: “Read the contents from the Excel file and convert them to Java objects, then do some rearranging and finally print the recommendation string.”

public String retrieveMovieRecommendation() throws IOException {

    List<Movie> movies = movieImportService.importMovies("movies.xlsx");

    List<Movie> moviesRearranged = rearrange(movies);

    return toRecommendationText(moviesRearranged);
}

Let’s analyse two of the methods called: MovieService.rearrange and MovieService.toRecommendationText.

This is MovieService.rearrange:

static List<Movie> rearrange(List<Movie> movies) {

    Optional<Movie> bigLebowski =
            movies.stream().filter(movie -> "The Big Lebowski".equals(movie.getName())).findFirst();

    if(bigLebowski.isEmpty())
        return movies;

    Movie lebowskiSwap =
            movies.stream().filter(movie -> Integer.valueOf(1).equals(movie.getRanking())).findFirst().orElseThrow();
    lebowskiSwap.setRanking(bigLebowski.get().getRanking());

    bigLebowski.get().setRanking(1);

    return movies;
}

Basically, the method changes the ranking of the movie “The Big Lebowski” from whatever it is to “1”. To maintain a valid ranking order, the ranking of the highest-ranked movie is changed to whatever “The Big Lebowski” was.

For this method, there are a number of tests:

@Test
void emptyMovieListDoesNotRearrangeAndReturnsEmptyList() {
assertTrue(MovieService.rearrange(List.of()).isEmpty());
}

@Test
void movieListWithOneMovieDoesNotRearrangeAndReturnsListWithOneMovie() {

    List<Movie> rearrangedList = MovieService.rearrange(List.of(
            Movie.builder().name("One Movie").year(2000).ranking(42).build()));

    assertEquals(1, rearrangedList.size());
    assertEquals("One Movie", rearrangedList.get(0).getName());
}

@Test
void movieListRearrangementPutsLebowskiAlwaysOnTop() {

    List<Movie> rearrangedList = MovieService.rearrange(List.of(
            Movie.builder().name("Matrix").year(1999).ranking(3).build(),
            Movie.builder().name("The Big Lebowski").year(1998).ranking(2).build(),
            Movie.builder().name("Lord of the Rings").year(2001).ranking(1).build()
    ));

    assertEquals(3, rearrangedList.size());

    assertEquals("Matrix", rearrangedList.get(0).getName());
    assertEquals(3, rearrangedList.get(0).getRanking());
    assertEquals("The Big Lebowski", rearrangedList.get(1).getName());
    assertEquals(1, rearrangedList.get(1).getRanking());
    assertEquals("Lord of the Rings", rearrangedList.get(2).getName());
    assertEquals(2, rearrangedList.get(2).getRanking());
}

I wrote three tests that resemble the first three letters of the ZOMBIES concept, which stand for “zero”, “one” and “many”. My first test verifies that an empty list is simply returned, just as a list with only one entry is. The last test verifies a correct rearranging for a list of three movies.

The concept mentioned before also shows that test coverage alone is one of the worst metrics ever because a good set of unit tests covers each line multiple times, kind of creating a coverage of well above 100%, if multiple executions would count. The real problem with test coverage shows when seeing things from the other side: Code can be tested with 100% coverage via only testing the happy path and some error handling, ignoring all the interesting corner cases.

Because the method under test is very short, these three tests can be written easily. (Also, if tests are written first, the resulting methods are short.)

The test methods have long, descriptive names that provide all the information needed to understand what is going on. This is important because after 6 months, nobody remembers all the tests and it is important to understand a problem only by the names of the failed tests. (I do know of the @DisplayName feature of JUnit 5, but I prefer to use descriptive method names instead of spreading the same information to two places.)

Although the test methods have multiple asserts, they all are asserts of only one concept / use case. The method testing the list with one entry has two asserts, but they both focus on the one entry in the list. This is also mentioned in the method name that says, with inserted spaces, that a “movie list with one movie does not rearrange and returns a list with one movie”.

It is also noteworthy that the complete test fixture is in the test method. There is no loading of data files or setup methods involved. Unit tests are meant to be small, fast and easily understandable. To achieve this independence from dependencies, mocks come into play.

Another way of achieving this is to use only partial data to highlight the focus of the test. An object does not have to be completely filled or build up with a complex data structure when only a part of this is needed to test something.

Besides testing the happy path unit tests are also great to test corner cases and errors because these can be setup with a few lines of code.

Another important aspect of unit tests are that in my opinion, they should indeed test “private” methods which have to be changed from being “private” to the default access modifier so that the test class can “see” the method. That way, even the tiniest helper methods are testable via unit tests.

Let’s look at the implementation of MovieService.toRecommendationText:

static String toRecommendationText(List<Movie> movies) {

    if (movies.isEmpty()) {
        return "No movies known. Please add some movies to create a recommendation.";
    }

    if (movies.size() == 1) {
        Movie movie = movies.get(0);
        return "Only one movie known: " + movie.getName() + " from " + movie.getYear() + ". " +
                "Better watch that one and add some more later.";
    }

    String movieString = movies.stream()
            .sorted(Comparator.comparingInt(Movie::getRanking))
            .map(movie -> movie.getName() + " from " + movie.getYear())
            .collect(Collectors.joining("," + System.lineSeparator()));

    return "This is your personal movie recommendation. Watch the following films in that order:"
            + System.lineSeparator() +
            movieString;
}

Here are the unit tests for this method:

@Test
void recommendationIsEmptyInCaseOfNoMovies() {

    String recommendationText = MovieService.toRecommendationText(new ArrayList<>());

    assertEquals("No movies known. Please add some movies to create a recommendation.", recommendationText);
}

@Test
void recommendationListsOnlyOneMovieInCaseOfOneMovieKnown() {

    String recommendationText =
            MovieService.toRecommendationText(List.of(
                    Movie.builder().name("Matrix").year(1999).ranking(1).build()));

    assertEquals("Only one movie known: Matrix from 1999. Better watch that one and add some more later.",
            recommendationText);

    recommendationText =
            MovieService.toRecommendationText(List.of(
                    Movie.builder().name("Lord of the Rings").year(2001).ranking(1).build()));

    assertEquals("Only one movie known: Lord of the Rings from 2001. Better watch that one and add some more " +
                    "later.",
            recommendationText);
}

@Test
void recommendationListsAllMoviesInCorrectOrder() {

    List<Movie> movies = List.of(
            Movie.builder().name("Matrix").year(1999).ranking(3).build(),
            Movie.builder().name("The Big Lebowski").year(1998).ranking(2).build(),
            Movie.builder().name("Lord of the Rings").year(2001).ranking(1).build());

    assertEquals("This is your personal movie recommendation. Watch the following films in that order:"
            + System.lineSeparator() +
            "Lord of the Rings from 2001," + System.lineSeparator() +
            "The Big Lebowski from 1998," + System.lineSeparator() +
            "Matrix from 1999", MovieService.toRecommendationText(movies));
}

You can see a lot of similarities to the tests for MovieService.rearrange here, for example, the ZOMBIE concept (at least the first three letters), the naming of the methods and the multiple asserts.

Integration Tests

Integration tests focus on a set of classes and their integration together, instead of only testing the functionality of one class. Hence, they are written after the implementation.

Below is the integration test for the example application.

@Test
void importFromExcelCreatesCorrectListOfJavaObjects() throws IOException {

    List<Movie> importedMovies = movieImportService.importMovies("movies.xlsx");

    assertEquals(3, importedMovies.size());

    assertEquals("Matrix", importedMovies.get(0).getName());
    assertEquals(1999, importedMovies.get(0).getYear());
    assertEquals(3, importedMovies.get(0).getRanking());
    assertEquals("The Big Lebowski", importedMovies.get(1).getName());
    assertEquals(1998, importedMovies.get(1).getYear());
    assertEquals(2, importedMovies.get(1).getRanking());
    assertEquals("Lord of the Rings", importedMovies.get(2).getName());
    assertEquals(2001, importedMovies.get(2).getYear());
    assertEquals(1, importedMovies.get(2).getRanking());
}

Only one method is tested, MovieImportService.importMovies, and only one parameter is set, the name of the Exel file to be imported. This one method call executes all the methods mentioned above when we talked about unit tests. However, the true complexity is totally hidden in the one method call in this integration test. Hence, the integration test can hardly test all the different use cases and combinations of parameters like a unit test. It would be possible to create a number of Excel files, each designed for a specific execution path. This would make it very hard to read the true intention of the integration test, though.

Because of the inherent complexity of integration tests, they are often only used to test the happy path. However, they are also great in testing error states that would need a lot of setup in a unit test. In our example code, an invalid Excel file could be more easily tested in an integration test than in a unit test because the latter would need a lot of mocks.

Integration tests are much harder to read and maintain because of their complexity. They should not be used as the only way of documenting or testing the code base.

Also, integration tests are often much slower in execution compared to unit tests. For example, a Spring integration test annotated with @SpringBootTest builds the whole application context which takes multiple seconds. A simple unit test with no dependencies is executed in a couple of milliseconds.

Last, integration tests can use real test data in all its complexity and original medium. With the help of Docker, even whole database dumps can be used to test an application, as well as several test files like the Excel file used in the above example.

Recommendations and Conclusion

Each kind of test, unit test and integration test, have their advantages and should be used. Both of them should be used. Unit tests are for writing awesome code in TDD-style where integration tests can verify that complicated situations work the way they should.

I recommend writing unit tests first, via TDD. After that, integration tests are added for complex scenarios.

Don’t annotate real unit tests with @SprintBootTest because that makes them slow. Instead, write test classes with only fast unit tests and test classes with slower integration tests. These can be tagged with JUnit 5 tags to run the fast unit test during the local development and the slower integration tests on the CI pipeline.

Before implementing the first unit test, I think about the desired behavior and scribble down all the use cases as comments directly in the test class. That creates a nice to-do list and a place to add additional test cases. These often emerge when implementing another test and should be written down immediately to not forget about them.

These are my thoughts on unit tests and integration tests. If you want to add something, let me know.