Link

JUnit 5

Table of contents

  1. Add JUnit 5
  2. Create the first test
  3. IntelliJ and @DisplayName
  4. Parameterized test
  5. Load test data from files (@CsvFileSource)
  6. Custom converters
  7. Tests tagging
    1. Custom annotations
  8. Nested tests
  9. How to verify that an exception is thrown?
  10. Test lifecycle
  11. Recommended reading
  12. Miscellaneous

Add JUnit 5

  1. Add the junit-jupiter aggregator dependency and configure that test task to make use of JUnit 5.

    dependencies {
      testImplementation 'org.junit.jupiter:junit-jupiter:5.7.0-M1'
    }
    
    test {
      useJUnitPlatform()
    }
    

    The Maven repository make use of older Gradle dependency configurations. Use the new dependency configuration testImplementation that replaced the testCompile dependency configuration, as the latter is now deprecated.

    Maven JUnit 5 (Reference)

    Use the latest version when possible.

    You may need to refresh Gradle to see the changes in the IDE.

    Refresh Gradle

    You can press the [command] + [shift] + [I] shortcut to refresh Gradle and update the external dependencies. The new libraries will be included under the External Libraries, a shown next.

    JUnit 5 Dependency

Create the first test

A player is playing a dice game that requires 10 or more to win. Write a method that determines whether the player has won. This method will take two integers as an input and will return true if the sum of the given integers is equal to or greater than 10.

  1. Open the App.java class, press [command] + [shift] + [T] and select the Create New Test… menu option

    Create new Test

    A warning will appear if the test directory is missing.

    No Test Roots Found

    Click Cancel and create the test directory under src folder first.

    New Test Directory

    Create the test class.

    Create Test

    A blank test class under the test directory will be created.

    package demo;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    class AppTest {
    
    }
    

    Please refer to Gradle documentation for more information about the project structure.

  2. Provide a better description

    Update file: src/test/java/demo/AppTest.java

    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    
    @DisplayName( "Dice game test" )
    class AppTest {
    
    }
    
  3. Write the first test

    Update file: src/test/java/demo/AppTest.java

    ⓘ NoteThe test method does not have the static key word.
    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    
    import static org.junit.jupiter.api.Assertions.fail;
    
    @DisplayName( "Dice game test" )
    class AppTest {
    
     @Test
     @DisplayName( "should return true if the sum of the given integers is equal to or greater than 10, false otherwise" )
     void shouldReturnTrueIfWon() {
       fail( "Simulating error" );
     }
    }
    

    This test fails on purpose.

  4. Run the test

    $ ./gradlew clean test
    
    > Task :test FAILED
    
    Dice game test > should return true if the sum of the given integers is equal to or greater than 10, false otherwise FAILED
      org.opentest4j.AssertionFailedError at AppTest.java:14
    
    ...
    
    BUILD FAILED in 1s
    4 actionable tasks: 2 executed, 2 up-to-date
    
  5. Add the proper assertion

    Update file: src/test/java/demo/AppTest.java

    ⚠ The following example does not compile!!
    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    
    import static org.junit.jupiter.api.Assertions.assertTrue;
    
    @DisplayName( "Dice game test" )
    class AppTest {
    
      @Test
      @DisplayName( "should return true if the sum of the given integers is equal to or greater than 10, false otherwise" )
      void shouldReturnTrueIfWon() {
        assertTrue( App.hasWon( 5, 5 ) );
      }
    }
    

    Add a basic implementation (that throws an exception when invoked on purpose)

    Update file: src/main/java/demo/App.java

    package demo;
    
    public class App {
      public static boolean hasWon( final int a, final int b ) {
        throw new UnsupportedOperationException("Coming soon...");
      }
    }
    

    Run the tests

    $ ./gradlew clean test
    

    The test will fail as expected.

  6. Implement the functionality

    Update file: src/main/java/demo/App.java

    package demo;
    
    public class App {
      public static boolean hasWon( final int a, final int b ) {
        return a + b >= 10;
      }
    }
    
    ⓘ NoteNote that in TDD, we would simply return true (just enough to make the test passes).

    Run the tests again, this time these should pass.

    $ ./gradlew clean test
    
    BUILD SUCCESSFUL in 1s
    4 actionable tasks: 4 executed
    
  7. The output does not include the tests that were executed.

    Update file: build.gradle

    test {
      useJUnitPlatform()
      testLogging {
        events = ['FAILED', 'PASSED', 'SKIPPED', 'STANDARD_OUT']
      }
    }
    

    Run the tests again.

    ⓘ NoteIt is important to run the clean Gradle task as otherwise the tests may not run given that no changes are made to the code.
    $ ./gradlew clean test
    
    > Task :test
    
    Dice game test > should return true if the sum of the given integers is equal to or greater than 10, false otherwise PASSED
    

