skip to content

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