skip to content

JUnit Platform

The architecture underneath: the Platform launcher, pluggable test engines, Jupiter and Vintage as peers, and how builds wire it all up. The go-to topic for 'do you understand JUnit 5's design'.

on this pageshow

explore

questions

15

JUnit 5 is usually described as three sub-projects rather than one library. What are they, and what is each one responsible for when tests actually run?

level: juniorimportance: must knowfreq 58%

answer

  1. Platform = launcher + TestEngine SPI
  2. Jupiter = api + params + engine
  3. Vintage = runs JUnit 3/4 on the Platform
  4. Platform doesn't know what @Test is
  5. ServiceLoader finds engines

basics

~20 s

JUnit 5 = Platform + Jupiter + Vintage. The Platform is the foundation: it launches tests and defines the TestEngine plug-in interface. Jupiter is the new programming model (annotations, assertions) plus its own engine. Vintage is an engine that runs old JUnit 3 and 4 tests.

solid answer

~50 s

JUnit 5 is an umbrella over three sub-projects. - **JUnit Platform** — the foundation everything else plugs into. It defines the `TestEngine` SPI and ships the `Launcher` that IDEs and build tools call. The Platform itself knows nothing about `@Test`; it only knows how to ask engines to discover and execute tests and how to report the results. - **JUnit Jupiter** — the new programming model and extension model. `junit-jupiter-api` gives you `@Test`, `@BeforeEach`, `Assertions`, `@ParameterizedTest`, `Extension`; `junit-jupiter-engine` is the `TestEngine` implementation that finds and runs those tests on the Platform. - **JUnit Vintage** — `junit-vintage-engine`, a `TestEngine` that runs existing JUnit 3.8 and JUnit 4 tests on the same Platform, so old and new tests execute in one run. The practical payoff: the Platform is a shared launching surface, so non-JUnit frameworks (Cucumber, ArchUnit, Spock, jqwik) can ship their own engines and be run by the same IDE and build-tool machinery.

code

java · 13 lines
java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class CartTest {

    @Test
    void totalsTwoItems() {
        Cart cart = new Cart();
        cart.add(new Item("pen", 2));
        cart.add(new Item("pad", 3));
        assertEquals(5, cart.total());
    }
}

go deeper

for a junior

Name the three parts and one sentence each; make clear you write against Jupiter's API and that Vintage exists for old tests.

for a middle

Add the artifact names (api vs engine vs launcher) and explain that engines are found via ServiceLoader at runtime.

for a senior

Frame it as a plug-in architecture: the Platform decoupled tool vendors from JUnit internals, which is why Cucumber, Spock and ArchUnit ship engines.

for a principal

Discuss the consequence for an organisation's tooling strategy — one launching surface means reporting, tagging and CI integration are written once against the Platform rather than per framework.

