May 4, 2020 1:31 PM

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

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.

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] ------------------------------------------------------------------------

Parameterized tests in JUnit 5

You've seen how to write and run a basic JUnit 5 unit test, so let's consider another example. This one is also based on the MathTools class, but we'll use parameterized tests to more thoroughly test our code.

To start, I've added another method to the MathTools class, named isEven:


public static boolean isEven(int number) {
  return number % 2 == 0;
}

We could test this code the same way we did in the previous section, by passing different numbers to the isEven method and validating the response:


@Test
void testIsEvenSuccessful() {
  Assertions.assertTrue(MathTools.isEven(2));
  Assertions.assertFalse(MathTools.isEven(1));
}

The methodology works, but if we want to test a large number of values, it will soon become cumbersome to enter the values manually. In this case, we can use a parameterized test to specify the values that we want to test:


@ParameterizedTest
@ValueSource(ints = {0, 2, 4, 6, 8, 10, 100, 1000})
void testIsEven(int number) {
  Assertions.assertTrue(MathTools.isEven(number));
}

For this test, we use the @ParameterizedTest annotation instead of the @Test annotation. We also have to provide a source for the parameters.

Using sources in parameterized testing

There are different types of sources, but the simplest is the @ValueSource, which lets us specify a list of Integers or Strings. The parameter is passed as an argument to the test method and then can be used in the test. In this case, we're passing in eight even integers and validating that the MathTools::isEven method properly identifies them as even.

This is better, but we still have to enter all of the values we want to test. What would happen if we wanted to test all the even numbers between 0 and 1,000? Rather than manually entering all 500 values, we could replace our @ValueSource with a @MethodSource, which generates the list of numbers for us. Here's an example:

@ParameterizedTest
@MethodSource("generateEvenNumbers")
void testIsEvenRange(int number) {
  Assertions.assertTrue(MathTools.isEven(number));
}

static IntStream generateEvenNumbers() {
  return IntStream.iterate(0, i -> i + 2).limit(500);
}

When using a @MethodSource, we define a static method that returns a stream or collection. Each value will be sent to our test method as a method argument. In this example, we create an IntStream, which is a stream of integers. The IntStream starts at 0, increments by twos, and limits the total number of items in the stream to 500. This means that the isEven method will be called 500 times, with all even numbers between 0 and 998.

Parameterized tests include support for the following types of sources:

  • ValueSource: Specifies a hardcoded list of integers or Strings.
  • MethodSource: Invokes a static method that generates a stream or collection of items.
  • EnumSource: Specifies an enum, whose values will be passed to the test method. It allows you to iterate over all enum values or include or exclude specific enum values.
  • CsvSource: Specifies a comma-separated list of values.
  • CsvFileSource: Specifies a path to a comma-separated value file with test data.
  • ArgumentSource: Allows you to specify an argument provider that generates a stream of arguments to be passed to your test method.
  • NullSource: Passes null to your test method if you are working with Strings, collections, or arrays. You can include this annotation with other annotations, such as the ValueSource, to write code that tests a collection of values and null.
  • EmptySource: Includes an empty value if you are working with Strings, collections, or arrays.
  • NullAndEmptySource: Includes both null and an empty value if you are working with Strings, collections, or arrays.

Using JUnit 5 with an assertions library

For most circumstances, the default assertions methods will meet your needs, but if you would like to use another, more robust, assertions library, such as AssertJ, Hamcrest, or Truth, JUnit 5 provides support for doing so. In this section, I'll quickly show you how to integrate Hamcrest with Junit 5.

Using Hamcrest with JUnit 5

Hamcrest is based on the concept of a matcher, which can be a very natural way of asserting whether or not the result of a test is in a desired state. If you have not used Hamcrest, the examples below will better describe what it does and how it works.

The first thing we need to do is add the following additional dependency to our POM file:


<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest-library</artifactId>
    <version>2.2</version>
    <scope>test</scope>
</dependency>

Next, when we want to use Hamcrest in our test classes, we need to leverage the org.hamcrest.MatcherAssert.assertThat method, which works in combination with one or more of its matchers. For example, a test for String equality might look like this:

assertThat(name, is("Steve"));

Or if you prefer:

assertThat(name, equalsTo("Steve"));

Both of these matchers do the same thing—the is() method is just syntactic sugar for equalTo().

