Core Java

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:

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:

  1. an API two write tests against
  2. 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:

  1. an API two write tests against
  2. a mechanism to discover and run tests
    1. a mechanism to discover and run a specific variant of tests (e.g. JUnit 5)
    2. a mechanism to orchestrate the specific mechanisms
    3. 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 TestNGSpockCucumber, 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?

junit-5-architecture

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.

Nicolai Parlog

Nicolai is a thirty year old boy, as the narrator would put it, who has found his passion in software development. He constantly reads, thinks, and writes about it, and codes for a living as well as for fun.Nicolai is the editor of SitePoint's Java channel, writes a book about Project Jigsaw, blogs about software development on codefx.org, and is a long-tail contributor to several open source projects. You can hire him for all kinds of things.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Back to top button