In JUnit 5, some teams mark tests with one custom annotation such as @SlowIntegrationTest instead of writing @Test plus @Tag("slow") on every method. What is that custom annotation called, and how does JUnit know to treat the method as a test?
answer
- annotation on an annotation = meta-annotation
- directly present OR meta-present
- @Retention(RUNTIME) or invisible
- @Target METHOD / TYPE / ANNOTATION_TYPE
- one name = @Test + tags + extensions
basics
~20 sIt is a composed annotation: your own annotation type that is itself annotated with JUnit's @Test, @Tag, @ExtendWith and so on. Jupiter looks up annotations recursively, so anything meta-annotated with @Test is discovered and run as a test.
solid answer
~40 sThat is a **composed annotation**; the JUnit annotations placed on it are its *meta-annotations*. You declare your own annotation type and annotate the annotation itself with `@Test`, `@Tag("slow")`, `@Tag("integration")`, plus `@Retention(RUNTIME)` and a `@Target`. JUnit Jupiter never asks only whether `@Test` is *directly present* on a method; it walks the annotations of the annotations recursively. A method annotated `@SlowIntegrationTest` is therefore meta-annotated with `@Test`, is discovered exactly as if `@Test` were written there, and carries both tags. The payoff is one vocabulary word per test category: the marker, the tags and any extensions live in one place, so changing the convention is one edit instead of hundreds. The two mandatory pieces are `@Retention(RetentionPolicy.RUNTIME)` — without it the annotation is invisible to reflection — and a `@Target` that includes the element you intend to annotate.
code
java · 15 lines@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Tag("slow")
@Tag("integration")
@Test
public @interface SlowIntegrationTest {
}
class OrderRepositoryTest {
@SlowIntegrationTest
void persistsOrderAcrossRestart() {
// discovered as a test; tagged slow + integration
}
}go deeper
Know the term 'composed annotation', that JUnit finds @Test through it, and that @Retention(RUNTIME) is mandatory.
Explain directly-present versus meta-present lookup and pick sensible @Target values for method-level versus class-level bundles.
Discuss what belongs in the vocabulary (tags plus extensions), the indirection cost, and the absence of attribute aliasing.
Frame it as codebase-wide taxonomy: a small set of named test categories that selection and reporting are built on, owned and reviewed like any public API.
## Meta-annotations and composition A Java annotation is a marker attached to a class, method or field. An annotation *type* can itself carry annotations, and those are called **meta-annotations**. A **composed annotation** is a custom annotation type that bundles several JUnit Jupiter annotations behind one name. JUnit 5 (the Jupiter programming model) was designed for this from the start: `@Test`, `@Tag`, `@ExtendWith`, `@TestInstance`, `@DisplayNameGeneration` and friends are all declared with `@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})` or `...TYPE)`, so they may legally be placed on another annotation. ## How discovery sees it When the JUnit Platform discovers tests, Jupiter does not use plain `element.getAnnotation(Test.class)`. It uses the platform's annotation support, which considers an annotation *present* if it is: 1. **directly present** on the element, or 2. **meta-present** — reachable by recursively following the annotations of the annotations, to arbitrary depth. So `@SlowIntegrationTest` → `@Test` makes the method a test method; `@SlowIntegrationTest` → `@Tag("slow")` makes the tag apply. You can compose composed annotations (`@NightlyIntegrationTest` meta-annotated with `@SlowIntegrationTest`), and the lookup still finds `@Test`. ## Writing one correctly Three declaration details matter: - `@Retention(RetentionPolicy.RUNTIME)` is **required**. The Java default is `CLASS`, which keeps the annotation in the bytecode but hides it from reflection, so JUnit sees nothing and the method silently is not a test. - `@Target` should list what you mean to annotate: `METHOD` for a test marker, `TYPE` for a class-level bundle (tags plus `@ExtendWith`), and `ANNOTATION_TYPE` if you want others to compose on top of yours. - Nothing else is required. `@Documented` is nice for Javadoc; `@Inherited` is largely irrelevant because Jupiter runs its own hierarchy search rather than relying on JDK annotation inheritance. ## Why teams do it - **One edit, whole category.** Adding an extension to every integration test becomes a single line on the annotation. - **Intent over mechanics.** `@AcceptanceTest` reads better than four stacked annotations, and it is greppable. - **Consistency.** Tag strings are typo-prone; a composed annotation turns a string into a compile-checked name. The cost is indirection: a reader who has never seen `@AcceptanceTest` must open it to learn that it means `@Test` plus two tags and a Testcontainers extension. Keep the vocabulary small and the annotations discoverable in one package. One limit to remember: Jupiter has no attribute aliasing. Your annotation cannot take a parameter and forward it into the meta-annotation it composes — the composed values are fixed at declaration time.
- Can a composed annotation be composed of another composed annotation?Yes. Jupiter's lookup is recursive and depth-unbounded, with cycle protection, so `@NightlyIntegrationTest` meta-annotated with `@SlowIntegrationTest` still resolves to `@Test` and to every tag on the way. Keep the chain shallow, though: each extra hop is another file a reader must open to know what a test actually does.
- Did JUnit 4 support this?Not really. JUnit 4 checks for `@Test` directly on the method, so you could not hide it behind a custom annotation; the closest equivalents were runners and rules, and category marker interfaces. Meta-annotation lookup is a Jupiter feature and is one of the concrete reasons teams cite for migrating.
saying these in an interview costs you the question
- Thinking the custom annotation needs a JUnit-provided base class or registration somewhere
- Omitting @Retention(RUNTIME) and then blaming the build for not finding the test
- Believing Jupiter only reads annotations that are directly present
- Claiming the composed annotation replaces the engine or requires a custom runner