Hamcrest defines the following common matchers:

  • Objects: equalTo, hasToString, instanceOf, isCompatibleType, notNullValue, nullValue, sameInstance
  • Text: equalToIgnoringCase, equalToIgnoringWhiteSpace, containsString, endsWith, startsWith
  • Numbers: closeTo, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo
  • Logical: allOf, anyOf, not
  • Collections: array (compare an array to an array of matchers), hasEntry, hasKey, hasValue, hasItem, hasItems, hasItemInArray

Listing 4 shows a few examples of using Hamcrest in a JUnit 5 test class.

Listing 4. Using Hamcrest in a JUnit 5 test class (HamcrestDemoTest.java)


package com.javaworld.geekcap.hamcrest;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

class HamcrestDemoTest {
    @Test
    @DisplayName("String Examples")
    void stringExamples() {
        String s1 = "Hello";
        String s2 = "Hello";

        assertThat("Comparing Strings", s1, is(s2));
        assertThat(s1, equalTo(s2));
        assertThat(s1, sameInstance(s2));
        assertThat("ABCDE", containsString("BC"));
        assertThat("ABCDE", not(containsString("EF")));
    }

    @Test
    @DisplayName("List Examples")
    void listExamples() {
        // Create an empty list
        List<String> list = new ArrayList<>();
        assertThat(list, isA(List.class));
        assertThat(list, empty());

        // Add a couple items
        list.add("One");
        list.add("Two");
        assertThat(list, not(empty()));
        assertThat(list, hasSize(2));
        assertThat(list, contains("One", "Two"));
        assertThat(list, containsInAnyOrder("Two", "One"));
        assertThat(list, hasItem("Two"));
    }

    @Test
    @DisplayName("Number Examples")
    void numberExamples() {
        assertThat(5, lessThan(10));
        assertThat(5, lessThanOrEqualTo(5));
        assertThat(5.01, closeTo(5.0, 0.01));
    }
}

One thing that I like about Hamcrest is that it is very easy to read. The stringExamples compares two Strings for equality and then checks for substrings. Note the use of not() combined with containsString(). The listExamples creates a new list, validates that it is a List.class and that it is empty. Then it adds two items, and validates that it is not empty and contains two elements. Finally, it validates that it contains the two Strings, "One" and "Two", that it contains those Strings in any order, and that it has the item "Two". The numberExamples checks to see that 5 is less than 10, that 5 is less than or equal to 5, and that the double 5.01 is close to 5.0 with a delta of 0.01.

If you're new to Hamcrest, I encourage you to learn more about it from the Hamcrest website.

JUnit 5's test lifecycle

For many tests, there are things that you might want to do before and after each of your test runs and before and after all of your tests run. For example, if you were testing database queries, you might want to set up a connection to a database and import a schema before all the tests run, insert test data before each individual test runs, clean up the database after each test runs, and then delete the schema and close the database connection after all the tests run.

JUnit 5 provides the following annotations that you can add to methods in your test class to do this:

  • @BeforeAll: A static method in your test class that is called before all of its tests run.
  • @AfterAll: A static method in your test class that is called after all of its tests run.
  • @BeforeEach: A method that is called before each individual test runs.
  • @AfterEach: A method that is called after each individual test runs.

Listing 5 shows a very simple example that logs the invocations of the various lifecycle methods.

Listing 5. Logging the invocations of JUnit 5 lifecycle methods (LifecycleDemoTest.java)


package com.javaworld.geekcap.lifecycle;

import org.junit.jupiter.api.*;

public class LifecycleDemoTest {

    @BeforeAll
    static void beforeAll() {
        System.out.println("Connect to the database");
    }

    @BeforeEach
    void beforeEach() {
        System.out.println("Load the schema");
    }

    @AfterEach
    void afterEach() {
        System.out.println("Drop the schema");
    }

    @AfterAll
    static void afterAll() {
        System.out.println("Disconnect from the database");
    }

    @Test
    void testOne() {
        System.out.println("Test One");
    }

    @Test
    void testTwo() {
        System.out.println("Test Two");
    }
}

The output from running this test prints the following:


Connect to the database
Load the schema
Test One
Drop the schema
Load the schema
Test Two
Drop the schema
Disconnect from the database

As you can see from this output, the beforeAll method is called first and can do something like connect to a database or create a large data structure into memory. Next, the beforeEach method does things to prepare the data for each test, such as populating a test database with an expected set of data. Then the first test runs, followed by the afterEach method. This process of: beforeEach, test, and afterEach continues until all the tests have completed. Finally, the afterAll method cleans up the test environment, such as by disconnecting from a database.

New in JUnit 5: Tags

