How does the JUnit 5 Jupiter engine discover and execute methods annotated with @Test, and where does @Test fit within the JUnit 5 architecture?
answer
- 3 layers: Platform (Launcher + TestEngine SPI), Engines (Jupiter/Vintage), API (@Test)
- discovery -> selectors/filters -> TestDescriptor tree -> TestPlan
- reflection scan for @Test and siblings
- PER_METHOD instance, extensions + @BeforeEach, ParameterResolver injects args
- pass=return, fail=throw, abort=TestAbortedException (assumptions)
basics
~20 sJUnit 5 has a Platform that launches tests, a Jupiter engine that finds @Test methods by scanning classes via reflection, and the API (@Test) you annotate with. The engine builds a test plan, creates an instance per test, runs lifecycle callbacks, then invokes each @Test method.
solid answer
~50 sJUnit 5 is three layers: the Platform (the launcher and TestEngine SPI), test engines (Jupiter runs the new @Test style; Vintage runs JUnit 3/4), and the APIs you write against (Jupiter's org.junit.jupiter.api). When a build tool starts the Platform launcher, it asks registered engines to discover tests. The Jupiter engine scans the classpath/selectors, finds classes and methods carrying @Test (and @ParameterizedTest, @TestFactory, etc.), and builds a hierarchical TestDescriptor tree — the test plan. During execution it resolves a test instance (a new one per method by default, PER_METHOD lifecycle), runs registered extensions and @BeforeEach callbacks, invokes the @Test method via reflection resolving any injected parameters through ParameterResolvers, then runs @AfterEach. A normal return is a pass; an assertion error or thrown exception is a fail; a TestAbortedException is skipped. Results flow back through the launcher to listeners that the IDE or build tool reports.
go deeper
Knows JUnit finds @Test methods automatically and runs each one, reporting pass or fail.
Can describe reflection-based discovery, the per-method instance, and the @BeforeEach/@AfterEach surrounding execution.
Explains the Platform/Engine/API split, discovery building a TestDescriptor/TestPlan, ParameterResolver injection, and pass/fail/abort outcomes.
Reasons about the TestEngine SPI as an extension point (custom engines, composed annotations, listener-based reporting) and how layering enables tool interoperability and migration.
## The JUnit 5 architecture in three parts JUnit 5 is deliberately **not one monolith**. It is: 1. **JUnit Platform** — the foundation. It defines the **`TestEngine` SPI** (Service Provider Interface — a plug-in point) and a **`Launcher`** that build tools (Gradle, Maven) and IDEs call to run tests. The Platform knows nothing about `@Test` specifically; it just orchestrates engines. 2. **Test engines** — plug-ins that actually understand a *style* of test: - **JUnit Jupiter** — the engine for the **new** programming model, including `@Test` from `org.junit.jupiter.api`. - **JUnit Vintage** — an engine that runs **legacy JUnit 3/4** tests so old and new can coexist. 3. **JUnit Jupiter API** — the annotations and assertions you compile against (`@Test`, `@BeforeEach`, `Assertions.*`, etc.). So `@Test` is an **API element interpreted by the Jupiter engine**, sitting on top of the Platform. ## Discovery: building the test plan When your build tool runs tests, it calls the Platform `Launcher` with a **discovery request** containing **selectors** (e.g. "this package," "this class," "this method") and optional **filters** (tags, name patterns). The Launcher hands the request to each registered engine. The **Jupiter engine**: - Uses **reflection** to scan the selected classes for methods annotated with `@Test` (and its siblings `@ParameterizedTest`, `@RepeatedTest`, `@TestFactory`, `@TestTemplate`). - Builds a **`TestDescriptor` tree** — a hierarchy of *containers* (classes, nested classes) and *tests* (individual methods). This tree, merged across engines, is the **`TestPlan`**. Because `@Test` is **meta-annotatable**, you can create your own composed annotations (e.g. `@FastTest` that bundles `@Test` + `@Tag("fast")`); the engine discovers those too. ## Execution For each test in the plan the Jupiter engine: 1. **Resolves a test instance.** By default the lifecycle is **`PER_METHOD`** — a *new* instance of the test class per `@Test` method — guaranteeing isolation of instance fields. `@TestInstance(Lifecycle.PER_CLASS)` reuses one instance (and allows non-static `@BeforeAll`). 2. **Runs the extension/callback chain:** registered **extensions** (`@ExtendWith`), then `@BeforeEach` methods. 3. **Invokes the `@Test` method via reflection.** Any method **parameters are resolved by `ParameterResolver`s** (built-in ones supply `TestInfo`, `TestReporter`; extensions like Mockito or Spring supply mocks/beans). 4. **Runs `@AfterEach`** and post-callbacks (even if the test failed). ## How pass/fail/skip is decided - **Pass:** the method returns normally and no assertion failed. - **Fail:** an assertion throws (e.g. `AssertionFailedError` from `assertEquals`) or any other exception escapes the method. - **Skipped/aborted:** a `TestAbortedException` is thrown — typically via `Assumptions.assumeTrue(...)` — meaning "preconditions not met, don't count this as a failure." - **Disabled:** `@Disabled` short-circuits discovery-to-execution so the body never runs. Results are reported back through the Launcher to **`TestExecutionListener`s** that your IDE/build tool registers, which is how you see the green/red tree. ## Why this layering matters The engine/Platform split means a *third party* could write a new `TestEngine` (Spock, ArchUnit, etc.) and reuse the same Launcher, reporting, and IDE integration. `@Test` is simply Jupiter's marker within that ecosystem — not a hard-wired keyword.
- How can multiple test styles (e.g. legacy JUnit 4 and new Jupiter) run in the same build?The Platform Launcher discovers and runs all registered TestEngines. The Vintage engine handles JUnit 3/4 tests and the Jupiter engine handles the @Test/new model, so both run side by side under one launcher with unified reporting.
- What is a TestAbortedException and how does it differ from a failed assertion?TestAbortedException (thrown by Assumptions like assumeTrue) marks a test as skipped/aborted because a precondition wasn't met — it is not a failure. A failed assertion throws AssertionFailedError, which counts as a failure.
saying these in an interview costs you the question
- Saying the Platform understands @Test directly — only the Jupiter engine interprets it
- Claiming Vintage runs Jupiter tests — Vintage runs legacy JUnit 3/4
- Confusing a failed assertion (fail) with an aborted assumption (skip)
- Assuming one test-class instance is shared by default — it's per-method unless PER_CLASS