ARTICLE
JUnit 5 Architecture
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.
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).
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.
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:
- 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).
- 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:
- 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). TheExpectedException.none()
factory method creates an unconfiguredExpectedException
. - We initialize an instance of the
Calculator
class whose functionality we’re testing (2). - The
ExpectedException
is configured to keep the type of exception (3) and the message (4), before being thrown by invoking thesqrt
method at line (5). - The
ExpectedException
is configured to keep the type of exception (6) and the message (7), before being thrown by invoking thedivide
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:
- We initialize an instance of the
Calculator
class whose functionality we’re testing (1’). - We assert that the execution of the supplied
calculator.sqrt(-1)
executable throws anIllegalArgumentException
(2’). - We assert that the execution of the supplied
calculator.divide(1, 0)
executable throws anArithmeticException
(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 @TempDi
r 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:
- 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). - We use the
TemporaryFolder
field to create a folder and a file (2). These ones are to be found into theTemp
folder of your user profile into the operating system. - 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:
- We declare a
@TempDir
annotated field (1’). - We check the creation of this temporary directory before the execution of the test (2’).
- 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:
- We declare our
CustomRule
class that implements theTestRule
interface (1). - We keep references to a
Statement
field and to aDescription
field (2) and we use them into theapply
method that returns aCustomStatement
(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:
- We declare our
CustomStatement
class that extends theStatement
class (1). - We keep references to a
Statement
field and to aDescription
field (2) and we use them as arguments of the constructor (3). - We override the inherited
evaluate
method and callbase.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:
- We declare a public
CustomRule
field and we annotate it with@Rule
(1). - 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.
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:
- We declare
CustomExtension
as implementing theAfterEachCallback
andBeforeEachCallback
interfaces (1’). - We override the
afterEach
method, to be executed after each test method from the testing class which is extended withCustomExtension
(2’). - We override the
beforeEach
method, to be executed before each test method from the testing class is extended withCustomExtension
(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:
- We extend
JUnit5CustomExtensionTester
with theCustomExtension
class (1’). - 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.