Unit testing with JUnit 5

JUnit 5 tutorial, part 1: Unit testing with JUnit 5, Mockito, and Hamcrest

Set up your first Maven project and start writing robust unit tests with JUnit 5, Hamcrest, and Mockito

software development / application testing / tools in hand amid abstract code mapping
Sorayut / MF3D / Getty Images

JUnit 5 is the new de facto standard for developing unit tests in Java. This newest version has left behind the constraints of Java 5 and integrated many features from Java 8, most notably support for lambda expressions.

In this first half of a two-part introduction to JUnit 5, you'll get started with testing with JUnit 5. I'll show you how to configure a Maven project to use JUnit 5, how to write tests using the @Test and @ParameterizedTest annotations, and how to work with the new lifecycle annotations in JUnit 5. You'll also see a brief example of using filter tags, and I'll show you how to integrate JUnit 5 with a third-party assertions library—in this case, Hamcrest. Finally, you'll get a quick, tutorial introduction to integrating JUnit 5 with Mockito, so that you can write more robust unit tests for complex, real-world systems.

download
Get the source code for examples in this tutorial. Created by Steven Haines for JavaWorld.

Test-driven development

If you've been developing Java code for any period of time, you are probably intimately familiar with test-driven development, so I'll keep this section brief. It's important to understand why we write unit tests, however, as well as the strategies developers employ when designing unit tests.

Test-driven development (TDD) is a software development process that interweaves coding, testing, and design. It is a test-first approach that aims to improve the quality of your applications. Test-driven development is defined by the following lifecycle:

  1. Add a test.
  2. Run all of your tests and observe the new test failing.
  3. Implement the code.
  4. Run all of your tests and observe the new test succeeding.
  5. Refactor the code.

Figure 1 shows this TDD lifecycle.

A diagram of the test-driven development lifecycle. Steven Haines

Figure 1. The test-driven development lifecycle

There's a twofold purpose to writing tests before writing your code. First, it forces you to think about the business problem you are trying to solve. For example, how should successful scenarios behave? What conditions should fail? How should they fail?  Second, testing first gives you more confidence in your tests. Whenever I write tests after writing code, I always have to break them to ensure that they are actually catching errors. Writing tests first avoids this extra step.

Writing tests for the happy path is usually easy: Given good input, the class should return a deterministic response. But writing negative (or failure) test cases, especially for complex components, can be more complicated.

As an example, consider writing tests for a database repository. On the happy path, we insert a record into the database and receive back the created object, including any generated keys. In reality, we must also consider the possibility of a conflict, such as inserting a record with a unique column value that is already held by another record. Additionally, what happens when the repository can't connect to the database, perhaps because the username or password has changed? What happens if there's a network error in transit? What happens if the request doesn't complete in your defined timeout limit?

To build a robust component, you need to consider all likely and unlikely scenarios, develop tests for them, and write your code to satisfy those tests. Later in the article, we'll look at strategies for creating different failure scenarios, along with some of the new features in JUnit 5 that can help you test those scenarios.

Unit testing with JUnit 5

Let's start simple, with an end-to-end example of configuring a project to use JUnit 5 for a unit test. Listing 1 shows a MathTools class whose method converts a numerator and denominator to a double.

Listing 1. An example JUnit 5 project (MathTools.java)


package com.javaworld.geekcap.math;

public class MathTools {
    public static double convertToDecimal(int numerator, int denominator) {
        if (denominator == 0) {
            throw new IllegalArgumentException("Denominator must not be 0");
        }
        return (double)numerator / (double)denominator;
    }
}

We have two primary scenarios for testing the MathTools class and its method:

  • A valid test, in which we pass non-zero integers for the numerator and denominator.
  • A failure scenario, in which we pass a zero value for the denominator.

Listing 2 shows a JUnit 5 test class to test these two scenarios.

Listing 2. A JUnit 5 test class (MathToolsTest.java)


package com.javaworld.geekcap.math;

import java.lang.IllegalArgumentException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

class MathToolsTest {
    @Test
    void testConvertToDecimalSuccess() {
        double result = MathTools.convertToDecimal(3, 4);
        Assertions.assertEquals(0.75, result);
    }

    @Test
    void testConvertToDecimalInvalidDenominator() {
        Assertions.assertThrows(IllegalArgumentException.class, () -> MathTools.convertToDecimal(3, 0));
    }
}

In Listing 2, the testConvertToDecimalInvalidDenominator method executes the MathTools::convertToDecimal method inside an assertThrows call. The first argument is the expected type of exception to be thrown. The second argument is a function that will throw that exception. The assertThrows method executes the function and validates that the expected type of exception is thrown.

The Assertions class and its methods