Before wrapping up this introduction to the core of JUnit 5, I'll show you how to use tags to selectively run different test cases in different scenarios. Tags are used to identify and filter specific tests that you want to run in different scenarios. For example, you can tag a test class or a test method as an integration test and another as development. The names and uses of the tags are all up to you.

We'll create three new test classes and tag two of them as development and one as production, presumably to differentiate between tests you want to run when building for different environments. Listings 6, 7, and 8 show these three simple tests.

Listing 6. Tags, Test 1 (TestOne.java)


package com.javaworld.geekcap.tags;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("Development")
class TestOne {
    @Test
    void testOne() {
        System.out.println("Test 1");
    }
}

Listing 7. Tags, Test 2 (TestTwo.java)


package com.javaworld.geekcap.tags;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("Development")
class TestTwo {
    @Test
    void testTwo() {
        System.out.println("Test 2");
    }
}

Listing 8. Tags, Test 3 (TestThree.java)

package com.javaworld.geekcap.tags;

import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;

@Tag("Production")
class TestThree {
    @Test
    void testThree() {
        System.out.println("Test 3");
    }
}

Tags are implemented through annotations, and you can annotate either an entire test class or individual methods in a test class; furthermore, a class or a method can have multiple tags. In this example, TestOne and TestTwo are annotated with the "Development" tag, and TestThree is annotated with the "Production" tag. We can filter test runs in different ways based on tags. The simplest of these is to specify a test in your Maven command line; for example, the following only executes tests tagged as "Development":

mvn clean test -Dgroups="Development"

The groups property allows you to specify a comma-separated list of tag names for the tests that you want JUnit 5 to run. Executing this yields the following output:

