skip to content

Instead of repeating the JUnit 5 annotation @Tag("slow") as a string literal across hundreds of tests, how would you define a custom @Slow annotation that carries the tag, and what does that buy you?

level: middleimportance: should knowfreq 35%

answer

  1. @Retention(RUNTIME) or it vanishes silently
  2. JUnit searches meta-annotations recursively
  3. @Target TYPE + METHOD
  4. bundle @Tag + @ExtendWith + @Timeout
  5. CI still filters on the string, not the type

basics

~20 s

Declare your own annotation with RUNTIME retention, targets TYPE and METHOD, and put @Tag("slow") on it. JUnit searches meta-annotations, so anything annotated @Slow is tagged slow. You get compile-checked usage, one place to rename, and can bundle extra annotations.

solid answer

~50 s

JUnit 5 resolves annotations *recursively through meta-annotations*, so any annotation that is itself annotated with `@Tag` acts as a tag. ```java @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Tag("slow") public @interface Slow {} ``` Now `@Slow` on a class or method tags it `slow`. `RUNTIME` retention is mandatory — without it the annotation is invisible to reflection and the tag silently disappears. The payoff is that the tag stops being a magic string sprinkled across the suite. The compiler rejects `@Sloww`, IDE find-usages tells you exactly which tests carry it, and renaming is a refactor rather than a grep. You can also compose more than metadata: one annotation can carry several `@Tag`s plus `@ExtendWith(...)`, `@Timeout`, or `@Testcontainers`, so `@IntegrationTest` becomes a single opt-in for a whole test profile. The expression in CI still refers to the *string* `slow`, not the annotation type — that link stays untyped.

code

java · 12 lines
java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Tag("slow")
@Tag("db")
@ExtendWith(PostgresExtension.class)
public @interface DatabaseTest {}

@DatabaseTest
class OrderRepositoryTest {
    @Test
    void findsByCustomer() { /* ... */ }
}

go deeper

for a junior

Be able to write the four-line annotation and say why RUNTIME retention is required.

for a middle

Explain the meta-annotation search, and that composition can bundle extensions and timeouts, not just tags.

for a senior

Pitch it as vocabulary governance: a closed annotation set stops tag-string drift and makes the taxonomy reviewable.

for a principal

Discuss it as an internal test-API decision — one annotation per test profile, owned centrally, with the build expression kept in sync as the one untyped seam.

## The mechanism JUnit 5 does not read annotations with a plain `getAnnotation` call. Its `AnnotationUtils` searches the annotation *graph*: an annotation on your test, an annotation on that annotation, and so on, plus enclosing classes and superclasses. An annotation that appears anywhere in that graph counts as present. That is what makes composed (meta-)annotations work, and it is the same mechanism behind `@Test` composites and custom `@ExtendWith` bundles. ## Writing one ```java @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Tag("slow") public @interface Slow {} ``` Three parts matter. `@Retention(RUNTIME)` is non-negotiable: the default is `CLASS`, which is dropped before reflection sees it, and the failure mode is silent — tests simply are not tagged and quietly disappear from an include-filtered run. `@Target` decides where it may be used; `TYPE` plus `METHOD` mirrors where `@Tag` itself is allowed (add `ANNOTATION_TYPE` if you want to compose further). And the `@Tag` value is still the plain string the build filters on. ## Why bother *Typo safety.* `@Tag("slwo")` compiles and runs, and the test just falls out of every tagged job forever. `@Slwo` does not compile. *Refactorability.* Renaming a tag across 800 files is a grep-and-hope with literals; with an annotation it is one edit inside the annotation declaration (plus the CI expression). IDE find-usages and "go to declaration" work on the annotation type, so the suite becomes navigable. *Bundling.* Composition is not limited to one tag. A realistic integration marker often reads: ```java @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Tag("slow") @Tag("db") @ExtendWith(PostgresExtension.class) @Timeout(120) public @interface DatabaseTest {} ``` One annotation now expresses the labels *and* the machinery. Tests get a single, meaningful opt-in and cannot end up tagged `db` while forgetting the extension that provides the database. *Governance.* A closed set of annotations is a vocabulary you can review; free-form strings drift into `slow`, `Slow`, `slow-test` and `slowish` within a year. Since tags are case-sensitive and unvalidated, that drift is invisible until a job runs the wrong set. ## Limits worth naming The annotation type is compile-checked, but the *link to CI is not*: the Gradle or Maven configuration still names the string `slow`, so renaming the tag value without updating the build silently changes what runs. Keep the value and the build config together in review. Also remember composition does not create scoping: `@DatabaseTest` on a class tags every test in it, including `@Nested` classes, exactly as a literal `@Tag` would. And because tags accumulate rather than override, a composed annotation adds its tags to whatever the enclosing class already declares. ## The JUnit 4 comparison Interviewers often ask how this differs from JUnit 4's `@Category(SlowTests.class)`. JUnit 4 used marker *types*, which gave type safety but also gave inheritance semantics (a category matched its subtypes) and required the class to exist. JUnit 5 chose plain strings plus meta-annotations: simpler engine, no inheritance magic, and you recover type safety yourself by composing an annotation — opt-in rather than built-in.

  • What happens if you forget @Retention(RetentionPolicy.RUNTIME) on your custom tag annotation?
    The annotation defaults to CLASS retention, so it is present in the bytecode but invisible to reflection. JUnit never sees the meta-annotated `@Tag`, the tests are effectively untagged, and they silently drop out of any include-filtered job while still compiling and passing locally. It is one of the quietest failure modes in the whole tagging feature.
  • Can one composed annotation carry more than one tag, and how does that interact with tags already on the class?
    Yes — `@Tag` is repeatable, so a composed annotation can declare several, and it can also carry unrelated annotations like `@ExtendWith` or `@Timeout`. Tags always accumulate: the composed annotation's tags are added to any tags already declared on the class, the enclosing class, or the method.

saying these in an interview costs you the question

  • Omitting @Retention(RUNTIME) and expecting the tag to work
  • Thinking JUnit only reads directly declared annotations, so meta-annotations cannot work
  • Claiming the composed annotation makes the CI tag expression type-safe too
  • Assuming a composed annotation replaces the enclosing class's tags rather than adding to them
  • Believing JUnit 5 tags are class-based like JUnit 4 @Category

context