The org.junit.jupiter.api.Test annotation denotes a test method. Note that the @Test annotation now comes from the JUnit 5 Jupiter API package instead of JUnit 4's org.junit package. The testConvertToDecimalSuccess method first executes the MathTools::convertToDecimal method with a numerator of 3 and a denominator of 4, then asserts that the result is equal to 0.75. The org.junit.jupiter.api.Assertions class provides a set of static methods for comparing actual and expected results. The Assertions class has the following methods, which cover most of the primitive data types:

  • assertArrayEquals compares the contents of an actual array to an expected array.
  • assertEquals compares an actual value to an expected value.
  • assertNotEquals compares two values to validate that they are not equal.
  • assertTrue validates that the provided value is true.
  • assertFalse validates that the provided value is false.
  • assertLinesMatch compares two lists of Strings.
  • assertNull validates that the provided value is null.
  • assertNotNull validates that the provided value is not null.
  • assertSame validates that two values reference the same object.
  • assertNotSame validates that two values do not reference the same object.
  • assertThrows validates that the execution of a method throws an expected exception (you can see this in the testConvertToDecimalInvalidDenominator example above).
  • assertTimeout validates that a supplied function completes within a specified timeout.
  • assertTimeoutPreemptively validates that a supplied function completes within a specified timeout, but once the timeout is reached it kills the function's execution.

If any of these assertion methods fail, the unit test is marked as failed. That failure notice will be written to the screen when you run the test, then saved in a report file.

Analyzing your test results

In addition to validating a value or behavior, the assert methods can also accept a textual description of the error, which can help you diagnose failures. For example:


Assertions.assertEquals(0.75, result, "The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4");
Assertions.assertEquals(0.75, result, () -> "The MathTools::convertToDecimal value did not return the correct value of 0.75 for 3/4");

The output will show the expected value of 0.75 and the actual value. It will also display the specified message, which can help you understand the context of the error. The difference between the two variations is that the first one always creates the message, even if it is not displayed, whereas the second one only constructs the message if the assertion fails. In this case, the construction of the message is trivial, so it doesn't really matter. Still, there is no need to construct an error message for a test that passes, so it's usually a best practice to use the second style.

Finally, if you're using an IDE like IntelliJ to run your tests, each test method will be displayed by its method name. This is fine if your method names are readable, but you can also add a @DisplayName annotation to your test methods to better identify the tests:

@Test
@DisplayName("Test successful decimal conversion")
void testConvertToDecimalSuccess() {
  double result = MathTools.convertToDecimal(3, 4);
  Assertions.assertEquals(0.751, result);
}

Running your unit test

In order to run JUnit 5 tests from a Maven project, you need to include the maven-surefire-plugin in the Maven pom.xml file and add a new dependency. Listing 3 shows the pom.xml file for this project.

Listing 3. Maven pom.xml for an example JUnit 5 project


<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
      <modelVersion>4.0.0</modelVersion>
      <groupId>com.javaworld.geekcap</groupId>
      <artifactId>junit5</artifactId>
      <packaging>jar</packaging>
      <version>1.0-SNAPSHOT</version>
      <build>
          <plugins>
              <plugin>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-compiler-plugin</artifactId>
                  <version>3.8.1</version>
                  <configuration>
                      <source>8</source>
                      <target>8</target>
                  </configuration>
              </plugin>
              <plugin>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-surefire-plugin</artifactId>
                  <version>3.0.0-M4</version>
              </plugin>
          </plugins>
      </build>
      <name>junit5</name>
      <url>http://maven.apache.org</url>
      <dependencies>
          <dependency>
              <groupId>org.junit.jupiter</groupId>
              <artifactId>junit-jupiter</artifactId>
              <version>5.6.0</version>
              <scope>test</scope>
          </dependency>
      </dependencies>
  </project>

JUnit 5 dependencies

JUnit 5 packages its components in the org.junit.jupiter group and we need to add the junit-jupiter artifact, which is an aggregator artifact that imports the following dependencies:

  • junit-jupiter-api defines the API for writing tests and extensions.
  • junit-jupiter-engine is the test engine implementation that runs the unit tests.
  • junit-jupiter-params provides support for parameterized tests.

Next, we need to add the maven-surefire-plugin build plug-in in order to run the tests.

Finally, be sure to include the maven-compiler-plugin with a version of Java 8 or later, so that you'll be able to use Java 8 features like lambdas.

Run it!

Use the following command to run the test class from your IDE or from Maven:

mvn clean test

If you're successful, you should see output similar to the following:


[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.javaworld.geekcap.math.MathToolsTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.04 s - in com.javaworld.geekcap.math.MathToolsTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  3.832 s
[INFO] Finished at: 2020-02-16T08:21:15-05:00
[INFO] ------------------------------------------------------------------------
1 2 3 Page 1
Page 1 of 3