Migrating from JUnit 4 to JUnit 5 in a large application

addtoany linkedin

Written by: Shannon Lee

 

At Kinaxis, unit testing is an integral part of our development process. It is often the first line of defense when it comes to catching defects. In our Promotion Manager web application, we unit test across all layers of our stack. Our frontend layer is written in React, so we use Jest and Enzyme, two libraries that work together to test the behaviour of individual components. This layer communicates with a RESTful API that encapsulates our product’s business logic. Here, we use the Java Spring framework and a combination of JUnit and Mockito for testing.

Though we have long used JUnit 4, we recently started looking into JUnit 5, also known as JUnit Jupiter, to improve our unit tests. This library is a significant upgrade from its predecessor, as it has more powerful features that have made our tests easier to implement and maintain.

In this article, I’ll walk through our migration process, what features we have implemented, and next steps. Migrations can be tricky, so much of our process involved reading JUnit 5 documentation to gain a deeper understanding of the framework.

Here are a few resources I highly recommend for additional context:

https://junit.org/junit5/docs/current/user-guide/

https://www.baeldung.com/junit-5

Nested tests: https://junit.org/junit5/docs/current/user-guide/#writing-tests-nested

Parameterized tests: https://www.baeldung.com/parameterized-tests-junit-5

 

First steps

Some migrations are all or nothing -- updating the version number will cause all of the old code to break, forcing a complete rewrite. This can be a long and tedious process. Luckily, JUnit 4 and JUnit 5 features can coexist, even within the same test classes. All we need to do is add the necessary JUnit 5 dependencies to our dependencies file. For example, below is a snippet of a Maven POM file. It includes two core JUnit Jupiter dependencies, followed by Mockito, and finally JUnit Vintage to support legacy JUnit test engines. Then, once we install the JAR files, we are ready to start integrating new features.

  1. <dependency> 
  2.     <groupId>org.junit.jupiter</groupId> 
  3.     <artifactId>junit-jupiter-engine</artifactId> 
  4.     <version>5.5.2</version> 
  5.     <scope>test</scope> 
  6. </dependency> 
  7. <dependency> 
  8.     <groupId>org.junit.jupiter</groupId> 
  9.     <artifactId>junit-jupiter-params</artifactId> 
  10.     <version>5.5.2</version> 
  11.     <scope>test</scope> 
  12. </dependency> 
  13. <dependency> 
  14.     <groupId>org.mockito</groupId> 
  15.     <artifactId>mockito-junit-jupiter</artifactId> 
  16.     <version>2.23.0</version> 
  17.     <scope>test</scope> 
  18. </dependency>  
  19. <dependency> 
  20.     <groupId>org.junit.vintage</groupId> 
  21.     <artifactId>junit-vintage-engine</artifactId> 
  22.     <version>5.5.2</version> 
  23.     <scope>test</scope> 
  24. </dependency> 

JUnit 4 introduced extensions, which provide additional functionality to test classes. These extensions can be created independently and then used across multiple classes. One such extension is the Spring class runner. It allows us to leverage Spring’s application context and dependency injection in our tests. JUnit 5 simplifies the implementation of extensions by using a single annotation, @ExtendWith.

To use the updated SpringExtension with @ExtendWith, the application must be using at least Spring 5. We are currently using Spring 4.2.5, which means that migrating any classes that use the Spring JUnit 4 class runner is blocked until we upgrade Spring first. Despite that blocker, we are still able to implement new JUnit features into some of our existing tests that don’t use the Spring class runner.

Let’s get into what they are and how we set them up.


Parameterized tests

Our application deals with large data sets, and the complex relationships between different data types are reflected in our underlying domain model. As such, we need careful testing to ensure that any changes to the model don’t affect existing objects. Let’s say we have a function that manipulates an object depending on a given data type. If we want to verify the behaviour of each type, we need to mock the object and invoke the function several times. Because of this, as our codebase grows, our tests become increasingly (and needlessly!) long.

Now, in JUnit 5, it’s possible to do these repetitive invocations in just a few lines of code using parameterized tests. They’re fast and easy to set up, and they’ve greatly reduced the length of our test classes. In the example below, we can write a single assertion statement that gets applied to a stream of arguments.

