skip to content

A legacy suite marks slow tests with JUnit 4's @Category(SlowTests.class) while newer tests use JUnit 5's @Tag("slow"). Running everything on the JUnit Platform, how can one filter exclude both groups, and what exactly does the vintage engine do with a JUnit 4 category?

level: middleimportance: nice to knowfreq 28%

answer

  1. category -> tag = fully qualified class name
  2. one tag expression covers both engines
  3. expression: slow | com.acme.SlowTests
  4. FQN breaks on rename/move
  5. excluded by tag = absent, not skipped

basics

~20 s

The vintage engine exposes each JUnit 4 @Category as a JUnit Platform tag whose name is the category class's fully qualified name. So excluding tags with an expression like "slow | com.acme.SlowTests" removes both the Jupiter-tagged and the category-marked tests in one filter.

solid answer

~50 s

Vintage translates JUnit 4 categories into platform tags. For each `@Category` value on a class or method, the descriptor gets a tag equal to the **fully qualified class name** of the category marker — `com.acme.SlowTests`, not `SlowTests`. Once that is understood, filtering is uniform: the platform's tag filter (a tag expression in the build's include/exclude tags, `--include-tag`/`--exclude-tag` on the ConsoleLauncher, or `@ExcludeTags` on a suite) applies to both engines. So a single exclusion expression `slow | com.acme.SlowTests` drops Jupiter tests tagged `slow` and JUnit 4 tests categorised `SlowTests`. Two practical notes: category *inheritance* follows JUnit 4's rules (a category on the class applies to its methods), and because the tag is an FQN it changes if the marker class is moved or renamed, which silently breaks the filter. Teams often add `@Tag`-equivalent names to their build filters for both spellings, or during migration replace categories with tags class by class.

code

java · 16 lines
java
public interface SlowTests {}

@Category(SlowTests.class)          // -> platform tag "com.acme.tests.SlowTests"
public class LegacyImportTest {
    @org.junit.Test
    public void importsCatalog() { }
}

class CatalogImportTest {
    @org.junit.jupiter.api.Tag("slow")   // -> platform tag "slow"
    @org.junit.jupiter.api.Test
    void importsCatalog() { }
}

// tag expression used by the launcher / suite:
//   !(slow | com.acme.tests.SlowTests)

go deeper

for a junior

Know that categories become tags and that one exclusion expression can cover both, even if you need to look up the exact tag name.

for a middle

State the mapping precisely (fully qualified category class name) and write the combined tag expression, including the class-level inheritance behaviour.

for a senior

Add the failure modes: FQN fragility on rename, filtered-out vs skipped in reporting, and why platform tag filtering beats the JUnit 4 Categories runner under the platform.

for a principal

Argue for one source of truth for CI slicing across the migration — a single tag-expression vocabulary that both engines feed — rather than parallel category and tag filtering schemes.

## Two grouping mechanisms, one filter JUnit 4 grouped tests with **categories**: a marker type (usually an empty interface) named in `@Category(SlowTests.class)`, selected at run time by the `Categories` runner with `@IncludeCategory`/`@ExcludeCategory`. JUnit 5 replaced that with **tags**: a plain string in `@Tag("slow")`, selected by a *tag expression* evaluated by the platform, not by any engine. Tag filtering is a **platform-level** post-discovery filter: engines report tags on their descriptors, and the launcher keeps or discards descriptors according to the expression. That is the key to unifying the two worlds — if the vintage engine reports categories as tags, one expression covers everything. ## The exact mapping The vintage engine reports, for every JUnit 4 test whose class or method carries `@Category`, one tag per category value, and the tag's name is the **fully qualified name of the category class**. `@Category(com.acme.tests.SlowTests.class)` becomes the tag `com.acme.tests.SlowTests`. It is not the simple name, not lowercased, not shortened. Multiple categories become multiple tags. JUnit 4's inheritance rules are preserved: a category on the class applies to every test method in it, and a method-level category adds to (rather than replaces) the class-level one. The practical filter is therefore a tag expression that names both spellings: ``` excludeTags = "slow | com.acme.tests.SlowTests" ``` Tag expressions support `!`, `&`, `|` and parentheses, plus `any()`/`none()`, and the same expression is understood by every entry point (build-tool tag filters, `--include-tag` / `--exclude-tag` on the ConsoleLauncher, `@IncludeTags`/`@ExcludeTags` on a `@Suite` class, and `TagFilter` in a programmatic discovery request). ## Gotchas worth naming in an interview 1. **FQN fragility.** Because the tag is the class name, moving `SlowTests` to another package or renaming it silently changes the tag. The build filter still compiles and the tests quietly stop being excluded (or quietly stop running). A comment next to the filter, or a test that asserts the category class's FQN, is cheap insurance. 2. **Tag syntax rules.** Platform tags must not be blank, must not contain whitespace, ISO control characters, or the reserved characters `,`, `(`, `)`, `&`, `|`, `!`. A Java FQN satisfies all of that, so category-derived tags are always legal — but this is why you cannot mimic the mapping with arbitrary strings. 3. **The `Categories` runner is a different thing.** If a legacy suite class uses `@RunWith(Categories.class)` with `@IncludeCategory`, that filtering happens *inside* JUnit 4 while Vintage drives the runner; it is unrelated to platform tag filtering and does not compose with it in an obvious way. Prefer platform-level filtering when running under the platform. 4. **Direction of filters.** An exclusion beats an inclusion in the sense that a descriptor excluded by tag never runs, regardless of other selectors; and filtering removes *descriptors*, so an excluded test is not reported as skipped — it is simply absent from the plan. Interviewers like that distinction: `@Disabled`/`@Ignore` produce a skipped result, tag exclusion produces no result at all. 5. **Containers vs tests.** Tags apply to the descriptor that carries them; excluding a tag on a class-level category removes the whole container. Jupiter and Vintage both support tags at class and method granularity. ## Migration angle Because categories map onto the tag namespace, a common migration path is: first move all build filtering to platform tag expressions containing both names; then, class by class, replace `@Category(SlowTests.class)` with `@Tag("slow")` as those classes are converted to Jupiter; finally drop the FQN half of the expression once no categories remain. That keeps a single source of truth for "which tests run in which CI job" throughout the migration rather than maintaining two parallel filtering mechanisms.

  • What is the difference in the report between a test removed by a tag exclusion and a test annotated @Ignore or @Disabled?
    A tag-excluded test is filtered out during discovery, so it never enters the test plan and appears nowhere in the report. An @Ignore'd JUnit 4 test or @Disabled Jupiter test is discovered and then reported with a skipped outcome and, in Jupiter, a reason. If you need visibility that a test was intentionally not run, disabling is the honest mechanism; tag filtering is for cutting whole slices of a suite.
  • Why does renaming the category marker class silently break a CI job's filter?
    The platform tag derived from a category is the marker's fully qualified class name, and the filter stores that name as a plain string in build config or a suite annotation. Renaming or moving the class changes the tag but not the string, and neither the compiler nor the platform validates that a filtered tag exists, so the job keeps running green while silently including or excluding the wrong tests.

saying these in an interview costs you the question

  • Saying the tag is the category's simple class name, so "SlowTests" alone works as a filter
  • Believing @Category is understood natively by the platform rather than translated by the vintage engine
  • Assuming a Jupiter @Tag on a JUnit 4 test class has an effect
  • Thinking a tag-excluded test shows up in the report as skipped
  • Claiming you must keep two separate CI filtering mechanisms because categories and tags cannot be combined

context