Architectural Analysis of JUnit

posted Originally published at medium.com 8 min read

JUnit is an open-source framework for creating automated object-oriented tests in the Java programming language. It allows you to test a part of the software, such as classes and methods. In order to better understand the framework, and not wanting to limit myself to just using it for testing, this article proposes an architectural analysis of JUnit 5, investigating its main components and communication patterns that interconnect them. First of all, we need to understand what a software architecture is and how systems are organized through components and connectors, and the way projects based on the Plugins and Micro-kernel pattern aim to achieve flexibility, reusability and adaptability — as is the case with JUnit 5.

What is a software architecture?

We can define it simply with:

A software system’s architecture is the set of principal design
decisions made about the system.

When it comes to design decisions, these are things related to the structure of the system, how elements should be organized and composed, functional behavior decisions, and things related to the interaction between system elements.

Software architecture is not merely a set of components, but the backbone that shapes the qualities of a system. In modern systems, architecture is intrinsically linked to the ability to meet critical non-functional requirements (NFR), such as efficiency, ease of evolution, complexity management, and scalability, and others.

Basic concepts of software architecture

The architecture of a software system includes elements that deal with: functionality or behavior, information or data, and interaction. It can be from a single operation a entire system. To deal with this, we have two elements:

  • Component: is a modular, replaceable and encapsulated unit that represents a functional part of the system. This element encapsulate a subset of functionality and/or data in a system’s architecture. At the compilation level, it can be compiled in isolation.

  • Connector: for communication between components, we have the connector. The simplest and most widely used type of connector is procedure call. Interaction, depending on the system, can be critical, which is why connectors have a wide classification and variation — perhaps it doesn’t make sense to go into this in depth here.

Architectural Pattern

Finally, I need to talk about the architectural pattern. We can define it as:

A named collection of architectural design decisions that are applicable to a recurring design problem, parameterized to account for
different software development context in which that problem appears.

In JUnit was used Microkernel Architecture Pattern in which the core (microkernel) is main component, fundamental base on which more complex functionality, while additional features are implemented as plug-ins or extensions. This allows for system isolation and fault tolerance, since the failure of a plug-in does not necessarily paralyze the entire system. In addition, resources can be added, removed, or replaced at runtime or compilation without affecting the core. Furthermore to these requirements, this pattern helps meet other non-functional requirements such as: modularity, by separating the core and plug-ins, better organizing the code and isolating responsibilities; extensibility, by allowing new features to be added without changing the core of the system; customization, by making it possible to adapt the system to different clients or scenarios with different plug-ins; security (in some cases), by allowing plug-ins to be isolated or restricted from accessing sensitive parts of the system.

Now, after this brief conceptualization, we can delve deeper into the subject and better understand the components and how the JUnit architecture works.

Architectural Project

JUnit 5 represents a significant evolution over previous versions, both in terms of architecture, programming features, extensibility and maintainability. It consists of three main modules, each with well-defined responsibilities, working together to provide a robust and adaptable testing framework. As we know, JUnit 5 uses microkernel architecture, and before talking about who the components are, I need to explain what the TestEngine API is. A TestEngine API is a plugin interface, a contract for test plugins, so every plugin that wants to connect to the microkernel must implement this interface. This makes the framework more extensible, allowing third-party testing frameworks to connect to the microkernel by implementing their own TestEngine, while ensuring a consistent and stable execution base. Now we can explore plugins and microkernel.

JUnit Jupiter

Supply a TestEngine with the new features provided by JUnit 5. It is the core API that contains all the annotations, utility classes, and interfaces used to write modern tests with JUnit 5. It has an engine that reads the annotations, organizes and executes the tests. Additionally, it has the API (junit-jupiter-api) that provides modern JUnit 5 annotations, which the end user (tester) uses to create tests.

JUnit Vintage

It offers a TestEngine that allows you to run JUnit 3 and JUnit 4-based tests on the JUnit Platform. This backward compatibility is a crucial aspect, as it facilitates the gradual migration of legacy projects to JUnit 5 without the need to rewrite all existing tests. It has an engine that reads the annotations, organizes and executes the tests, but does not provide a unified annotation API, it reuses what already existed in JUnit 3/4.

JUnit Platform

Last but not least, JUnit Platform acts as a micro-kernel, defining a minimal set of abstractions and interfaces upon which TestEngines (Jupiter and Vintage) and other tools can be built. JUnit Platform establishes a robust and stable interface between JUnit and its clients, such as build tools (Maven, Gradle) and IDEs (IntelliJ IDEA, Eclipse, NetBeans, Visual Studio Code), facilitating integration and test execution. This design pattern promotes a pluggable architecture that is agnostic to the test programming model. JUnit Platform has several packages, I highlight the engine (junit-platform-engine) that creates and runs a test engine and the launcher (junit-platform-launcher) that launches tests.

Test Discovery Phase

Now let’s get into the part that I particularly find most interesting: test discovery and annotation execution flow. A fundamental aspect of the JUnit architecture is the dynamic discovery of TestEngines. By default, TestEngines are discovered at runtime using Java’s ServiceLoader mechanism. This mechanism is a direct implementation of the JUnit 5 extensibility principle, allowing different TestEngines (such as Jupiter, Vintage or third-party) to be plugged and discovered dynamically without the need for modifications to the JUnit Platform code. For the purpose of a TestEngine to be discovered by the ServiceLoader via SPI (Service Provider Interface), a file that allows modules to be plugged dynamically must be added to the engine’s JAR file. I give the example of Vintage and Jupiter.