## Why JUnit 5 is not one library JUnit 4 was a single jar that did everything: it defined `@Test`, it discovered tests by reflection, it ran them, and IDEs integrated with it by reaching into its internals (`JUnitCore`, `Runner`, `RunNotifier`). That coupling was the problem JUnit 5 set out to fix. Tool vendors were pinned to JUnit 4 internals, and every alternative testing framework had to pretend to be a JUnit 4 `Runner` to get a green tick in an IDE. JUnit 5 therefore ships as three sub-projects that are released together but have distinct jobs. ## 1. JUnit Platform — the foundation The Platform is the layer that tools talk to. Its key artifacts: - `junit-platform-commons` — shared internal utilities. - `junit-platform-engine` — the **SPI** (service provider interface) that any test framework implements: `TestEngine`, with `discover(...)` and `execute(...)`, plus `TestDescriptor` and `UniqueId`. - `junit-platform-launcher` — the **client-facing API**. An IDE or build tool builds a discovery request, calls the `Launcher`, receives a `TestPlan`, and listens for execution events. - `junit-platform-console` — a standalone launcher you can run from a plain command line. - `junit-platform-suite` — declarative suites (`@Suite`) that run across engines. Crucially, the Platform contains **no test annotations**. It does not know what `@Test` means. It knows only "find engines on the classpath via `ServiceLoader`, ask each one what tests it has, then ask it to run them and tell me what happened". ## 2. JUnit Jupiter — the new programming model Jupiter is what you actually write against: - `junit-jupiter-api` — `@Test`, `@BeforeEach`/`@AfterEach`, `@BeforeAll`/`@AfterAll`, `@Nested`, `@DisplayName`, `@Disabled`, `Assertions.assertEquals/assertThrows/assertAll`, `Assumptions`, and the `Extension` interfaces (`@ExtendWith`). - `junit-jupiter-params` — `@ParameterizedTest` and its argument sources. - `junit-jupiter-engine` — the `TestEngine` implementation, registered under the engine id `junit-jupiter`, that scans classes for Jupiter annotations, builds the descriptor tree, and executes it with lifecycle callbacks and extensions. So "Jupiter" = the API you compile against **plus** the engine that understands it. They are deliberately different artifacts (see the api-vs-engine question). ## 3. JUnit Vintage — backward compatibility `junit-vintage-engine` is a `TestEngine` with the id `junit-vintage` that delegates to the real JUnit 4 runner infrastructure. Put it on the test runtime classpath together with `junit:junit` 4.12+ and your existing JUnit 4 classes — including `@RunWith`, `@Rule`, `@Category`, and JUnit 3 `TestCase` subclasses — keep running, side by side with new Jupiter tests, in the same run and the same report. Vintage exists so that migration is incremental: you never need a big-bang rewrite of a legacy suite before you can write your first Jupiter test. ## How a run flows end to end 1. Your IDE or build tool builds a `LauncherDiscoveryRequest` ("everything under `com.example`"). 2. The `Launcher` finds all `TestEngine` implementations on the classpath through `ServiceLoader`. 3. Each engine performs discovery and returns a tree of `TestDescriptor`s rooted at its own unique id, e.g. `[engine:junit-jupiter]/[class:com.example.CartTest]/[method:total()]`. 4. The Launcher merges those trees into one `TestPlan` and hands it to registered listeners. 5. Execution runs engine by engine; listeners receive started/finished/skipped events, and the tool renders the familiar green/red tree. ## Versioning In the JUnit 5 line the version numbers deliberately differ: Platform artifacts were `1.x` while Jupiter and Vintage were `5.x` — they are separate products with separate compatibility promises. JUnit 6.0 (2025) unified the numbering so Platform, Jupiter and Vintage all carry the same `6.x` version, and raised the baseline to Java 17. If you see `junit-platform-launcher:1.11.0` next to `junit-jupiter:5.11.0`, that mismatch is normal for the 5.x line, not a bug. ## Why the split matters in practice Because the Platform is framework-agnostic, non-JUnit tools ship engines and get first-class IDE and build support for free: Cucumber (`cucumber-junit-platform-engine`), ArchUnit, Spock 2.x, jqwik, Kotest. That is the real architectural win — the Platform turned "running tests in Java" into a plug-in ecosystem rather than a JUnit 4 monopoly.

  • Which of the three sub-projects does an IDE integrate against?
    The Platform — specifically `junit-platform-launcher`. The IDE builds a discovery request, calls the `Launcher`, and consumes the resulting `TestPlan` and execution events. It never talks to the Jupiter engine directly, which is exactly why the same IDE view can display Cucumber or ArchUnit results.
  • If you only ever write Jupiter tests, do you still need the Platform?
    Yes, always. Jupiter's engine implements a Platform SPI and can only be driven by a Platform `Launcher`; there is no way to run a Jupiter test without it. You usually don't declare it explicitly because the engine depends on `junit-platform-engine` transitively and your IDE or build tool supplies the launcher.
  • Can the Jupiter engine run JUnit 4 tests?
    No. The Jupiter engine only understands Jupiter annotations from `junit-jupiter-api`. A class using `org.junit.Test` from JUnit 4 is invisible to it, which is why the separate vintage engine exists. Silently skipped legacy tests are the classic symptom of expecting otherwise.

The Platform is a power socket standard, Jupiter and Vintage are two appliances plugged into it, and Cucumber or ArchUnit are third-party appliances that fit the same socket.

saying these in an interview costs you the question

  • Saying JUnit 5 is just "JUnit 4 with new annotations" in one jar
  • Thinking the Platform contains @Test and the assertions
  • Believing the Jupiter engine also executes JUnit 4 tests
  • Assuming Platform 1.x next to Jupiter 5.x is a broken dependency set
  • Claiming Vintage is required for new projects

context

open as a page

You need to discover and run JUnit tests from your own Java code — for example inside a custom tool that picks which tests to run — rather than letting a build tool do it. Which JUnit Platform API do you use, and what are the steps?

level: middleimportance: must knowfreq 30%

basics

~20 s

Use the JUnit Platform Launcher API from junit-platform-launcher: build a LauncherDiscoveryRequest with selectors and filters, open a LauncherSession to get a Launcher, register a TestExecutionListener, then call discover() to inspect the TestPlan or execute() to run it.

open as a page

In JUnit 5, how do you declare a test suite that runs a chosen set of test classes, and which annotations decide what goes into it?

level: juniorimportance: should knowfreq 38%

basics

~10 s

Put @Suite on a plain class, then add selectors: @SelectClasses lists test classes explicitly, @SelectPackages scans whole packages. The junit-platform-suite-engine artifact must be on the test runtime classpath, otherwise the suite class runs nothing.

open as a page

In a JUnit 5 project, junit-jupiter-api is normally a compile-scope test dependency while junit-jupiter-engine is runtime-only. Why is the API split from the engine at all, and what does the aggregate junit-jupiter artifact contain?

level: middleimportance: should knowfreq 42%

basics

~20 s

Test code compiles only against the API (annotations, assertions, extensions); the engine is an implementation found at runtime through ServiceLoader. Keeping the engine off the compile path stops code depending on engine internals. The aggregate junit-jupiter artifact bundles api plus params, with the engine at runtime.