The arguments can be sourced in a few different ways. Here, we’re using the @MethodSource annotation to provide a series of parameters from a function. When the test is run, it is treated as a single test, but if one set of parameters fails, the assertion error will tell us exactly which set caused the failure.

Example 1.1 - Parameterized tests using method source. We’ve abstracted the employee types into a separate method so that we can run the same assertion on each type:

private static Stream<Arguments> employeeParamSource() {

   return Stream.of(

             Arguments.of(Employee.CASHIER"01", 12),

           Arguments.of(Employee.STOCK"02", 10),

           Arguments.of(Employee.MANAGER, "03", 2)

   );

}


@ParameterizedTest

@MethodSource(“employeeParamSource")

public void testGetNumStoreEmployees(Employee employeeType, String storeId, Integer numEmployees) {

     assertThat(storeService.getNumEmployees(employeeType, storeId)

         .compareTo(expectedValue), is(numEmployees);

}

 

Nested tests

Our most logic-heavy layers naturally have many tests associated with them. One of our test classes, at a whopping 3000+ lines, has become more difficult to work with as we have continued adding to it. We looked at a couple of different options to break it into more manageable chunks. We could create separate classes for closely related tests. The other option, offered by JUnit 5, is the @Nested annotation. When used in an inner class, it allows us to create a new set of tests that can be run on their own.

Why would we want to use @Nested over separate classes? Our tests are equally -- if not more so -- verbose if they were to be nested, but the greatest advantage here is that we can leverage shared dependencies from the outer class and use them in our nested tests. We can also set up specific mock data for each inner class, giving us greater flexibility in how we write our tests.

Example 1.2 - Nested test class. The outer class tests EmployeeDto while the inner class tests a CSV object containing the employee information:

class EmployeeDtoTest {

    private EmployeeDto employeeDto;

     @BeforeEach

    void setup() {

        employeeDto = new EmployeeDto("Alice", Employee.CASHIER);

    }


     @Nested

    class EmployeeDtoCsvTest {

        private EmployeeDto.Csv csvObject;

        @BeforeEach

        void setup() {

            csvObject = new EmployeeDto.Csv(employeeDto);

        }

        @Test

        void testGetEmployee() {

            assertEquals("Alice - Cashier", csvObject.getEmployeeInfo());     

        }

    }

}

 

One caveat of using @Nested is that the inner class must be non-static, whereas the method source function for parameterized tests must be private static. This means that if we want to use parameterized tests within a nested class, we need to use one of the other options to provide the parameters.

Completing the migration

One of the biggest challenges of developing a large enterprise application is balancing feature work and technical debt. Our team is no different; it’s easy to get overwhelmed with client-related work at the expense of the health and maintenance of our codebase. The major blocker for a full JUnit 5 migration is the Spring migration, and we know that this will be a huge undertaking as it will require major changes to our entire application.

We’ve found that the piecewise manner to slowly integrate upgrades offers a safer alternative. Because the tests are not user-facing, it gives us more freedom to play around with new features and try different things. This also means that we don’t have to disrupt our development cycles to fit in the migration. Once we’ve upgraded Spring, we can migrate the remaining tests a few at a time.

So far, we’ve done so in an ad hoc manner. We’ve followed the general guideline that the developer modifying a particular piece of code is responsible for migrating the corresponding test class. This has been helpful in distributing knowledge of JUnit 5 across our team, as each person has contributed to the migration in some way.

Once we’ve migrated all the remaining tests, we can safely remove the JUnit Vintage dependency. We can ensure that everything works as expected by running all test suites and verifying that they pass without any errors.

We recognize that a partial migration may not suit all major upgrades. However, given that JUnit 4 and JUnit 5 can be used simultaneously, a slow, gradual process reduces the risk of error. We will likely revisit our experience with JUnit 5 and see what lessons we can take away for future migrations. Until then, the team is looking forward to completing our migration to JUnit 5 and exploring its full potential.

 

AUTHOR:
Shannon Lee is a former software engineer at Kinaxis. She is currently a software engineer based in Toronto, ON.
 

 

Leave a Reply

CAPTCHA