The Launcher is the main entry point for the client code, which discovers and executes tests. After that the discovery request is created, with the LauncherDiscoveryRequest that accesses the information necessary to discover the tests and containers. To do this, we need to filter with selectors, which can be the name of a Java class, method, the path to a file or directory, etc. JUnit Platform is generic it needs you to tell it exactly what you want to run. Below is an example of how it works adapted from the source code by JUnit:

LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
    .selectors(
      selectPackage("org.example.user"),
      selectClass("org.example.payment.PaymentTests"),
      selectClass(ShippingTests.class),
      selectMethod("org.example.order.OrderTests#test1")
    )
    .build();

From then on, a check is made to see if there are tests related to an EngineTest in the request, through the discover method, called by JUnit Platform, and then a TestDescriptor tree is built, where each node is a container (e.g.: class) or a test (e.g.: method). The tests are organized in a hierarchical tree, which serves as an agnostic representation of the TestEngine. The way the tests are defined internally by the TestEngine is abstracted. I think this is incredible. The microkernel interacts with this unified structure regardless of the origin of the test.

TestDescriptor descriptor = engine.discover(request, uniqueId);

After the discovery phase and the construction of the TestPlan (test tree), the execute method is called to execute the tests represented by the root TestDescriptor and its children. It is at this stage that the execution logic specific to each test framework is triggered. It also reports progress and results via the TestExecutionListener, which receives notifications about the progress and results of test execution by calling methods such as:

  • testPlanExecutionStarted(TestPlan testPlan): Called when the TestPlan execution starts.
  • executionStarted(TestIdentifier testIdentifier): Called when a test or container execution starts.
  • executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult): Called when a test or container execution is finished, regardless of the result.
  • reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry entry): Called when additional report data is published for a specific TestIdentifier.

The Launcher listens to these events and updates reports by sending results to IDEs, terminals, HTML reports, etc.

With this, we can understand the execution flow of JUnit 5 test annotations.
The tester uses the annotation in its code, which is a compile-time metadata. The annotations are provides by org.junit.jupiter.api :

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

...

@Test
void addition() {
    assertEquals(2, calculator.add(1, 1));
}

After that, the annotation is discovered and read by the junit-jupiter-engine module, which is Jupiter’s TestEngine. It scans the classes with reflection to find methods annotated with @Test, etc. The engine transforms these methods into TestDescriptor, which describe what should be executed. Each test, class and method becomes an execution node and then the test execution is initiated. JUnit Platform orchestrates and notifies the events that occur during the test. The tester can follow the events in real time through a graphical interface or terminal, depending on the IDE.
Conclusion

JUnit 5 represents a significant architectural advancement in the domain of Java testing frameworks. Its modularity, expressed in the division between JUnit Platform, JUnit Jupiter, and JUnit Vintage, is not merely a design choice, but a fundamental solution to the extensibility and maintainability challenges seen in previous versions. The JUnit 5 architecture is a testament to a successful design that prioritizes flexibility, compatibility, and a robust and adaptable testing ecosystem.

Did you get a better understanding JUnit 5 Architecture? I can’t go into the architecture any deeper, I don’t have enough knowledge for that yet, but I hope to have given an overview of it and how test execution works. Do you have any questions or find any errors? Let me know, comment below. See you later!

References

R. N. Taylor, N. Medvidovic, and E. M. Dashofy, Software Architecture: Foundations, Theory, and Practice, 1st ed. Hoboken, NJ, USA: Wiley, 2009.

F. Buschmann, R. Meunier, H. Rohnert, P. Sommerlad, and M. Stal, Pattern-Oriented Software Architecture: A System of Patterns, vol. 1, 1st ed. Chichester, UK: Wiley, 1996.

JUnit‑Team, junit‑framework, GitHub repository, Eclipse Public License v2.0, 2025. [Online]. Available: https://github.com/junit-team/junit-framework.

JUnit, “JUnit 5 API Documentation (Snapshot)” [Online]. Available: https://docs.junit.org/snapshot/api/index.html.

J. Ivers, P. Clements, D. Garlan, R. Nord, B. Schmerl, and J. R. Oviedo Silva, Documenting Component and Connector Views with UML 2.0, Software Engineering Institute, Carnegie Mellon University, Apr. 2004.

If you read this far, tweet to the author to show them you care. Tweet a Thanks

Thanks for this detailed breakdown of JUnit’s architecture — really helps clarify how the microkernel pattern boosts flexibility and extensibility. How do you see this architectural approach influencing the future of testing frameworks beyond JUnit? Would love to hear your thoughts!

That’s a great question! Expect more test frameworks to offer modularity and pluggability because this allow extensions and customizations frameworks.

More Posts

Best NestJS Practices and Advanced Techniques

Ebenezer Boyinbode - Aug 21

Inversion of Control (IoC) Principle

Victor Lopes - Jul 13

How I Supercharged My Workflow with Git Worktrees

livecodelife - Jun 28

Unlocking New Horizons: What GitHub Copilot Teaches Us About the Future of Code

Sunny - Oct 3

Timeless software principles are vital to guide the speed and risks of modern AI-driven development.

Matheus Ricardo - Aug 19
chevron_left