IntelliJ and @DisplayName

IntelliJ may not pick up the @DisplayName annotation values. as shown next.

IntelliJ Test Name

This can be easily fixed.

  1. Open IntelliJ preferences and filter for gradle, as shown next

    ⓘ NotePress [command] + [,] to open the preferences for any application running on a Mac OS.

    IntelliJ Preferences Gradle

    Our project is using Gradle to build, run and test our project.

  2. Change the Run tests using to IntelliJ, as shown next.

    IntelliJ Preferences Gradle

  3. Run the test from within IntelliJ

    Open the test class AppTest and press [control] + [shift] + [R]

    IntelliJ Test Name

    The tests names should not reflect the @DisplayName annotation values.

Parameterized test

The following example makes use of the @CsvSource annotation as we have multiple parameters. In case of single parameters, the @ValueSource annotation can be used.

  1. Convert the test to make use of parameters instead

    Update file: src/test/java/demo/AppTest.java

    ⓘ NoteThe @Test annotation is replaced by the @ParameterizedTest annotation.
    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.CsvSource;
    
    import static org.junit.jupiter.api.Assertions.assertTrue;
    
    @DisplayName( "Dice game test" )
    class AppTest {
    
      @CsvSource( { "5, 5", "5, 6", "6, 5", "6, 6" } )
      @ParameterizedTest( name = "Dice values {0} and {1} should yield a victory" )
      @DisplayName( "should return true if the sum of the given integers is equal to or greater than 10, false otherwise" )
      void shouldReturnTrueIfWon( int a, int b ) {
        assertTrue( App.hasWon( a, b ) );
      }
    }
    
  2. Run the test

    $ ./gradlew clean test
    

    Note that the test will be executed 4 times, one for each line

    > Task :test
    
    Dice game test > Dice values 5 and 5 should yield a victory PASSED
    
    Dice game test > Dice values 5 and 6 should yield a victory PASSED
    
    Dice game test > Dice values 6 and 5 should yield a victory PASSED
    
    Dice game test > Dice values 6 and 6 should yield a victory PASSED
    

Load test data from files (@CsvFileSource)

Instead of putting all the inputs in the source code, we can put the inputs in a CSV file and have these loaded by the test, using the @CsvFileSource annotation.

  1. Create the test sample file

    Create file: src/test/resources/samples/game_won.csv

    Die 1,Die 2
    5,5
    5,6
    6,5
    6,6
    
  2. IntelliJ treats CSV files differently, as shown in the following image.

    IntelliJ see CSV data as table

    A small table like icon will appear at the top right corner and two tabs are shown at the bottom left corner of the editor.

  3. Click on the table like icon to open the CSV preferences for this file.

    IntelliJ see CSV data as table

  4. The data tab is now selected, as shown next.

    IntelliJ see CSV data as table

    We can easily edit the CSV using the data tab which will automatically add the delimiters for us.

  5. Use the CSV file

    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.provider.CsvFileSource;
    
    import static org.junit.jupiter.api.Assertions.assertTrue;
    
    @DisplayName( "Dice game test" )
    class AppTest {
    
      @CsvFileSource( resources = "/samples/game_won.csv", numLinesToSkip = 1 )
      @ParameterizedTest( name = "Dice values {0} and {1} should yield a victory" )
      @DisplayName( "should return true if the sum of the given integers is equal to or greater than 10, false otherwise" )
      void shouldReturnTrueIfWon( int a, int b ) {
        assertTrue( App.hasWon( a, b ) );
      }
    }
    

    The @CsvSource annotation is replaced by the @CsvFileSource annotation.

      @CsvFileSource( resources = "/samples/game_won.csv", numLinesToSkip = 1 )
    

    The first line is skipped as this is the CSV header line and it should not be used as parameter.

Custom converters

In some applications, negative numbers are wrapped in round brackets. For example, negative ten is represented as (10).

package demo;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

@DisplayName( "Custom negative number conversion" )
class AppTest {