open as a page

JUnit's platform reads settings such as parallel test execution and the default test-instance lifecycle from configuration parameters. Where can those parameters be supplied, and which source wins when two of them disagree?

level: middleimportance: should knowfreq 42%

basics

~20 s

Configuration parameters are simple key/value settings. They can come from the LauncherDiscoveryRequest, a JVM system property, or a junit-platform.properties file at the root of the classpath — and that is also the precedence order, request first, file last.

open as a page

When you build a JUnit Platform discovery request you can add both selectors and filters. What is the difference between them, and at what point in a run is each one applied?

level: middleimportance: should knowfreq 22%

basics

~20 s

Selectors say where to look — a package, class, method, classpath root or unique id — and engines interpret them while discovering. Filters remove things from what was found: class-name and package filters during discovery, engine and tag filters applied by the launcher around it.

open as a page

How do you make a JUnit 5 suite class run only the tests carrying certain @Tag values, and what expression syntax do the include and exclude filters accept?

level: middleimportance: should knowfreq 36%

basics

~20 s

Add @IncludeTags and/or @ExcludeTags to the @Suite class. Both accept tag expressions with ! (not), & (and), | (or), parentheses, and any()/none(). Exclusion wins: a test matching both an include and an exclude is not run.

open as a page

The JUnit Platform's TestEngine interface splits work into a discovery phase and an execution phase. What does each phase produce, and why is discovery a separate step at all?

level: seniorimportance: should knowfreq 25%

basics

~20 s

discover() inspects the request and returns a tree of TestDescriptors with unique ids, running no test code. execute() then runs that tree, reporting started/skipped/finished events. Separating them lets tools list, count, filter and address tests before anything executes.

open as a page

You want a custom live console line for every test as it finishes, and separately you want one shared Docker container started before the first test in a JVM and stopped after the last one. Which JUnit Platform listener interfaces cover each need, and how are they registered?

level: seniorimportance: should knowfreq 20%

basics

~10 s

Per-test reporting uses a TestExecutionListener, which receives testPlanExecutionStarted, executionStarted/skipped/finished and testPlanExecutionFinished. One-per-JVM setup uses a LauncherSessionListener, whose launcherSessionOpened/Closed bracket the whole session. Both are registered automatically via ServiceLoader, or explicitly on the Launcher.

open as a page

Besides Gradle or Maven, how can you execute a JUnit Platform test suite straight from a command line, and when is doing so genuinely useful?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

Use the ConsoleLauncher, shipped as the junit-platform-console-standalone fat jar: java -jar junit-platform-console-standalone.jar execute --class-path <cp> --scan-classpath. It runs the same launcher a build tool uses, so it isolates classpath and engine problems from build configuration.

open as a page

What does the JUnit 5 @ConfigurationParameter annotation on a suite class do, and how does it relate to a junit-platform.properties file and JVM system properties?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

@ConfigurationParameter sets a JUnit Platform configuration parameter (key/value) for that suite's nested run only — for example enabling parallel execution or per-class test instance lifecycle. It is supplied to the launcher, so it takes precedence over system properties and junit-platform.properties.

open as a page

Can two different test engines — say the JUnit Jupiter engine and a Cucumber or ArchUnit engine — take part in the same JUnit Platform run? How does the platform keep their tests apart in the results?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Yes. The Platform discovers every TestEngine on the classpath via ServiceLoader, asks each to discover tests, and merges the results into one TestPlan. Each engine roots its tests under its own unique-id segment, such as [engine:junit-jupiter], so identities never collide.

open as a page

Which artifact provides the engine that executes classes annotated with JUnit 5's @Suite, and what must such a class satisfy to be discovered and run?

level: seniorimportance: nice to knowfreq 24%

basics

~20 s

junit-platform-suite-engine provides the TestEngine that finds @Suite classes; junit-platform-suite-api provides the annotations (the junit-platform-suite aggregator pulls in both). The class must be non-private, non-abstract, have a no-arg constructor, be static if nested, and declare at least one selector.

open as a page

Under what circumstances would you implement a custom JUnit Platform TestEngine, rather than solving the problem inside an existing testing framework's programming model?

level: principalimportance: nice to knowfreq 14%

basics

~20 s

Write an engine only when your tests are not Java methods with annotations — for example specs in files, generated cases, or a different execution model. If tests are still ordinary annotated methods, an extension or a parameterised source is the right level and far cheaper.

open as a page

Your team wants named groupings of a large test corpus — a smoke set, a slow set, an integration set. When would you express those as JUnit 5 @Suite classes rather than relying on @Tag values filtered at launch time, and what does each cost over years?

level: principalimportance: nice to knowfreq 22%

basics

~20 s

Tags are the default: each test declares what it is once, and any run can select combinations. Add a @Suite class only when the grouping needs a code-versioned identity, its own configuration parameters, or must select across engines — and accept double execution and drift as its cost.

open as a page