Skip to main content
Back to Blog
Automation TutorialsJVM Quality Architecture · Part 1

Structure JUnit 5 Tests Around Behavior, Not Classes

Under JUnit Platform a test class name is not a discovery filter — Gradle states outright that scanForTestClasses has no effect there — so the one-test-class-per-production-class convention has no mechanical support behind it. This guide covers what the engine actually builds, grouping by the precondition a set of assertions shares rather than by package, turning the container tree into readable report prose with IndicativeSentences, the way inherited @Test methods silently multiply across subclasses, and the point at which a group stops being a container and becomes its own suite.

Shashank Rawlani

Engineering Leader | Builder | Problem Solver | AI Engineer

Published Sep 26, 202612 min read
View AI-ready Markdown
Dark technical illustration in three vertical bands. On the left, six grey rounded panels stacked in a column run off the left edge, each holding two short grey pills. A dashed grey link joins every one of them to a matching green-outlined panel in a second column of six, each of those holding three green pills. Three of the second column's panels also contain an orange rounded square, at different heights and different horizontal offsets, and a dashed orange curve loops between the three squares from top to bottom. A dashed green vertical line with a ringed dot at each end stands to the right of that column, and faint curved grey lines fan from each panel through it. On the right sits a large dashed green rounded container with a green pill and a small ringed dot at its top; inside it are two smaller dashed green containers, each also headed by a pill and a dot. The upper inner container holds three grey pills on short stubs. The lower one holds three green rounded squares, each with a green pill beside it, all gathered together. A curved green line leaves the large container on the right, ends in an arrowhead near the right edge, and two faint green pills sit beneath it. Thin grey rules run edge to edge at the top and bottom.
Listen to this article · 14 min
Quick answer: JUnit does not discover tests by class name, so mirroring your production classes buys no mechanical benefit. The engine builds a tree of containers and tests from annotations, and a container is free. Group by precondition using @Nested, name methods after the outcome a rule promises, and split by runtime contract: anything needing a database, a port or a container belongs in a different Gradle test suite, not merely a different class in the same one.

A service class picks up a method. Someone adds the matching test method to OrderServiceTest. Two years later that file is nine hundred lines, four of its forty methods need a live PostgreSQL, and the rule "an expired promotion is not applied" is spread across three of them: one in OrderServiceTest, one in PromotionRepositoryTest, one in CheckoutControllerTest. Delete the rule from the product and you cannot find every test that asserted it.

The mirror looks disciplined and it is not a structure. It is the absence of one, because the decision "which test class does this go in" was answered by the production package layout rather than by anything about the test.

"JUnit 5" is a programming model, not the current version. The shipping release line is 6.x, checked against the 6.1.3 user guide on 2026-09-07 for this article, and it requires Java 17 or later at runtime. What people name when they say JUnit 5 is Jupiter, the model both lines share, so everything below applies to a 5.x suite; the version numbers, artifact coordinates and Java floor do not. Check your own junit-jupiter version before copying a coordinate out of any article, including this one.

The engine builds a tree, not a mirror

Start from what the platform actually produces. Jupiter discovers annotated elements and assembles them into a tree of containers and tests. A top-level class is a container. A @Nested inner class is another container inside it. A @ParameterizedTest is a container whose children are its invocations. Nothing in that process consults your production type graph.

The constraints on a container are unusually loose, and each one is an option most suites decline to use. Test classes, test methods and lifecycle methods "are not required to be public, but they must not be private", and the guide recommends omitting public unless a technical reason demands it. Nesting "can be arbitrarily deep, and those inner classes are subject to full lifecycle support, including @BeforeAll and @AfterAll methods on each level." A Java record can be a test class.

So a container costs one inner class declaration. If grouping is that cheap, the question is what grouping is for, and "matching a production file name" is not an answer that survives being asked.

The file name is not a discovery filter

Many teams believe the *Test suffix is load-bearing. Under JUnit Platform it is not, and Gradle says so explicitly. Gradle's class-scanning heuristics (inherits from TestCase, carries @RunWith, contains an @Test method) belong to the JUnit 3 and 4 path, and its fallback patterns **/*Tests.class and **/*Test.class apply when scanning is switched off. Then comes the sentence that matters:

With JUnit Platform, only includes and excludes are used to filter test classes — scanForTestClasses has no effect.

Rename OrderPricingTest to BulkDiscountRules and it still runs. That is worth knowing for two opposite reasons. It frees you to name a container after the behaviour it protects instead of after a class. And it means a naming convention cannot be trusted to keep slow tests out of a fast task, because nothing is enforcing it. That is what tags and separate suites are for.

Group by the precondition tests share

The useful grouping axis is the setup a set of assertions needs in common. Jupiter is built for this: "the fact that setup code from outer tests is run before inner tests are executed gives you the ability to run all tests independently. You can even run inner tests alone without running the outer tests, because the setup code from the outer tests is always executed."

An order-pricing rule, carved by precondition rather than by class:

@DisplayName("Order pricing")
class OrderPricing {

    private PriceList prices;

    @BeforeEach
    void loadPriceList() {
        prices = PriceList.of(Money.usd("2.00"));
    }

    @Nested
    @DisplayName("with no promotion")
    class WithoutPromotion {

        @Test
        @DisplayName("charges list price below the bulk threshold")
        void chargesListPriceBelowThreshold() {
            assertEquals(Money.usd("18.00"), price(9, prices, none()));
        }

        @Test
        @DisplayName("applies bulk pricing from ten units")
        void appliesBulkPricingFromTenUnits() {
            assertEquals(Money.usd("17.00"), price(10, prices, none()));
        }
    }

    @Nested
    @DisplayName("with an expired promotion")
    class WithExpiredPromotion {

        private Promotion promotion;

        @BeforeEach
        void expireIt() {
            promotion = Promotion.percentage(20).endingOn(LocalDate.of(2026, 1, 31));
        }

        @Test
        @DisplayName("rejects the promotion and charges the undiscounted price")
        void rejectsTheExpiredPromotion() {
            assertEquals(Money.usd("18.00"),
                    price(9, prices, promotion, asOf(LocalDate.of(2026, 2, 1))));
        }

        @Test
        @DisplayName("still applies bulk pricing")
        void stillAppliesBulkPricing() {
            assertEquals(Money.usd("17.00"),
                    price(10, prices, promotion, asOf(LocalDate.of(2026, 2, 1))));
        }
    }
}

Two properties of that arrangement are worth naming. The expired-promotion rule is now in one place, so deleting the feature is a directory-sized edit rather than a search. And expireIt() runs for exactly the tests that need an expired promotion, instead of being one branch of a @BeforeEach that every test pays for.

The cost is real: nesting deepens the report, and a reader scanning a failure now has three levels of display name to parse. Nesting used decoratively, a @Nested class per method of the class under test, reintroduces the mirror one level down and adds indentation for it. If the inner class has no distinct @BeforeEach and no shared precondition to state, it is not earning its container.

Let the report read as the specification

Once containers carry preconditions, the display names can carry the rule. Jupiter ships four generators (Standard, Simple, ReplaceUnderscores and IndicativeSentences), and the last one "generates complete sentences by concatenating the names of the test and the enclosing classes."

That turns the nesting you already have into prose, without a @DisplayName on every method:

@SentenceFragment("An expired promotion")
@IndicativeSentencesGeneration(separator = " -> ", generator = ReplaceUnderscores.class)
class ExpiredPromotionRules {

    @SentenceFragment("is not applied to the order total")
    @Test
    void notAppliedToTotal() { }

    @Test
    void does_not_prevent_bulk_pricing() { }
}
An expired promotion ✔
├─ An expired promotion -> is not applied to the order total ✔
└─ An expired promotion -> does not prevent bulk pricing ✔

Set it once for the whole project rather than per class, using junit.jupiter.displayname.generator.default in src/test/resources/junit-platform.properties. The precedence is documented and worth holding in your head, because it explains why a name you set does not appear: an explicit @DisplayName wins, then a @DisplayNameGeneration on an enclosing class, then the configured default, then Standard.

Inheritance is the sharing mechanism that bites

