JUnit 5 – Architecture
Now that we know how to set JUnit 5 up and write some tests with it, let’s take a look under the covers. In this post we’ll discuss the JUnit 5 architecture and the reasons why it turned out this way.
Overview
This post is part of a series about JUnit 5:
- Setup
- Basics
- Architecture
- Conditions
- Injection
- …
JUnit 4
Ignoring Hamcrest, JUnit 4 has no dependencies and bundles all functionality in one artifact. This is in stark violation of the Single Responsibility Principle and it shows: developers, IDEs, build-tools, other testing frameworks, extensions; they all depend on the same artifact.
Among this group developers are, for once, the most sanely behaving ones. They usually rely on JUnit’s public API and that’s that.
But other testing frameworks and extensions and especially IDEs and build tools are a different breed: They reach deep into JUnit’s innards. Non-public classes, internal APIs, even private fields are not safe. This way they end up depending on implementation details, which means that the JUnit maintainers can not easily change them when they want to, thus hindering further development.
Of course those tools’ developers did not do this out of spite. To implement all the shiny features, which we value so much, they had to use internals because JUnit 4 does not have a rich enough API to fulfill their requirements.
The JUnit Lambda team set out to make things better with JUnit 5.
JUnit 5
Separating Concerns
Taking a step back it is easy to identify at least two separate concerns:
- an API two write tests against
- a mechanism to discover and run tests
Looking at the second point a little closer we might ask “Which tests?”. Well, JUnit tests, of course. “Yes but which version?” Err… “And what kinds of tests?” Wait, let me… “Just the lame old @Test-annotated methods? What about lambdas?” Ok, ok, shut up already!
To decouple the concrete variant of tests from the concern of running them, the point got split up:
- an API two write tests against
- a mechanism to discover and run tests
- a mechanism to discover and run a specific variant of tests (e.g. JUnit 5)
- a mechanism to orchestrate the specific mechanisms
- an API between them
Architecture
JUnit’s architecture is the result of that line of thought:
- junit5-api (1)
- The API against which developers write tests. Contains all the annotations, assertions, etc. that we saw when we discussed JUnit 5’s basics.
- junit-enginge-api (2c)
- The API all test engines have to implement, so they are accessible in a uniform way. Engines might run typical JUnit tests but alternatively implementations could run tests written with TestNG, Spock, Cucumber, etc.
- junit5-engine (2a)
- An implementation of the junit-engine-api that runs JUnit 5 tests.
- junit4-engine (2a)
- An implementation of the junit-engine-api that runs tests written with JUnit 4. Here, the JUnit 4 artifact (e.g. junit-4.12) acts as the API the developer implements her tests against (1) but also contains the main functionality of how to run the tests. The engine could be seen as an adapter of JUnit 4 for version 5.
- junit-launcher (2b)
- Uses the ServiceLoader to discover test engine implementations and to orchestrate their execution. It provides an API to IDEs and build tools so they can interact with test execution, e.g. by launching individual tests and showing their results.
Makes sense, right?
Most of that structure will be hidden from us front-line developers. Our projects only need a test dependency on the API we are using; everything else will come with our tools.
API Lifecycle
Now, about those internal APIs everybody was using. The team wanted to solve this problem as well and created a lifecycle for its API. Here it is, with the explanations straight from the source:
- Internal
- Must not be used by any code other than JUnit itself. Might be removed without prior notice.
- Deprecated
- Should no longer be used, might disappear in the next minor release.
- Experimental
- Intended for new, experimental features where we are looking for feedback.
- Maintained
- Intended for features that will not be changed in a backwards-incompatible way for at least the next minor release of the current major version. If scheduled for removal, it will be demoted to Deprecated first.
- Stable
- Intended for features that will not be changed in a backwards-incompatible way in the current major version.
Publicly visible classes will be annotated with with @API(usage) where usage is one of these values. This, so the plan goes, gives API callers a better perception of what they’re getting into and the team the freedom to mercilessly change or remove unsupported APIs.
Open Test Alliance
There’s one more thing, though. The JUnit 5 architecture enables IDEs and build tools to use it as a facade for all kinds of testing frameworks (assuming those provide corresponding engines). This way tools would not have to implement framework-specific support but can uniformly discover, execute, and assess tests.
Or can they?
Test failures are typically expressed with exceptions but different test frameworks and assertion libraries do not share a common set. Instead, most implement their own variants (usually extending AssertionError or RuntimeException), which makes interoperability more complex than necessary and prevents uniform handling by tools.
To solve this problem the JUnit Lambda team split off a separate project, the Open Test Alliance for the JVM. This is their proposal:
Based on recent discussions with IDE and build tool developers from Eclipse, Gradle, and IntelliJ, the JUnit Lambda team is working on a proposal for an open source project to provide a minimal common foundation for testing libraries on the JVM.
The primary goal of the project is to enable testing frameworks like JUnit, TestNG, Spock, etc. and third-party assertion libraries like Hamcrest, AssertJ, etc. to use a common set of exceptions that IDEs and build tools can support in a consistent manner across all testing scenarios – for example, for consistent handling of failed assertions and failed assumptions as well as visualization of test execution in IDEs and reports.
Up to now the mentioned projects’ response was underwhelming, i.e. mostly lacking. If you think this is a good idea, you could support it by bringing it up with the maintainers of your framework of choice.
Reflection
We have seen how the JUnit 5 architecture divides the API for writing tests against and the engines for running them into separate parts, splitting the engines further into an API, a launcher using it, and implementations for different test frameworks. This gives users lean artifacts to develop tests against (because they only contain the APIs), testing frameworks only have to implement an engine for their API (because the rest is handled by JUnit), and build tools have a stable launcher to orchestrate test execution.
The next post in this series about JUnit 5 will discuss its extensibility. Stay tuned!
Reference: | JUnit 5 – Architecture from our JCG partner Nicolai Parlog at the CodeFx blog. |