ARTICLE

JUnit 5 Architecture

Manning Publications
12 min readJan 20, 2020

From JUnit in Action, Third Edition by Catalin Tudose

This article dives into Junit 5’s architecture.

_________________________________________________________________

Take 37% off JUnit in Action, Third Edition. Just enter fcctudose into the discount box at checkout at manning.com.
_________________________________________________________________

JUnit 5 architecture

It’s time for a new approach. It hasn’t come instantly; it required reflection, and the shortcomings of JUnit 4 are a good input for the needed improvements. Architects know the problems, and they decided to go on the path of reduced sizes and modularity.

JUnit 5 modularity

A new approach, a modular one, was necessary in order to allow the evolution of the JUnit framework. Its architecture had to allow JUnit to interact with different programmatic clients, with different tools and IDEs. The logical separation of concerns required:

  • An API to write tests, dedicated mainly to the developers.
  • A mechanism for discovering and running the tests.
  • An API to allow the easy interaction with IDEs and tools and to run the tests from them.

As a consequence, the resulting JUnit 5 architecture contained three modules (fig. 1):

  • JUnit Platform, which serves as a foundation for launching testing frameworks on the JVM (Java Virtual Machine), also provides an API to launch tests from either the console, IDEs, or build tools.
  • JUnit Jupiter, the combination of the new programming model and extension model for writing tests and extensions in JUnit 5. The name has been chosen from the fifth planet of our Solar System, which is also the largest one.
  • JUnit Vintage, a test engine for running JUnit 3 and JUnit 4 based tests on the platform, ensuring the necessary backwards compatibility.
Figure 1 The modular architecture of JUnit 5

JUnit 5 Platform

Going further with the modularity idea, we’ll have a brief look at the artifacts contained into the JUnit 5 Platform (fig. 2):

  • junit-platform-commons, an internal common library of JUnit, intended solely for usage within the JUnit framework itself. Any usage by external parties isn’t supported.
  • junit-platform-console, which provides support for discovering and executing tests on the JUnit Platform from the console.
  • junit-platform-console-standalone an executable JAR with all dependencies included. It’s used by Console Launcher, a command-line Java application that lets you launch the JUnit Platform from the console. For example, it can be used to run JUnit Vintage and JUnit Jupiter tests and print test execution results to the console.
  • junit-platform-engine, a public API for test engines.
  • junit-platform-launcher, a public API for configuring and launching test plans, typically used by IDEs and build tools.
  • junit-platform-runner, a runner for executing tests and test suites on the JUnit Platform in a JUnit 4 environment.
  • junit-platform-suite-api, which contains the annotations for configuring test suites on the JUnit Platform.
  • junit-platform-surefire-provider, which provides support for discovering and executing tests on the JUnit Platform using Maven Surefire.
  • junit-platform-gradle-plugin, which provides support for discovering and executing tests on the JUnit Platform using Gradle.

JUnit 5 Jupiter

JUnit Jupiter is the combination of the new programming model (annotations, classes, methods) and extension model for writing tests and extensions in JUnit 5. The Jupiter sub-project provides a TestEngine for running Jupiter based tests on the platform. In contrast to the previously existing runners and rules extension points in JUnit 4, the JUnit Jupiter extension model consists of a single, coherent concept: the Extension API.

The artifacts contained into the JUnit Jupiter are:

  • junit-jupiter-api, the JUnit Jupiter API for writing tests and extensions.
  • junit-jupiter-engine, the JUnit Jupiter test engine implementation, only required at runtime.
  • junit-jupiter-params, which provides support for parameterized tests in JUnit Jupiter.
  • junit-jupiter-migrationsupport, which provides migration support from JUnit 4 to JUnit Jupiter, and it’s required only for running selected JUnit 4 rules.

JUnit 5 Vintage

JUnit Vintage provides a TestEngine for running JUnit 3 and JUnit 4 based tests on the platform. JUnit 5 Vintage only contains junit-vintage-engine, the engine implementation to execute tests written in JUnit 3 or 4. For this you also need the JUnit 3 or 4 JARs.

This is useful in order to interact with the old tests through JUnit 5. It’s likely that you may need to work on your projects with JUnit 5, but still support many old tests. JUnit 5 Vintage is the solution for this situation!

The big picture of the JUnit 5 architecture