  @CsvSource( { "(10), -10" } )
  @ParameterizedTest( name = "The value ''{0}'' should be converted to {1}" )
  void shouldConvertInput( int actual, int expected ) {
    assertEquals( actual, expected );
  }
}

The above will fail to convert the value (10) to -10

$ ./gradlew clean test

> Task :test FAILED

Custom negative number conversion > The value (10) should be converted to -10 FAILED
    org.junit.jupiter.api.extension.ParameterResolutionException at ParameterizedTestMethodContext.java:273
        Caused by: org.junit.jupiter.params.converter.ArgumentConversionException at DefaultArgumentConverter.java:122
            Caused by: java.lang.NumberFormatException at NumberFormatException.java:68

1 test completed, 1 failed

...


BUILD FAILED in 1s
5 actionable tasks: 5 executed
  1. Create a custom converter

    package demo;
    
    import org.junit.jupiter.params.converter.ArgumentConversionException;
    import org.junit.jupiter.params.converter.DefaultArgumentConverter;
    import org.junit.jupiter.params.converter.SimpleArgumentConverter;
    
    import java.util.regex.Pattern;
    
    public final class CustomNumberConverter extends SimpleArgumentConverter {
    
      private static final Pattern REGEX = Pattern.compile( "\\(\\d+\\)" );
    
      @Override
      protected Object convert( final Object source, final Class<?> targetType ) throws ArgumentConversionException {
        final String updated = replaceRoundBracketsWithMinus( source );
        return DefaultArgumentConverter.INSTANCE.convert( updated, targetType );
      }
    
      private String replaceRoundBracketsWithMinus( final Object source ) {
        final String value = source.toString();
        if ( REGEX.matcher( value ).matches() ) {
          return "-" + value.subSequence( 1, value.length() - 1 );
        }
        return value;
      }
    }
    
  2. Use the custom converter

    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.params.ParameterizedTest;
    import org.junit.jupiter.params.converter.ConvertWith;
    import org.junit.jupiter.params.provider.CsvSource;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    @DisplayName( "Custom negative number conversion" )
    class AppTest {
    
      @ParameterizedTest( name = "The value ''{0}'' should be converted to {1}" )
      @CsvSource( { "(1), -1", "(10), -10", "(100), -100", "(1000), -1000", "10, 10" } )
      void shouldConvertInput(
        @ConvertWith( CustomNumberConverter.class ) int actual,
        int expected
      ) {
        assertEquals( actual, expected );
      }
    }
    

    The first argument is annotated with the @ConvertWith. JUnit will use this converter to convert the string CSV value to integer.

  3. Run the test

    $ ./gradlew clean test
    
    ...
    
    Custom negative number conversion > The value '(1)' should be converted to -1 PASSED
    
    Custom negative number conversion > The value '(10)' should be converted to -10 PASSED
    
    Custom negative number conversion > The value '(100)' should be converted to -100 PASSED
    
    Custom negative number conversion > The value '(1000)' should be converted to -1000 PASSED
    
    Custom negative number conversion > The value '10' should be converted to 10 PASSED
    
    BUILD SUCCESSFUL in 2s
    5 actionable tasks: 5 executed
    

    The values are converted using our converter and then by the default converter.

Tests tagging

Consider the following test class.

package demo;

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

@DisplayName( "Playing with tags" )
class AppTest {

  @Test
  @DisplayName( "should always run this test" )
  void shouldRunTest() {
  }

  @Test
  @DisplayName( "should only run this test when the slow tag is active" )
  void shouldRunSlowTest() {
  }
}

The test class shown above has two (blank) test methods. The second test method, shouldRunSlowTest() is a slow running test. We may not want to run this test everytime as it would slow development down. With that said, we would like to control when this test runs.