[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.javaworld.geekcap.tags.TestOne
Test 1
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.029 s - in com.javaworld.geekcap.tags.TestOne
[INFO] Running com.javaworld.geekcap.tags.TestTwo
Test 2
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.001 s - in com.javaworld.geekcap.tags.TestTwo

Likewise, we could execute just the "Production" tests as follows:

mvn clean test -Dgroups="Production"

Or both "Development" and "Production" as follows:

mvn clean test -Dgroups="Development, Production"

In addition to the groups property, JUnit 5 allows you to use an excludedGroups property to execute all tests that do not have the specified tag. For example, in a development environment, we do not want to execute the production tests, so we could execute the following:

mvn clean test -DexcludedGroups="Production"

This is helpful because in a large application you can have literally thousands of tests. If you wanted to create this environmental differentiation and add some new production tests, you would not want to have to go back and add a development tag to the other 10,000 tests.

Finally, you can add these same groups and excludedGroups fields to the surefire plug-in in your Maven POM file. You can also control these fields using Maven profiles. I encourage you to review the JUnit 5 User Guide to learn more about tags.

Introduction to Mock objects using Mockito

Thus far we have only reviewed testing simple methods that do not rely on external dependencies, but this is far from normal for large applications. For example, a business service probably relies on either a database or web service call to retrieve the data that it operates on. So how would we test a method in such a class? And how would we simulate problematic conditions, such as a database connection error or timeout?

The strategy of mock objects is to analyze the class under test and create mock versions of all of its dependencies, creating the scenarios that we want to test. You can do this manually—which is a lot of work—or you could leverage a tool like Mockito, which simplifies the creation and injection of mock objects into your classes. Mockito provides a simple API to create mock implementations of your dependent classes, inject the mocks into your classes, and control the behavior of the mocks.

The example in Listing 9 shows the source code for a simple repository.

Listing 9. Example repository (Repository.java)

package com.javaworld.geekcap.mockito;

import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;

public class Repository {
    public List<String> getStuff() throws SQLException {
        // Execute Query

        // Return results
        return Arrays.asList("One", "Two", "Three");
    }
}

Listing 10 shows the source code for a service that uses the above repository.

Listing 10. Example service (Service.java)


package com.javaworld.geekcap.mockito;

import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class Service {
    private Repository repository;

    public Service(Repository repository) {
        this.repository = repository;
    }

    public List<String> getStuffWithLengthLessThanFive() {
        try {
            return repository.getStuff().stream()
                    .filter(stuff -> stuff.length() < 5)
                    .collect(Collectors.toList());
        } catch (SQLException e) {
            return Arrays.asList();
        }
    }
}

The Repository in Listing 9 has a single method, getStuff, that would presumably connect to a database, execute a query, and return the results. In this example, it simply returns a list of three Strings. The Service in Listing 10 receives the Repository through its constructor and defines a single method, getStuffWithLengthLessThanFive, which returns all Strings with a length less than 5. If the repository throws a SQLException then it returns an empty list.

Unit testing with JUnit 5 and Mockito

Now let's look at how we can test our service using JUnit 5 and Mockito. Listing 11 shows the source code for a ServiceTest class.

Listing 11. Testing the service (ServiceTest.java)

package com.javaworld.geekcap.mockito;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;

import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;

@ExtendWith(MockitoExtension.class)
class ServiceTest {
    @Mock
    Repository repository;

    @InjectMocks
    Service service;

    @Test
    void testSuccess() {
        // Setup mock scenario
        try {
            Mockito.when(repository.getStuff()).thenReturn(Arrays.asList("A", "B", "CDEFGHIJK", "12345", "1234"));
        } catch (SQLException e) {
            e.printStackTrace();
        }

        // Execute the service that uses the mocked repository
        List<String> stuff = service.getStuffWithLengthLessThanFive();

        // Validate the response
        Assertions.assertNotNull(stuff);
        Assertions.assertEquals(3, stuff.size());
    }

    @Test
    void testException() {
        // Setup mock scenario
        try {
            Mockito.when(repository.getStuff()).thenThrow(new SQLException("Connection Exception"));
        } catch (SQLException e) {
            e.printStackTrace();
        }

        // Execute the service that uses the mocked repository
        List<String> stuff = service.getStuffWithLengthLessThanFive();

        // Validate the response
        Assertions.assertNotNull(stuff);
        Assertions.assertEquals(0, stuff.size());
    }
}

The first thing to notice about this test class is that it is annotated with @ExtendWith(MockitoExtension.class). The @ExtendWith annotation is used to load a JUnit 5 extension. JUnit defines an extension API, which allows a third-party vendor like Mockito to hook into the lifecycle of running test classes and add additional functionality. The MockitoExtension looks at the test class, finds member variables annotated with the @Mock annotation, and creates a mock implementation of those variables. It then finds member variables annotated with the @InjectMocks annotation and attempts to inject its mocks into those classes, using either construction injection or setter injection.

In this example, MockitoExtension finds the @Mock annotation on the Repository member variable, so it creates a mock implementation of it and assigns it to the repository variable. When it sees the @InjectMocks annotation on the Service member variable, it creates an instance of the Service class, passing the mock Repository to its constructor. This allows us to control the behavior of the mock Repository class using Mockito's APIs.

In the testSuccess method, we use the Mockito API to return a specific result set when its getStuff method is called. The API works as follows: the Mockito::when defines the condition, which in this case is the invocation of the repository.getStuff() method. The when() method returns a org.mockito.stubbing.OngoingStubbing instance, which defines a set of methods that determine what to do when the specified method is called. In this case, we invoke the thenReturn() method to tell the stub to return a specific List of Strings.

At this point, we have a Service instance with a mock Repository. When the Repository's getStuff method is called, it returns a list of five known strings. We invoke the Service's getStuffWithLengthLessThanFive() method, which will invoke the Repository's getStuff() method, and return a filtered list of Strings whose length is less than five. We can then assert that the returned list is not null and that the size of it is three. This process allows us to test the logic in the specific Service method, with a known response from the Repository.

The testException method configures Mockito so that when the Repository's getStuff() method is called, it throws an SQLException. If this happens, the Service should not throw an exception; rather, it should return an empty list.

Mockito is a powerful tool and we've only scratched the surface of what it can do. If you've ever wondered how you can test abhorrent conditions—such as network, database, timeout, or other I/O error conditions—Mockito is the tool for you, and it works very elegantly with JUnit 5. If you do run into situations that Mockito does not support, such as mocking static member variables or private constructors, then there is another powerful but complex tool called PowerMock.

Conclusion

In this article, I've quickly introduced you to some of the highlights of working with JUnit 5. I showed you how to configure a Maven project to use JUnit 5 and how to write tests using the @Test and @ParameterizedTest annotations. I then introduced most of the JUnit 5 lifecycle annotations, reviewed the use and benefits of filter tags, and walked through integrating JUnit 5 with Hamcrest. Finally, I introduced Mockito and demonstrated how to use mock objects to test some of your more robust Java classes and scenarios.

We'll build on this foundation in the second half of this article, where you'll learn how to integrate JUnit 5 with the Spring framework. You'll learn how to use JUnit 5's built-in and third-party classes and extensions to test your Spring web controllers, services, and repositories.