The other way teams share setup is a base class, and it behaves differently from how most people picture it. Jupiter states the rule plainly: "fields in test classes are inherited", and "test methods and lifecycle methods are inherited unless they are overridden according to the visibility rules of the Java language."

The inheritance covers methods, not only setup. Put a convenience @Test on an abstract base, a smoke check or a "the context loads" assertion, and it executes once per subclass. Twelve subclasses, twelve executions, twelve entries in the report that all report the same fact and all break together when it breaks. Worse, the guide notes the case where you cannot even override it: "if a package-private @Test method is declared in a superclass that resides in a different package than the subclass, that @Test method will always be applied in the subclass since the subclass cannot override a package-private method from a superclass in a different package."

Base classes for shared lifecycle are fine. Base classes containing assertions are a multiplier you did not ask for. When several containers genuinely need the same checks, a test interface with default methods makes the sharing explicit at the point of use, because each container has to implement it by name.

The same caution applies to the instance lifecycle. The default is a fresh instance per test method, which is what makes mutable fields on the container safe. Switching the project default to PER_CLASS to allow non-static @BeforeAll methods removes that guarantee everywhere at once, and the guide's own warning is unusually direct: changing it "can lead to unpredictable results and fragile builds if not applied consistently," particularly when the build and the IDE disagree. Annotate the handful of classes that need it instead.

When the split is a suite and not a container

Containers organise behaviour. They do not separate runtime contracts, and that is the split that decides whether your fast feedback loop stays fast.

The question to ask of each behaviour is what it needs in order to run: a JVM only, a database, a container runtime, a deployed dependency. Behaviours with the same answer belong in the same execution unit, because they share a failure mode, a runtime cost and an owner when the environment breaks.

Tags are the lightweight version, and their syntax rules are stricter than they look. A tag "must not be null or blank", must not contain whitespace or ISO control characters after stripping, and must not contain any of , ( ) & | !, the characters that make up the tag-expression grammar. So needs-postgres is a legal tag and db,slow is not.

@Tag("needs-postgres")
@Retention(RUNTIME)
@Target({ TYPE, METHOD })
public @interface NeedsPostgres { }
tasks.withType(Test).configureEach {
    useJUnitPlatform {
        includeTags 'fast'
        excludeTags 'slow'
    }
}

Tag expressions support !, & and | with parentheses, plus any() and none(), so (micro | integration) & (product | shipping) is a single selector. That is enough to slice one task's run, and it is not enough to give the integration tests their own dependencies, their own classpath or their own report. When a group needs those, it needs a Gradle test suite, the topic of the fourth part of this series, and the tag becomes redundant the moment the suite exists.

The map that makes the argument checkable

All of the above is a set of choices, and choices drift unless they are written down where a reviewer trips over them. The artefact worth keeping is one row per behaviour, not per class:

BehaviourLayerContainerNeeds
Bulk pricing starts at ten unitsunitOrderPricing.WithoutPromotionJVM
An expired promotion is not appliedunitOrderPricing.WithExpiredPromotionJVM
Promotion validity is read from the stored end dateintegrationPromotionPersistencePostgreSQL
Order totals round to the currency's minor unitunitMoneyRoundingJVM
The quote response exposes the discounted totalcontractQuoteRepresentationrunning service

Two behaviours mapping to the same container is fine. A behaviour with no container is the gap. A container with no behaviour beside it is a test looking for a reason, and it is usually the one asserting a getter returns what the constructor was given.

Apply this now

Open the largest test class in your repository and list the distinct preconditions its @BeforeEach sets up conditionally: every if, every flag parameter, every "null means no promotion". Each one is a @Nested container waiting to be extracted, and the extraction is mechanical: move the branch into the inner class's own @BeforeEach and drop the branch.

Then grep that same class for anything requiring a running dependency: a JDBC URL, a container, an HTTP client pointed at a port. Tag those methods today so the fast task can exclude them, and put the suite split on the backlog rather than doing both at once.

You will know it worked when the test tree printed by a failing build reads as a list of rules rather than a list of classes, and when someone asked to delete a feature can find every assertion about it by opening one file.

