skip to content

Composed Annotations

Building your own annotations out of JUnit ones — the mechanism Spring's test annotations rely on. Shows an interviewer you understand that Jupiter resolves meta-annotations.

on this pageshow

questions

4

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?

level: juniorimportance: must knowfreq 45%

answer

  1. annotation on an annotation = meta-annotation
  2. directly present OR meta-present
  3. @Retention(RUNTIME) or invisible
  4. @Target METHOD / TYPE / ANNOTATION_TYPE
  5. one name = @Test + tags + extensions

basics

~20 s

It 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 s

That 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
java
@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

for a junior

Know the term 'composed annotation', that JUnit finds @Test through it, and that @Retention(RUNTIME) is mandatory.

for a middle

Explain directly-present versus meta-present lookup and pick sensible @Target values for method-level versus class-level bundles.

for a senior

Discuss what belongs in the vocabulary (tags plus extensions), the indirection cost, and the absence of attribute aliasing.

for a principal

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

context

open as a page

You wrote a custom JUnit 5 annotation that bundles @Test and @Tag, but methods marked with it are no longer picked up as tests. Which Java meta-annotations must your annotation type itself declare, and what goes wrong when each is missing or wrong?

level: middleimportance: must knowfreq 38%

basics

~20 s

@Retention(RetentionPolicy.RUNTIME) is mandatory — the default CLASS retention hides the annotation from reflection, so JUnit never sees it. @Target must include the element you annotate (METHOD for a test marker, TYPE for class-level, ANNOTATION_TYPE to allow further composition).

open as a page

Explain how JUnit Jupiter's annotation lookup actually resolves an annotation such as @Tag or @ExtendWith: how deep does it follow annotations of annotations, and where besides the element itself does it search?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Jupiter treats an annotation as present if it is directly on the element or meta-present anywhere up the annotation graph, searched recursively with cycle protection. For classes it also searches superclasses and implemented interfaces, and enclosing classes for @Nested. Repeatable annotations such as @Tag and @ExtendWith are collected from all of those sources, not overridden.

open as a page

How would you decide whether a large JUnit 5 test codebase should adopt custom composed annotations that bundle @Test with tags and extensions, versus having every test repeat the standard annotations?

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Introduce a composed annotation when a category is real, stable and repeated many times, and when its tags and extensions must stay consistent. Keep the vocabulary small, one shallow level, documented in one package. Prefer explicit standard annotations for one-off or evolving setups.

open as a page