A small team of dedicated developers is currently working on JUnit 5, the next version of one of Java’s most popular libraries. While the improvements on the surface are deliberately incremental, real innovation happens under the hood, with the potential to redefine testing on the JVM.
The talk will examine those topics in depth and these notes help to follow along.
Setup
All we need to write tests is a small artifact that contains JUnit’s new API:
- Group ID:
org.junit.jupiter
- Artifact ID:
junit-jupiter-api
- Version:
5.0.0-M2
To actually run tests it is easiest to use IntelliJ 2016.2 or greater,
which has basic native JUnit 5 integration.
If IntelliJ is no option, there are other ways to set JUnit 5 up.
New API
The API to write tests against has undergone a thoughtful evolution.
All of the following snippets are valid JUnit 5 tests – can you spot what’s new?
class JUnit5Test { @Test void someTest() { assertTrue(true); } }
Before And After Methods
@BeforeAll static void beforeAll() { ... } @BeforeEach void beforeEach() { ... } @AfterEach void afterEach() { ... } @AfterAll static void afterAll() { ... }
Disabling Tests
@Test @Disabled("Y U No Pass?!") void failingTest() { assertTrue(false); }
@Test @DisabledOnFriday void failingTest() { assertTrue(false); }
Classic Assertions
@Test void someTest() { ... assertEquals( expected, actual, "Should be equal."); }
@Test void someTest() { ... assertEquals( expected, actual, () -> "Should " + "be " + "equal."); }
Extended Assertions
@Test void assertAllProperties() { Address ad = new Address( "City", "Street", "42"); assertAll("address", () -> assertEquals("C", ad.city), () -> assertEquals("Str", ad.street), () -> assertEquals("63", ad.number) ); }
void methodUnderTest() { throw new IllegalStateException(); } @Test void assertExceptions() { assertThrows( Exception.class, this::methodUnderTest); }
@Test void assertExceptions() { Exception ex = expectThrows( Exception.class, this::methodUnderTest); assertEquals("Msg", ex.getMessage()); }
Nesting Tests
class CountTest { // lifecycle and tests @Nested class CountGreaterZero { // lifecycle and tests @Nested class CountMuchGreaterZero { // lifecycle and tests } } }
Naming Tests
@DisplayName("A count") class CountTest { @Nested @DisplayName("when greater zero") class CountGreaterZero { @Test @DisplayName("is positive") void isPositive() { ... } } }
@Test void someTest(MyServer server) { // do something with `server` }
Dynamic Tests
Next thing we’re going to look at are dynamic tests, which make it possible to define tests at run time. To do this we need a @TestFactory
-annotated method that returns DynamicTest
instances. A DynamicTest
is nothing more than a name and a piece of code that can be executed by JUnit.
Say we have a method to test the distance computation of our point class:
void testDistanceComputation(Point p1, Point p2, double expectedDistance) { assertEquals(expectedDistance, p1.distanceTo(p2)); }
We can now create a lot of test data and create an individual test for each “point / point / expected distance”-triple.
@TestFactory Stream<DynamicTest> testDistanceComputations() { List<PointPointDistance> testData = createTestData(); return testData.stream() .map(datum -> DynamicTest.dynamicTest( "Testing " + datum, () -> testDistanceComputation( datum.point1(), datum.point2(), datum.distance() ))); }
We can also use dynamic tests to define tests with lambda expressions.
By calling upon some uncommon language features and a little hacking we can get as far as this:
class PointTest extends LambdaTest {{ ?(A_Great_Test_For_Point -> { // test code }); }}
Extension Model
JUnit 5 has a very promising extension model, which defines a list extension points:
- Test Instance Post Processor
- BeforeAll Callback
- Test and Container Execution Condition
- BeforeEach Callback
- Parameter Resolution
- Before Test Execution
- After Test Execution
- Exception Handling
- AfterEach Callback
- AfterAll Callback
There is an interface for every extension point and we can use annotations to register our implementations of them with JUnit. It will then call them at the appropriate points in the test lifecycle.
Let’s have a look at a simple example where we implement the condition for @DisabledOnFriday
:
public class DisabledOnFridayCondition implements TestExecutionCondition { @Override public ConditionEvaluationResult evaluate(TestExtensionContext context) { if (isFriday()) return ConditionEvaluationResult.disabled("Happy Weekend!"); else return ConditionEvaluationResult.enabled("Fix it!"); } private boolean isFriday() { // ... } }
Architecture
Saving the best for last we will have a look at JUnit 5’s architecture.
Splitting JUnit 5
It isolates “JUnit the platform” from “JUnit the tool” by implementing a clear separation of concerns and splitting itself into three subprojects.
JUnit Jupiter
Everything we have seen so far is part of JUnit Jupiter as it defines the new API we write test against in the artifact junit-jupiter-api
. But it also contains the means to detect and run those tests. In JUnit 5 this responsibility was given to what’s called engines and accordingly the artifact that contains it is called junit-jupiter-engine
.
JUnit Vintage
This is a small subproject that adapts JUnit 3/4 to the engine API so that JUnit 5 can run those tests as well.
JUnit Platform
But JUnit was always more than an API for tests and the means to run them. It doubled as an integration point for various tools and other test frameworks. This is now factored out into JUnit Platform.
Tools can interact with junit-platform-launcher
to command JUnit to run tests. The launcher will then find all registered engines and forward the command to them. To that end it also contains junit-platform-engine
, which defines the APIs the different engines have to implement. They have the task to detect the tests they are responsible for, parse and run them, and return the results.
The Future Of Testing
Separating JUnit Jupiter and JUnit Platform means that the former can be evolved freely without tools depending on implementation details (which was the case with JUnit 3/4) and that the latter can be used by other test frameworks to gain rich tool support – all they have to do is implement an engine.
Because when evaluating test frameworks it is not only their API that is important. Without proper support by IDEs or build tools it is a non-starter for most serious projects to adopt them. This makes it very hard for new frameworks to establish themselves.
Not so with JUnit 5! All an innovative new framework has to do to gain rich tool support is implement a JUnit engine. This might very well cause a wave of new frameworks and herald the next generation of testing on the JVM!