JUnit 5 supports tags which can be used to group tests and only run the tests that we want.

  1. Run the tests

    $ ./gradlew clean test
    
    > Task :test
    
    Playing with tags > should only run this test when the slow tag is active PASSED
    
    Playing with tags > should always run this test PASSED
    
    BUILD SUCCESSFUL in 2s
    5 actionable tasks: 5 executed
    

    Both tests are executed.

  2. Tag the slow test with the @Tag annotation

    Update file: src/test/java/demo/AppTest.java

    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Tag;
    import org.junit.jupiter.api.Test;
    
    @DisplayName( "Playing with tags" )
    class AppTest {
    
      @Test
      @DisplayName( "should always run this test" )
      void shouldRunTest() {
      }
    
      @Test
      @Tag( "slow" )
      @DisplayName( "should only run this test when the slow tag is active" )
      void shouldRunSlowTest() {
      }
    }
    

    The @Tag annotation takes the tag name as its value, "slow" in our example.

  3. Exclude the slow tests

    Update file: build.gradle

    test {
      useJUnitPlatform {
        excludeTags "slow"
      }
    
      testLogging {
        events = ['FAILED', 'PASSED', 'SKIPPED', 'STANDARD_OUT']
      }
    }
    

    Re-run the tests. This time, the slow tests should not be included.

    $ ./gradlew clean test
    
    > Task :test
    
    Playing with tags > should always run this test PASSED
    
    BUILD SUCCESSFUL in 2s
    5 actionable tasks: 5 executed
    
  4. Define a new test type, slowTest

    Update file: build.gradle

    task slowTest(type: Test) {
      description = 'Runs the slow tests.'
      group = 'verification'
    
      useJUnitPlatform {
        includeTags "slow"
      }
    
      testLogging {
        events = ['FAILED', 'PASSED', 'SKIPPED', 'STANDARD_OUT']
      }
    
      shouldRunAfter test
    }
    

    List all gradle tasks.

    $ ./gradlew tasks
    

    The slowTest should be listed under the Verification tasks, as shown next.

    Verification tasks
    ------------------
    check - Runs all checks.
    slowTest - Runs the slow tests.
    test - Runs the unit tests.
    
  5. Run the slow tests

    ./gradlew clean slowTest
    
    > Task :slowTest
    
    Playing with tags > should only run this test when the slow tag is active PASSED
    
    BUILD SUCCESSFUL in 2s
    5 actionable tasks: 5 executed
    
  6. Add the slowTest Gradle task to the build flow

    Our new Gradle task is not part of the build flow. Running ./gradlew build will not run the slow tests. List the Gradle tasks on which build depends.

    $ ./gradlew build taskTree
    

    The build task depends on the check task, which depends on the test task, as shown next.

    :build
    +--- :assemble
    ...
    \--- :check
         \--- :test
              +--- :classes
              |    +--- :compileJava
              |    \--- :processResources
              \--- :testClasses
                   +--- :compileTestJava
                   |    \--- :classes
                   |         +--- :compileJava
                   |         \--- :processResources
                   \--- :processTestResources
    

    Make the Gradle check task depend on the Gradle slowTest task.

    check {
      dependsOn slowTest
    }
    

    Verify that the Gradle check task depends on the slowTest task.

    $ ./gradlew check taskTree
    

    The Gradle check task depends on both the test and the slowTest tasks.

    :check
    +--- :slowTest
    |    +--- :classes
    |    |    +--- :compileJava
    |    |    \--- :processResources
    |    \--- :testClasses
    |         +--- :compileTestJava
    |         |    \--- :classes
    |         |         +--- :compileJava
    |         |         \--- :processResources
    |         \--- :processTestResources
    \--- :test
         +--- :classes
         |    +--- :compileJava
         |    \--- :processResources
         \--- :testClasses
              +--- :compileTestJava
              |    \--- :classes
              |         +--- :compileJava
              |         \--- :processResources
              \--- :processTestResources
    
  7. Run both tests

    ./gradlew clean check
    
    > Task :test
    
    Playing with tags > should always run this test PASSED
    
    > Task :slowTest
    
    Playing with tags > should only run this test when the slow tag is active PASSED
    
    BUILD SUCCESSFUL in 2s
    6 actionable tasks: 6 executed
    

Custom annotations

  1. a

    package demo;
    
    import org.junit.jupiter.api.Tag;
    import org.junit.jupiter.api.Test;
    
    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    @Test
    @Tag( "slow" )
    @Retention( RetentionPolicy.RUNTIME )
    @Target( { ElementType.METHOD } )
    public @interface SlowTest {
    }
    
  2. b

    package demo;
    
    import org.junit.jupiter.api.DisplayName;
    import org.junit.jupiter.api.Test;
    
    @DisplayName( "Playing with tags" )
    class AppTest {
    
      @Test
      @DisplayName( "should always run this test" )
      void shouldRunTest() {
      }
    
      @SlowTest
      @DisplayName( "should only run this test when the slow tag is active" )
      void shouldRunSlowTest() {
      }
    }
    
  3. c

    $ ./gradlew clean check
    
    > Task :test
    
    Playing with tags > should always run this test PASSED
    
    > Task :slowTest
    
    Playing with tags > should only run this test when the slow tag is active PASSED
    
    BUILD SUCCESSFUL in 3s
    6 actionable tasks: 6 executed
    

