{"title":"Structure JUnit 5 Tests Around Behavior, Not Classes","excerpt":"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.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/junit-5-tests-behavior-not-classes","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["junit","java","jvm","test-architecture","gradle","test-design"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-26T13:30:00.000Z","dateModified":"2026-10-14T10:26:49.132Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/junit-5-tests-behavior-not-classes.webp","alt":"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."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> 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 <em>precondition</em> using <code class=\"language-text\">@Nested</code>, name methods after the outcome a rule promises, and split by <em>runtime contract</em>: 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.</div>\n\n<p>A service class picks up a method. Someone adds the matching test method to <code class=\"language-text\">OrderServiceTest</code>. 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 <code class=\"language-text\">OrderServiceTest</code>, one in <code class=\"language-text\">PromotionRepositoryTest</code>, one in <code class=\"language-text\">CheckoutControllerTest</code>. Delete the rule from the product and you cannot find every test that asserted it.</p>\n\n<p>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.</p>\n\n<div class=\"callout callout-warning\"><strong>\"JUnit 5\" is a programming model, not the current version.</strong> 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 <code class=\"language-text\">junit-jupiter</code> version before copying a coordinate out of any article, including this one.</div>\n\n<h2 id=\"the-engine-builds-a-tree-not-a-mirror\">The engine builds a tree, not a mirror</h2>\n\n<p>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 <code class=\"language-text\">@Nested</code> inner class is another container inside it. A <code class=\"language-text\">@ParameterizedTest</code> is a container whose children are its invocations. Nothing in that process consults your production type graph.</p>\n\n<p>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 <code class=\"language-text\">public</code> unless a technical reason demands it. Nesting \"can be arbitrarily deep, and those inner classes are subject to full lifecycle support, including <code class=\"language-text\">@BeforeAll</code> and <code class=\"language-text\">@AfterAll</code> methods on each level.\" A Java <code class=\"language-text\">record</code> can be a test class.</p>\n\n<p>So a container costs one inner class declaration. If grouping is that cheap, the question is what grouping is <em>for</em>, and \"matching a production file name\" is not an answer that survives being asked.</p>\n\n<h2 id=\"the-file-name-is-not-a-discovery-filter\">The file name is not a discovery filter</h2>\n\n<p>Many teams believe the <code class=\"language-text\">*Test</code> suffix is load-bearing. Under JUnit Platform it is not, and Gradle says so explicitly. Gradle's class-scanning heuristics (inherits from <code class=\"language-text\">TestCase</code>, carries <code class=\"language-text\">@RunWith</code>, contains an <code class=\"language-text\">@Test</code> method) belong to the JUnit 3 and 4 path, and its fallback patterns <code class=\"language-text\">**/*Tests.class</code> and <code class=\"language-text\">**/*Test.class</code> apply when scanning is switched off. Then comes the sentence that matters:</p>\n\n<blockquote><p>With JUnit Platform, only <code class=\"language-text\">includes</code> and <code class=\"language-text\">excludes</code> are used to filter test classes — <code class=\"language-text\">scanForTestClasses</code> has no effect.</p></blockquote>\n\n<p>Rename <code class=\"language-text\">OrderPricingTest</code> to <code class=\"language-text\">BulkDiscountRules</code> 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.</p>\n\n<h2 id=\"group-by-the-precondition-tests-share\">Group by the precondition tests share</h2>\n\n<p>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.\"</p>\n\n<p>An order-pricing rule, carved by precondition rather than by class:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@DisplayName(\"Order pricing\")\nclass OrderPricing {\n\n    private PriceList prices;\n\n    @BeforeEach\n    void loadPriceList() {\n        prices = PriceList.of(Money.usd(\"2.00\"));\n    }\n\n    @Nested\n    @DisplayName(\"with no promotion\")\n    class WithoutPromotion {\n\n        @Test\n        @DisplayName(\"charges list price below the bulk threshold\")\n        void chargesListPriceBelowThreshold() {\n            assertEquals(Money.usd(\"18.00\"), price(9, prices, none()));\n        }\n\n        @Test\n        @DisplayName(\"applies bulk pricing from ten units\")\n        void appliesBulkPricingFromTenUnits() {\n            assertEquals(Money.usd(\"17.00\"), price(10, prices, none()));\n        }\n    }\n\n    @Nested\n    @DisplayName(\"with an expired promotion\")\n    class WithExpiredPromotion {\n\n        private Promotion promotion;\n\n        @BeforeEach\n        void expireIt() {\n            promotion = Promotion.percentage(20).endingOn(LocalDate.of(2026, 1, 31));\n        }\n\n        @Test\n        @DisplayName(\"rejects the promotion and charges the undiscounted price\")\n        void rejectsTheExpiredPromotion() {\n            assertEquals(Money.usd(\"18.00\"),\n                    price(9, prices, promotion, asOf(LocalDate.of(2026, 2, 1))));\n        }\n\n        @Test\n        @DisplayName(\"still applies bulk pricing\")\n        void stillAppliesBulkPricing() {\n            assertEquals(Money.usd(\"17.00\"),\n                    price(10, prices, promotion, asOf(LocalDate.of(2026, 2, 1))));\n        }\n    }\n}</code></pre>\n\n<p>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 <code class=\"language-text\">expireIt()</code> runs for exactly the tests that need an expired promotion, instead of being one branch of a <code class=\"language-text\">@BeforeEach</code> that every test pays for.</p>\n\n<p>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 <code class=\"language-text\">@Nested</code> 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 <code class=\"language-text\">@BeforeEach</code> and no shared precondition to state, it is not earning its container.</p>\n\n<h2 id=\"let-the-report-read-as-the-specification\">Let the report read as the specification</h2>\n\n<p>Once containers carry preconditions, the display names can carry the rule. Jupiter ships four generators (<code class=\"language-text\">Standard</code>, <code class=\"language-text\">Simple</code>, <code class=\"language-text\">ReplaceUnderscores</code> and <code class=\"language-text\">IndicativeSentences</code>), and the last one \"generates complete sentences by concatenating the names of the test and the enclosing classes.\"</p>\n\n<p>That turns the nesting you already have into prose, without a <code class=\"language-text\">@DisplayName</code> on every method:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@SentenceFragment(\"An expired promotion\")\n@IndicativeSentencesGeneration(separator = \" -&gt; \", generator = ReplaceUnderscores.class)\nclass ExpiredPromotionRules {\n\n    @SentenceFragment(\"is not applied to the order total\")\n    @Test\n    void notAppliedToTotal() { }\n\n    @Test\n    void does_not_prevent_bulk_pricing() { }\n}</code></pre>\n\n<pre class=\"language-text\"><code class=\"language-text\">An expired promotion ✔\n├─ An expired promotion -&gt; is not applied to the order total ✔\n└─ An expired promotion -&gt; does not prevent bulk pricing ✔</code></pre>\n\n<p>Set it once for the whole project rather than per class, using <code class=\"language-text\">junit.jupiter.displayname.generator.default</code> in <code class=\"language-text\">src/test/resources/junit-platform.properties</code>. The precedence is documented and worth holding in your head, because it explains why a name you set does not appear: an explicit <code class=\"language-text\">@DisplayName</code> wins, then a <code class=\"language-text\">@DisplayNameGeneration</code> on an enclosing class, then the configured default, then <code class=\"language-text\">Standard</code>.</p>\n\n<h2 id=\"inheritance-is-the-sharing-mechanism-that-bites\">Inheritance is the sharing mechanism that bites</h2>\n\n<p>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.\"</p>\n\n<p>The inheritance covers <em>methods</em>, not only setup. Put a convenience <code class=\"language-text\">@Test</code> 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 <code class=\"language-text\">@Test</code> method is declared in a superclass that resides in a different package than the subclass, that <code class=\"language-text\">@Test</code> method will always be applied in the subclass since the subclass cannot override a package-private method from a superclass in a different package.\"</p>\n\n<p>Base classes for shared <em>lifecycle</em> 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.</p>\n\n<p>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 <code class=\"language-text\">PER_CLASS</code> to allow non-static <code class=\"language-text\">@BeforeAll</code> 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.</p>\n\n<h2 id=\"when-the-split-is-a-suite-and-not-a-container\">When the split is a suite and not a container</h2>\n\n<p>Containers organise behaviour. They do not separate <em>runtime contracts</em>, and that is the split that decides whether your fast feedback loop stays fast.</p>\n\n<p>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.</p>\n\n<p>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 <code class=\"language-text\">,</code> <code class=\"language-text\">(</code> <code class=\"language-text\">)</code> <code class=\"language-text\">&amp;</code> <code class=\"language-text\">|</code> <code class=\"language-text\">!</code>, the characters that make up the tag-expression grammar. So <code class=\"language-text\">needs-postgres</code> is a legal tag and <code class=\"language-text\">db,slow</code> is not.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@Tag(\"needs-postgres\")\n@Retention(RUNTIME)\n@Target({ TYPE, METHOD })\npublic @interface NeedsPostgres { }</code></pre>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">tasks.withType(Test).configureEach {\n    useJUnitPlatform {\n        includeTags 'fast'\n        excludeTags 'slow'\n    }\n}</code></pre>\n\n<p>Tag expressions support <code class=\"language-text\">!</code>, <code class=\"language-text\">&amp;</code> and <code class=\"language-text\">|</code> with parentheses, plus <code class=\"language-text\">any()</code> and <code class=\"language-text\">none()</code>, so <code class=\"language-text\">(micro | integration) &amp; (product | shipping)</code> 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.</p>\n\n<h2 id=\"the-map-that-makes-the-argument-checkable\">The map that makes the argument checkable</h2>\n\n<p>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:</p>\n\n<table>\n<thead><tr><th>Behaviour</th><th>Layer</th><th>Container</th><th>Needs</th></tr></thead>\n<tbody>\n<tr><td>Bulk pricing starts at ten units</td><td>unit</td><td><code class=\"language-text\">OrderPricing.WithoutPromotion</code></td><td>JVM</td></tr>\n<tr><td>An expired promotion is not applied</td><td>unit</td><td><code class=\"language-text\">OrderPricing.WithExpiredPromotion</code></td><td>JVM</td></tr>\n<tr><td>Promotion validity is read from the stored end date</td><td>integration</td><td><code class=\"language-text\">PromotionPersistence</code></td><td>PostgreSQL</td></tr>\n<tr><td>Order totals round to the currency's minor unit</td><td>unit</td><td><code class=\"language-text\">MoneyRounding</code></td><td>JVM</td></tr>\n<tr><td>The quote response exposes the discounted total</td><td>contract</td><td><code class=\"language-text\">QuoteRepresentation</code></td><td>running service</td></tr>\n</tbody>\n</table>\n\n<p>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.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Open the largest test class in your repository and list the distinct preconditions its <code class=\"language-text\">@BeforeEach</code> sets up conditionally: every <code class=\"language-text\">if</code>, every flag parameter, every \"null means no promotion\". Each one is a <code class=\"language-text\">@Nested</code> container waiting to be extracted, and the extraction is mechanical: move the branch into the inner class's own <code class=\"language-text\">@BeforeEach</code> and drop the branch.</p>\n\n<p>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.</p>\n\n<p>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.</p>\n\n<h2 id=\"questions-about-carving-up-a-jupiter-suite\">Questions about carving up a Jupiter suite</h2>\n\n<h3 id=\"does-one-container-per-rule-mean-hundreds-of-files\">Does one container per rule mean hundreds of files?</h3>\n\n<p>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.</p>\n\n<h3 id=\"what-happens-to-coverage-tooling-when-classes-stop-matching\">What happens to coverage tooling when test classes stop matching production classes?</h3>\n\n<p>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.</p>\n\n<h3 id=\"is-there-a-case-for-keeping-the-one-to-one-mirror\">Is there still a case for keeping the mirror?</h3>\n\n<p>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.</p>\n\n<h3 id=\"how-should-nested-containers-interact-with-parallel-execution\">How should nested containers interact with parallel execution?</h3>\n\n<p>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: <code class=\"language-text\">@Execution</code> 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.</p>\n\n<h3 id=\"do-display-name-generators-affect-filtering-or-reporting-tools\">Do display-name generators affect filtering or CI reporting?</h3>\n\n<p>They affect what a human reads, not what a filter matches. Gradle's <code class=\"language-text\">--tests</code> 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 -&gt; is not applied to the order total\" needs no follow-up question, and one that says <code class=\"language-text\">testPromotion3</code> always does.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://docs.junit.org/current/writing-tests/nested-tests.html\">JUnit User Guide 6.1.3 — Nested Tests</a>. That only non-static inner classes can be <code class=\"language-text\">@Nested</code> containers, that \"nesting can be arbitrarily deep, and those inner classes are subject to full lifecycle support, including <code class=\"language-text\">@BeforeAll</code> and <code class=\"language-text\">@AfterAll</code> methods on each level\", and that outer setup always runs first so \"you can even run inner tests alone without running the outer tests\".</li>\n<li><a href=\"https://docs.junit.org/current/writing-tests/test-classes-and-methods.html\">JUnit User Guide 6.1.3 — Test Classes and Methods</a>. 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 <code class=\"language-text\">record</code> may be used as a test class.</li>\n<li><a href=\"https://docs.junit.org/current/writing-tests/display-names.html\">JUnit User Guide 6.1.3 — Display Names</a>. The four built-in generators <code class=\"language-text\">Standard</code>, <code class=\"language-text\">Simple</code>, <code class=\"language-text\">ReplaceUnderscores</code> and <code class=\"language-text\">IndicativeSentences</code>, the <code class=\"language-text\">@IndicativeSentencesGeneration</code> and <code class=\"language-text\">@SentenceFragment</code> annotations with their rendered output, the <code class=\"language-text\">junit.jupiter.displayname.generator.default</code> configuration parameter, and the four-step precedence ending at <code class=\"language-text\">Standard</code>.</li>\n<li><a href=\"https://docs.junit.org/current/writing-tests/test-instance-lifecycle.html\">JUnit User Guide 6.1.3 — Test Instance Lifecycle</a>. That a new test class instance is created before each test method by default, what <code class=\"language-text\">@TestInstance(Lifecycle.PER_CLASS)</code> changes, and the warning that altering the project-wide default \"can lead to unpredictable results and fragile builds if not applied consistently\".</li>\n<li><a href=\"https://docs.junit.org/current/running-tests/tags.html\">JUnit User Guide 6.1.3 — Tags</a>. The syntax rules for tags, including the reserved characters <code class=\"language-text\">,</code> <code class=\"language-text\">(</code> <code class=\"language-text\">)</code> <code class=\"language-text\">&amp;</code> <code class=\"language-text\">|</code> <code class=\"language-text\">!</code>, and the tag-expression grammar with its operator precedence plus the <code class=\"language-text\">any()</code> and <code class=\"language-text\">none()</code> forms.</li>\n<li><a href=\"https://docs.junit.org/current/overview.html\">JUnit 6.1.3 release overview (user guide)</a>. 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.</li>\n<li><a href=\"https://docs.gradle.org/current/userguide/java_testing.html\">Gradle 9.7.1 — how test classes are detected and filtered</a>. Gradle's JUnit 3/4 class-detection criteria and the <code class=\"language-text\">**/*Test.class</code> fallback patterns, and the statement that \"with JUnit Platform, only <code class=\"language-text\">includes</code> and <code class=\"language-text\">excludes</code> are used to filter test classes — <code class=\"language-text\">scanForTestClasses</code> has no effect\". Also <code class=\"language-text\">useJUnitPlatform { includeTags … excludeTags … }</code> as the supported way to select by tag. Gradle 9.7.1.</li>\n</ul>\n"}