To put everything head to head and show how the full architecture works, we’ll say that JUnit Platform provides the facilities to run different kinds of tests: JUnit 5 tests, old JUnit 3 and 4 tests, third-party tests (figure 2).

Figure 2 The big picture of the JUnit 5 architecture.

In more detail (figure 3):

  • The test APIs provide the facilities for different test engines: junit-jupiter-api for JUnit 5 tests; junit-4.12 for legacy tests; custom engines for third-party tests.
  • The test engines mentioned above are created by extending the junit-platform-engine public API, part of the JUnit 5 Platform.
  • The junit-platform-launcher public API provide the facilities to discover tests inside the JUnit 5 Platform, for build tools like Maven or Gradle or for IDEs.
Figure 3 The detailed picture of the JUnit 5 architecture.

Besides the modular architecture, JUnit 5 also provides the extensions mechanism.

We underline the fact that the architecture of a system strongly determines its capabilities and its behavior. Understanding the architecture of both JUnit 4 and JUnit 5 helps you easily apply their capabilities in practice, write efficient tests, and analyze the implementation alternatives. They help you fasten the pace at which you gain the skills of a programmer who masters unit testing.

Rules vs the extension model

In order to put face-to-face the rules model of JUnit 4 and the extension model of JUnit 5, let’s use an extended Calculator class (listing 1). It’s used by the developers at Test It Inc. to execute mathematical operations, from verifying their systems under test. They’re interested in testing the methods that may throw exceptions. One rule which has been extensively used by the Test It Inc tests code is ExpectedException. It can be easily replaced by the JUnit 5 assertThrows method.

Listing 1 The extended Calculator class

public class Calculator {



public double sqrt(double x) { (1)
if (x < 0) {
throw new
IllegalArgumentException("Cannot extract the square (2)
root of a negative value"); (2)
}
return Math.sqrt(x); (1)
}

public double divide(double x, double y) { (3)
if (y == 0) {
throw new ArithmeticException("Cannot divide by zero"); (4)
}
return x/y; (3)
}
}

The logic that may throw exceptions into the Calculator class does the following:

  1. Declares a method to calculate the square root of a number (1). In case the number is negative, an exception containing a particular message is created and thrown (2).
  2. Declares a method to divide two numbers (3). In case the second number is zero, an exception containing a particular message is created and thrown (4).

Listing 2 provides an example that specifies which exception message is expected during the execution of the test code using the new functionality of the Calculator class above.

Listing 2 The JUnit4RuleExceptionTester class

public class JUnit4RuleExceptionTester {
@Rule (1)
public ExpectedException expectedException = (1)
ExpectedException.none(); (1)

private Calculator calculator = new Calculator(); (2)

@Test
public void expectIllegalArgumentException() {
expectedException.expect(IllegalArgumentException.class); (3)
expectedException.expectMessage("Cannot extract the square root (4)
of a negative value"); (4)
calculator.sqrt(-1); (5)
}

@Test
public void expectArithmeticException() {
expectedException.expect(ArithmeticException.class); (6)
expectedException.expectMessage("Cannot divide by zero"); (7)
calculator.divide(1, 0); (8)
}
}

Into the previous JUnit 4 example, we do the following:

  1. We declare an ExpectedException field annotated with @Rule. The @Rule annotation must be applied either on a public non-static field or on a public non-static method (1). The ExpectedException.none() factory method creates an unconfigured ExpectedException.
  2. We initialize an instance of the Calculator class whose functionality we’re testing (2).
  3. The ExpectedException is configured to keep the type of exception (3) and the message (4), before being thrown by invoking the sqrt method at line (5).
  4. The ExpectedException is configured to keep the type of exception (6) and the message (7), before being thrown by invoking the divide method at line (8).

Now, we move our attention to the new JUnit 5 approach.

Listing 3 The JUnit5ExceptionTester class

public class JUnit5ExceptionTester {
private Calculator calculator = new Calculator(); (1’)

@Test
public void expectIllegalArgumentException() {
assertThrows(IllegalArgumentException.class, (2’)
() -> calculator.sqrt(-1)); (2’)
}

@Test
public void expectArithmeticException() {
assertThrows(ArithmeticException.class, (3’)
() -> calculator.divide(1, 0)); (3’)
}
}

Into this JUnit 5 example, we do the following:

  1. We initialize an instance of the Calculator class whose functionality we’re testing (1’).
  2. We assert that the execution of the supplied calculator.sqrt(-1) executable throws an IllegalArgumentException (2’).
  3. We assert that the execution of the supplied calculator.divide(1, 0) executable throws an ArithmeticException (3’).

We remark the clear difference in code clarity and code length between JUnit 4 and JUnit 5. The effective testing JUnit 5 code is 13 lines, the effective JUnit 4 code is twenty lines. We don’t need to initialize and manage any additional rule. The testing JUnit 5 methods contain one line each.

Another largely used rule that Test It Inc would like to migrate is TemporaryFolder. The TemporaryFolder rule allows the creation of files and folders that should be deleted when the test method finishes (whether it passes or fails). As the tests of the Test It Inc projects work intensively with temporary resources, this step is required. The JUnit 4 rule has been replaced with the @TempDir annotation in JUnit 5. Listing 4 presents the JUnit 4 approach.

Listing 4 The JUnit4RuleTester class

public class JUnit4RuleTester {
@Rule
public TemporaryFolder folder = new TemporaryFolder(); (1)

@Test
public void testTemporaryFolder() throws IOException {
File createdFolder = folder.newFolder("createdFolder"); (2)
File createdFile = folder.newFile("createdFile.txt"); (2)
assertTrue(createdFolder.exists()); (3)
assertTrue(createdFile.exists()); (3)
}
}

Into this example, we do the following:

  1. We declare a TemporaryFolder field annotated with @Rule and initialize it. The @Rule annotation must be applied either on a public field or on a public method (1).
  2. We use the TemporaryFolder field to create a folder and a file (2). These ones are to be found into the Temp folder of your user profile into the operating system.
  3. We check the existence of the temporary folder and of the temporary file (3).

Now, we move our attention to the new JUnit 5 approach (listing 5).

Listing 5 The JUnit5TempDirTester class

public class JUnit5TempDirTester {
@TempDir (1’)
Path tempDir; (1’)

@Test
public void testTemporaryFolder() throws IOException {
assertTrue(Files.isDirectory(tempDir)); (2’)
Path createdFile = Files.createFile( (3’)
tempDir.resolve("createdFile.txt") (3’)
); (3’)

assertTrue(createdFile.toFile().exists()); (3’)

}
}

Into the previous JUnit 5 example, we do the following:

  1. We declare a @TempDir annotated field (1’).
  2. We check the creation of this temporary directory before the execution of the test (2’).
  3. We create a file within this directory and check its existence (3’).

The advantage of the JUnit 5 extension approach is that we don’t have to create the folder by ourselves through a constructor, but the folder is automatically created once we annotate a field with @TempDir.

We move our attention to replacing our own custom rule. Test It Inc. has defined some own rules for its tests. This is particularly useful when some types of tests need similar additional actions before and after their execution.

In JUnit 4, the Test It Inc. engineers needed their additional own actions to be executed before and after the execution of a test. Consequently, they have created their own classes which implement the TestRule interface. To do this, one has to override the apply(Statement, Description) method which returns an instance of Statement. Such an object represents the tests within the JUnit runtime and Statement#evaluate()runs them. The Description object describes the individual test. We can use it to read information about the test through reflection.

Listing 6 The CustomRule class

public class CustomRule implements TestRule {                           (1)
private Statement base; (2)
private Description description; (2)

@Override
public Statement apply(Statement base, Description description) {
this.base = base; (3)
this.description = description; (3)
return new CustomStatement(base, description); (3)
}


}

To clearly show how to define our own rules, we look at listing 6, where we do the following:

  1. We declare our CustomRule class that implements the TestRule interface (1).
  2. We keep references to a Statement field and to a Description field (2) and we use them into the apply method that returns a CustomStatement (3).

Listing 7 The CustomStatement class

public class CustomStatement extends Statement {                        (1)
private Statement base; (2)
private Description description; (2)

public CustomStatement(Statement base, Description description) {
this.base = base; (3)
this.description = description; (3)
}

@Override (4)
public void evaluate() throws Throwable { (4)
System.out.println(this.getClass().getSimpleName() + " " + (4)
description.getMethodName() + " has started" ); (4)
try { (4)
base.evaluate(); (4)
} finally { (4)
System.out.println(this.getClass().getSimpleName() + " " + (4)
description.getMethodName() + " has finished"); (4)
} (4)
} (4)
}

Into listing 7, we do the following:

  1. We declare our CustomStatement class that extends the Statement class (1).
  2. We keep references to a Statement field and to a Description field (2) and we use them as arguments of the constructor (3).
  3. We override the inherited evaluate method and call base.evaluate() inside it (4).

Listing 8 The JUnit4CustomRuleTester class

public class JUnit4CustomRuleTester {

@Rule (1)
public CustomRule myRule = new CustomRule(); (1)

@Test (2)
public void myCustomRuleTest() { (2)
System.out.println("Call of a test method");
}
}

Into listing 8, we use the previously defined CustomRule by doing the following:

  1. We declare a public CustomRule field and we annotate it with @Rule (1).
  2. We create the myCustomRuleTest method and annotate it with @Test (2).

The result of the execution of this test is shown in figure 1. As the engineers from Test It Inc. needed, the effective execution of the test is surrounded by the additional messages provided into the evaluate method of the CustomStatement class.

Figure 4 The result of the execution of JUnit4CustomRuleTester.

We now turn our attention to the JUnit 5 approach. The engineers from Test It Inc would like to migrate their own rules as well. JUnit 5 allows similar effects as in the case of the JUnit 4 rules by introducing the own extensions. The code is shorter and it relies on the declarative annotations style. We first define the CustomExtension class, which is used as an argument of the @ExtendWith annotation on the tested class.

Listing 9 The CustomExtension class

public class CustomExtension implements AfterEachCallback,             (1’) BeforeEachCallback {                                                   (1’)
@Override (2’)
public void afterEach(ExtensionContext extensionContext) (2’)
throws Exception { (2’)
System.out.println(this.getClass().getSimpleName() + " " + (2’)
extensionContext.getDisplayName() + " has started" ); (2’)
}

@Override (3’)
public void beforeEach(ExtensionContext extensionContext) (3’)
throws Exception { (3’)
System.out.println(this.getClass().getSimpleName() + " " + (3’)
extensionContext.getDisplayName() + " has finished"); (3’)
}
}

In listing 9, we do the following:

  1. We declare CustomExtension as implementing the AfterEachCallback and BeforeEachCallback interfaces (1’).
  2. We override the afterEach method, to be executed after each test method from the testing class which is extended with CustomExtension (2’).
  3. We override the beforeEach method, to be executed before each test method from the testing class is extended with CustomExtension (3’).

Listing 10 The JUnit5CustomExtensionTester class

@ExtendWith(CustomExtension.class)                                     (1’)
public class JUnit5CustomExtensionTester {

@Test (2’)
public void myCustomRuleTest() { (2’)
System.out.println("Call of a test method"); (2’)
}
}

In listing 10, we do the following:

  1. We extend JUnit5CustomExtensionTester with the CustomExtension class (1’).
  2. We create the myCustomRuleTest method and annotate it with @Test (2).

As the test class is extended with the CustomExtension class, the previously defined beforeEach and afterEach methods are executed before and after each test method respectively.

We remark the clear difference in code clarity and code length between JUnit 4 and JUnit 5. The JUnit 4 approach needs to work with three classes, the JUnit 5 approach needs to work with only two classes. The code to be executed before and after each test method is isolated into a dedicated method with a clear name. On the side of the testing class, you only need to annotate it with @ExtendWith.

The JUnit 5 extension model may also be used to gradually replace the runners from JUnit 4. For the extensions which have already been created, the migration process is simple. For example:

  • To migrate the Mockito tests, you need to replace, on the tested class, the annotation @RunWith(MockitoJUnitRunner.class) with the annotation @ExtendWith(MockitoExtension.class).
  • To migrate the Spring tests, you need to replace, on the tested class, the annotation @RunWith(SpringJUnit4ClassRunner.class) with the annotation @ExtendWith(SpringExtension.class).
  • At the time of writing this article, there’s no extension for Arquillian tests.

That’s all for this article.

If you want to learn more about the book, check it out on our browser-based liveBook reader here and see this slide deck.

--

--

Manning Publications

Follow Manning Publications on Medium for free content and exclusive discounts.