Nested tests

🚧 Pending 🚧

How to verify that an exception is thrown?

Guava is a set of core Java libraries from Google that includes new collection types (such as multimap and multiset), immutable collections, a graph library, and utilities for concurrency, I/O, hashing, caching, primitives, strings, and more! It is widely used on most Java projects within Google, and widely used by many other companies as well.

dependencies {
  implementation 'com.google.guava:guava:29.0-jre'
}

Example

package demo;

import com.google.common.base.Preconditions;

public class App {

  public static boolean hasWon( final int a, final int b ) {
   Preconditions.checkArgument( a >= 1 && a <= 6, "Invalid dice value %d", a );
   Preconditions.checkArgument( b >= 1 && b <= 6, "Invalid dice value %d", b );
   return a + b >= 10;
  }
}

Passing any invalid value will cause the function to throw an IllegalArgumentException.

App.hasWon( 7 ,1);

Exception

Exception in thread "main" java.lang.IllegalArgumentException: Invalid dice value 7
  at com.google.common.base.Preconditions.checkArgument(Preconditions.java:190)

Testing

package demo;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class AppTest {

  @ParameterizedTest( name = "should throw an IllegalArgumentException({0}) when provided the invalid dice values {1} and {2}" )
  @CsvSource( {
   "Invalid dice value 7, 7, 1",
   "Invalid dice value 7, 1, 7",
   "Invalid dice value 0, 0, 7",
  } )
  void shouldThrowAnExceptionWhen( String expectedErrorMessage, int a, int b ) {
   final IllegalArgumentException exception = assertThrows( IllegalArgumentException.class, () -> App.hasWon( a, b ) );
   assertEquals( expectedErrorMessage, exception.getMessage() );
  }
}

The above example runs three tests.

$ ./gradlew clean test

> Task :test

AppTest > should throw an IllegalArgumentException(Invalid dice value 7) when provided the invalid dice value 7 PASSED

AppTest > should throw an IllegalArgumentException(Invalid dice value 7) when provided the invalid dice value 1 PASSED

AppTest > should throw an IllegalArgumentException(Invalid dice value 0) when provided the invalid dice value 0 PASSED

Test lifecycle

JUnit 5 provides a set of powerful annotations that help managing the tests lifecycle.

Example

package demo;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class AppTest {

  @Test
  public void test1() {
   System.out.println( "@Test 1" );
  }

  @Test
  public void test2() {
   System.out.println( "@Test 2" );
  }

  @BeforeAll
  public static void setupOnce() {
   System.out.println( "@BeforeAll" );
  }

  @AfterAll
  public static void teardownOnce() {
   System.out.println( "@AfterAll" );
  }

  @BeforeEach
  public void setupBeforeEachTest() {
   System.out.println( "@BeforeEach" );
  }

  @AfterEach
  public void teardownAfterEachTest() {
   System.out.println( "@AfterEach" );
  }
}

Run the tests

$ ./gradlew clean test

Will show how each method is called.

> Task :test

AppTest STANDARD_OUT
   @BeforeAll

AppTest > test1() STANDARD_OUT
   @BeforeEach
   @Test 1
   @AfterEach

AppTest > test1() PASSED

AppTest > test2() STANDARD_OUT
   @BeforeEach
   @Test 2
   @AfterEach

AppTest > test2() PASSED

AppTest STANDARD_OUT
   @AfterAll

BUILD SUCCESSFUL in 1s
4 actionable tasks: 4 executed

Note that the methods invoked by an @AfterEach are called even when the tests fail. Also, note that here there is a mixture of static and non static methods.

  1. Mastering Software Testing with JUnit 5 (O’Reilly Books)
  2. Pragmatic Unit Testing in Java 8 with JUnit (O’Reilly Books)

Miscellaneous

  1. @Disabled
  2. TestInfo
  3. TestReporter
  4. MethodOrderer
  5. IgnoreCondition
  6. assertAll()
  7. Assumptions class in Junit 5 :
    1. Assumptions.assumeTrue() – If the condition is true, then run the test, else aborting the test.
    2. Assumptions.false() – If the condition is false, then run the test, else aborting the test.
    3. Assumptions.assumingThat() – is much more flexible, If condition is true then executes, else do not abort test continue rest of code in test.
  8. TestNG (alternative to JUnit)
  9. add an aggregator example