Questions about carving up a Jupiter suite

Does one container per rule mean hundreds of files?

No, because most containers are inner classes rather than files. A top-level class per aggregate or per policy, with nested containers per precondition, keeps the file count close to the number of things your domain has names for. If you find yourself creating a top-level class for a single assertion, the rule probably belongs inside an existing container as a nested case.

What happens to coverage tooling when test classes stop matching production classes?

Nothing, because coverage instruments production bytecode and does not care which test executed it. What does change is the report you can no longer read the same way: "class X has no test class" stops being a meaningful question. The replacement is the behaviour map above, which is a better question anyway: a production class can be fully covered by tests that assert none of its rules.

Is there still a case for keeping the mirror?

For a genuinely pure library, a date arithmetic utility, a codec, a parser, the class is the behaviour, and mirroring costs nothing because there is no second axis to organise by. The mirror fails on domain services, where one business rule is implemented across several classes and one class implements several rules. If you cannot say which of those two shapes a package has, look at whether any single rule already has assertions in more than one test class.

How should nested containers interact with parallel execution?

Carefully, and that is the subject of part three. The relevant fact when designing the tree now is that execution mode is inherited down it: @Execution applies to "the annotated element and its subelements", so a container is the unit at which you opt a group in or out. Preconditions that make tests unsafe to run concurrently, a shared file, a system property, are therefore easier to contain when they already live in their own nested class.

Do display-name generators affect filtering or CI reporting?

They affect what a human reads, not what a filter matches. Gradle's --tests filtering and the platform's tag expressions both work on class and method names, so renaming display names cannot break a filter. The JUnit XML your CI server parses does carry the display name, which is the whole point: a failure that says "An expired promotion -> is not applied to the order total" needs no follow-up question, and one that says testPromotion3 always does.

Primary references

  • JUnit User Guide 6.1.3 — Nested Tests. That only non-static inner classes can be @Nested containers, that "nesting can be arbitrarily deep, and those inner classes are subject to full lifecycle support, including @BeforeAll and @AfterAll methods on each level", and that outer setup always runs first so "you can even run inner tests alone without running the outer tests".
  • JUnit User Guide 6.1.3 — Test Classes and Methods. That test classes and methods "are not required to be public, but they must not be private"; that fields are inherited and that "test methods and lifecycle methods are inherited unless they are overridden", including the package-private case across packages where the subclass cannot override at all; and that a Java record may be used as a test class.
  • JUnit User Guide 6.1.3 — Display Names. The four built-in generators Standard, Simple, ReplaceUnderscores and IndicativeSentences, the @IndicativeSentencesGeneration and @SentenceFragment annotations with their rendered output, the junit.jupiter.displayname.generator.default configuration parameter, and the four-step precedence ending at Standard.
  • JUnit User Guide 6.1.3 — Test Instance Lifecycle. That a new test class instance is created before each test method by default, what @TestInstance(Lifecycle.PER_CLASS) changes, and the warning that altering the project-wide default "can lead to unpredictable results and fragile builds if not applied consistently".
  • JUnit User Guide 6.1.3 — Tags. The syntax rules for tags, including the reserved characters , ( ) & | !, and the tag-expression grammar with its operator precedence plus the any() and none() forms.
  • JUnit 6.1.3 release overview (user guide). That the current release is 6.1.3, composed of Platform, Jupiter and Vintage, and that JUnit requires Java 17 or higher at runtime, the version everything in this article was checked against on 2026-09-07.
  • Gradle 9.7.1 — how test classes are detected and filtered. Gradle's JUnit 3/4 class-detection criteria and the **/*Test.class fallback patterns, and the statement that "with JUnit Platform, only includes and excludes are used to filter test classes — scanForTestClasses has no effect". Also useJUnitPlatform { includeTags … excludeTags … } as the supported way to select by tag. Gradle 9.7.1.
Part 1 of 8

JVM Quality Architecture

Get part 2: Use JUnit Parameterized Tests Without Matrix Explosion

It is live now: read part 2. The form below subscribes you to The Automation Brief, the site newsletter.

No spam, no sending cadence promised. Unsubscribe any time.