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

1 2 3 Page 2
Page 2 of 3

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"
1 2 3 Page 2
Page 2 of 3
How to choose a low-code development platform