{"title":"Model JVM Test Layers with Gradle Test Suites","excerpt":"When integration tests live in src/test/java behind a filename filter, nothing stops a slow dependency entering the fast feedback path — and under JUnit Platform the filename is not even a discovery mechanism. This guide covers what a JvmTestSuite creates for you, why only the built-in test suite inherits the production code's dependencies and what implementation project() does and does not bring with it, wiring each suite into check with an explicit ordering, keeping suite identity in the JUnit XML and the aggregation variant, and the three costs of the split.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/gradle-jvm-test-suites-unit-integration-contract","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["gradle","jvm","java","junit","integration-testing","build-tooling"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-29T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/gradle-jvm-test-suites-unit-integration-contract.webp","alt":"Dark technical illustration read from left to right. On the left, nine faint grey lines and short grey pills enter from off the left edge and converge on one tall green-outlined rounded panel that also runs off the edge. Inside it, nine horizontal pills of varying length are stacked; six are green and three are a brighter orange, interleaved with them. Four curved grey lines leave that panel, pass through a dashed green vertical line that has a ringed dot at each end, and continue as green curves ending in arrowheads at four separate dashed green containers stacked down the right two-thirds of the frame. Each container has three short stubs entering its left edge from small pills, and holds two or three rows made of a ringed dot followed by a long pill and a shorter one beneath it. Three of the containers are green throughout; the second one from the top is orange throughout, including its entry pills. From every container a straight green line leaves the right edge, crosses a faint vertical grey rail that runs off the top and bottom of the frame, and ends at a large ringed green dot with a small grey pill beneath it. Thin grey rules and faint pills sit at the very top and bottom."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Declare each layer as its own <code class=\"language-text\">JvmTestSuite</code> so it gets a source set, a configuration set, a <code class=\"language-text\">Test</code> task and a results directory of its own. Three conventions will catch you out: only the built-in <code class=\"language-text\">test</code> suite inherits the production code's dependencies, every other suite needs an explicit <code class=\"language-text\">implementation project()</code>; new suites are not wired to <code class=\"language-text\">check</code>; and their targets have no ordering relationship to <code class=\"language-text\">test</code> until you add <code class=\"language-text\">shouldRunAfter</code>. The plugin is still incubating, so pin your Gradle version alongside it.</div>\n\n<p>A team agrees that integration tests end in <code class=\"language-text\">IT</code> and unit tests end in <code class=\"language-text\">Test</code>, and that <code class=\"language-text\">./gradlew test</code> runs only the second kind. Someone adds the filter to <code class=\"language-text\">build.gradle</code>. Eighteen months later the command takes eleven minutes and needs Docker running.</p>\n\n<p>Nothing broke. The filter was moved during a refactor, or a new module never got one, or a class was named <code class=\"language-text\">OrderRepositoryTest</code> because it tests <code class=\"language-text\">OrderRepository</code> and it happens to open a connection. A convention that lives in a string pattern cannot report that it has been violated, and under JUnit Platform it is not even a discovery mechanism. Gradle states that there \"only <code class=\"language-text\">includes</code> and <code class=\"language-text\">excludes</code> are used to filter test classes.\"</p>\n\n<p>The alternative is to make the layer a build object rather than a naming habit. That is what the <code class=\"language-text\">jvm-test-suite</code> plugin models, and the reason it works is not the DSL: it is that a suite carries its own compile classpath, so a dependency that only integration tests are allowed to use cannot be imported from a unit test at all. The rule stops being social and starts being a compile error.</p>\n\n<div class=\"callout callout-warning\"><strong>Two conventions will break your first suite, and both are the opposite of the obvious guess.</strong> An added suite does not inherit the production code's dependencies; only the built-in <code class=\"language-text\">test</code> suite does. Even <code class=\"language-text\">implementation project()</code> hands you the project's outputs plus its <code class=\"language-text\">api</code> and <code class=\"language-text\">compileOnlyApi</code> dependencies, never its <code class=\"language-text\">implementation</code> ones. An added suite is also wired to neither <code class=\"language-text\">check</code> nor any run ordering until you say so. Note too that <code class=\"language-text\">jvm-test-suite</code> remains an incubating API, so pin the wrapper: everything here was checked against Gradle 9.7.1 on 2026-09-07.</div>\n\n<h2 id=\"what-you-get-before-you-configure-anything\">What you get before you configure anything</h2>\n\n<p>The plugin ships with Gradle and \"is applied automatically by the <code class=\"language-text\">java</code> plugin\", so a plain Java project already has a <code class=\"language-text\">testing</code> extension and a suite named <code class=\"language-text\">test</code>. Declaring a second one is four lines:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">testing {\n    suites {\n        test {\n            useJUnitJupiter('6.1.3')\n        }\n\n        integrationTest(JvmTestSuite) {\n            dependencies {\n                implementation project()\n            }\n        }\n    }\n}</code></pre>\n\n<p>That creates a source set at <code class=\"language-text\">src/integrationTest/java</code>, the configurations <code class=\"language-text\">integrationTestImplementation</code>, <code class=\"language-text\">integrationTestCompileOnly</code> and <code class=\"language-text\">integrationTestRuntimeOnly</code>, and a <code class=\"language-text\">Test</code> task named <code class=\"language-text\">integrationTest</code>. One suite, one target, one task. The plugin is explicit that this is currently a 1:1:1 relationship and that \"future iterations of the plugin will allow defining multiple targets based on other attributes, such as a particular JDK/JRE runtime\".</p>\n\n<p>Two conventions in that snippet are worth stating outright, because they are the reverse of what most people guess. The built-in <code class=\"language-text\">test</code> suite has no framework configured by default: \"it is always necessary to explicitly configure the built-in test suite even if JUnit 4 is desired\". Every suite you add afterwards does have one: they \"will by convention work as if <code class=\"language-text\">useJUnitJupiter()</code> was called\".</p>\n\n<p>Use the suite's own <code class=\"language-text\">useJUnitJupiter()</code> rather than the <code class=\"language-text\">Test</code> task's <code class=\"language-text\">useJUnitPlatform()</code>, because they are not equivalent despite the similar names. The suite method \"automatically apply[s] the relevant testing framework libraries to the compile and runtime classpaths of the suite targets\" as well as configuring the task, and its overload takes a version string. The task method only configures the task, leaving you to declare <code class=\"language-text\">junit-jupiter</code> yourself.</p>\n\n<h2 id=\"the-dependency-cliff-past-the-built-in-suite\">The dependency cliff past the built-in suite</h2>\n\n<p>Here is the sentence that will cost you a build failure and then explain a design. Of the automatic dependency on production code: \"by convention, only the built-in <code class=\"language-text\">test</code> suite will automatically have a dependency on the production code of the project.\"</p>\n\n<p>So <code class=\"language-text\">implementation project()</code> is not boilerplate. Without it, <code class=\"language-text\">src/integrationTest/java</code> cannot see your own classes at all. And with it you get less than <code class=\"language-text\">test</code> gets, because a project dependency \"provides access to the project's outputs as well as any dependencies declared on its <code class=\"language-text\">api</code> and <code class=\"language-text\">compileOnlyApi</code> configurations\", not its <code class=\"language-text\">implementation</code> ones.</p>\n\n<p>Concretely: a library declared <code class=\"language-text\">implementation 'com.fasterxml.jackson.core:jackson-databind:…'</code> in the production block is on the unit-test compile classpath and is not on the integration-test compile classpath. Both are correct. <code class=\"language-text\">implementation</code> means \"internal\", and the integration suite is a separate consumer that has to ask for what it needs:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">testing {\n    suites {\n        integrationTest(JvmTestSuite) {\n            dependencies {\n                implementation project()\n                implementation 'org.testcontainers:testcontainers-postgresql:2.0.5'\n                implementation 'org.postgresql:postgresql:42.7.4'\n                runtimeOnly 'ch.qos.logback:logback-classic:1.5.6'\n            }\n        }\n\n        contractTest(JvmTestSuite) {\n            dependencies {\n                implementation project()\n                implementation 'io.rest-assured:rest-assured:5.4.0'\n            }\n        }\n    }\n}</code></pre>\n\n<p>This is the property the naming convention never had. A unit test cannot accidentally import <code class=\"language-text\">PostgreSQLContainer</code>, because that artefact is not on its compile classpath, and the compiler says so before anyone runs a build. Note the block's scoping too: inside a suite's <code class=\"language-text\">dependencies</code> you write <code class=\"language-text\">implementation</code>, <code class=\"language-text\">compileOnly</code>, <code class=\"language-text\">runtimeOnly</code> and <code class=\"language-text\">annotationProcessor</code> without prefixing the suite name, because \"the <code class=\"language-text\">dependencies</code> block within a test suite is already scoped for that test suite.\"</p>\n\n<p>When several suites share a stack, factor it with <code class=\"language-text\">configureEach</code> for all of them or <code class=\"language-text\">withType(JvmTestSuite).matching { … }.configureEach</code> for a subset, rather than repeating the list. Shared assertion libraries belong there; the database driver does not.</p>\n\n<h2 id=\"cadence-is-a-task-graph-not-a-comment\">Cadence is a task graph, not a comment</h2>\n\n<p>A suite that nobody runs is worse than no suite, and the plugin will not run it for you. Two wiring steps, both documented as conventions you must override:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">testing {\n    suites {\n        integrationTest(JvmTestSuite) {\n            targets {\n                all {\n                    // By convention a suite target has no relationship to any\n                    // other Test task, so the slow suite may start first.\n                    testTask.configure { shouldRunAfter(test) }\n                }\n            }\n        }\n    }\n}\n\n// By convention test suite targets are not associated with the check task.\ntasks.named('check') {\n    dependsOn(testing.suites.integrationTest)\n}</code></pre>\n\n<p>Which suites belong on <code class=\"language-text\">check</code> is the decision worth making deliberately, because it is the same question as \"what must be true before this merges\". A workable split for a service with four layers:</p>\n\n<table>\n<thead><tr><th>Suite</th><th>Runs on</th><th>Needs</th><th>Fails whose build</th></tr></thead>\n<tbody>\n<tr><td><code class=\"language-text\">test</code></td><td>every commit, on <code class=\"language-text\">check</code></td><td>a JVM</td><td>the author's</td></tr>\n<tr><td><code class=\"language-text\">integrationTest</code></td><td>pre-merge, on <code class=\"language-text\">check</code></td><td>a container runtime</td><td>the author's</td></tr>\n<tr><td><code class=\"language-text\">contractTest</code></td><td>when the provider or its schema changes</td><td>a published contract</td><td>the provider team's</td></tr>\n<tr><td><code class=\"language-text\">acceptanceTest</code></td><td>release candidates</td><td>a deployed environment</td><td>the release manager's</td></tr>\n</tbody>\n</table>\n\n<p>The last column is the one that decides the row. A suite whose failures are somebody else's to fix does not belong in the gate that blocks this pull request, and putting it there is how teams learn to rerun red builds.</p>\n\n<p>Before trusting new wiring, verify it without paying for it. Gradle's <code class=\"language-text\">--test-dry-run</code> \"will simulate the execution of the tests without actually running them. This will still generate reports, allowing for inspection of what tests were selected.\" That is the fastest way to confirm a suite picked up the classes you expected, and it costs seconds:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">./gradlew integrationTest --test-dry-run\n./gradlew check --dry-run          # the task graph, without running anything</code></pre>\n\n<h2 id=\"keeping-suite-identity-in-the-results\">Keeping suite identity in the results</h2>\n\n<p>Separate tasks give you separate results directories: JUnit XML lands in <code class=\"language-text\">build/test-results/$testTaskName</code> with a file per class. The HTML report does not preserve that split: it \"automatically includes the results from all your <code class=\"language-text\">Test</code> tasks\", so \"if you add a <code class=\"language-text\">Test</code> task for integration tests, the report will include the results of both the unit tests and the integration tests if both tasks are run.\" Read the HTML for a human summary and parse the XML when you need to know which layer failed.</p>\n\n<p>Two XML options are worth changing on day one, because the defaults predate the tooling that consumes them:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">tasks.withType(Test).configureEach {\n    reports {\n        junitXml {\n            // Attach log output to the individual test case rather than the\n            // class. Gradle's own recommendation, and off by default.\n            outputPerTestCase = true\n            // Report a retried pass as &lt;flakyFailure&gt; instead of hiding it.\n            mergeReruns = true\n        }\n    }\n}</code></pre>\n\n<p>Gradle's guidance on the first is unambiguous: \"most modern tools that observe JUnit XML files support the 'output per test case' format… it is recommended to enable this option as it provides more useful reporting.\" The second changes how a retried test is recorded rather than whether it is retried, which keeps a flake visible to a CI server that understands the format.</p>\n\n<p>For a multi-project build, suite identity is carried in the dependency graph rather than in a path. Every suite \"creates an outgoing variant containing its test execution results\" tagged with <code class=\"language-text\">org.gradle.testsuite.name</code>, and the <code class=\"language-text\">test-report-aggregation</code> plugin matches on exactly that attribute to build one report per suite name across subprojects. One detail decides whether you ever see those reports: Gradle \"stops executing tasks when any task fails — including test failures\", so aggregation runs need <code class=\"language-text\">--continue</code>.</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">./gradlew testAggregateTestReport integrationTestAggregateTestReport --continue</code></pre>\n\n<h2 id=\"each-suite-gets-its-own-execution-budget\">Each suite gets its own execution budget</h2>\n\n<p>The point of the separation is that the layers stop having to agree. A unit suite wants many small JVMs; an integration suite wants few, because each one is holding a container.</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">testing {\n    suites {\n        test {\n            targets.all {\n                testTask.configure {\n                    maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1\n                }\n            }\n        }\n\n        integrationTest(JvmTestSuite) {\n            targets.all {\n                testTask.configure {\n                    maxParallelForks = 1\n                    maxHeapSize = '1g'\n                    // Jupiter's own threading is configured per suite too.\n                    systemProperty 'junit.jupiter.execution.parallel.enabled', 'false'\n                }\n            }\n        }\n    }\n}</code></pre>\n\n<p>Note that <code class=\"language-text\">testTask</code> is a <code class=\"language-text\">TaskProvider&lt;Test&gt;</code>, so <code class=\"language-text\">configure</code> keeps it lazy. You can also configure the resulting task by name outside the suites block, which the documentation says is fine, and that is often easier to read for one-off settings that have nothing to do with suite modelling, such as a debugging JVM argument.</p>\n\n<h2 id=\"what-the-split-actually-costs\">What the split actually costs</h2>\n\n<p>Three costs, none fatal, all worth knowing before you propose this.</p>\n\n<p>The plugin is incubating. Gradle labels it \"an incubating API and is subject to change in a future release\", and the <code class=\"language-text\">java_testing</code> chapter refers to it the same way. In practice the DSL has been stable across releases, but incubating means a minor upgrade may require an edit, so the Gradle version belongs in your wrapper and in your CI image rather than floating.</p>\n\n<p>Dependency duplication is real. Each suite declares its own assertion library, its own logging backend, its own mocking framework. <code class=\"language-text\">configureEach</code> reduces the repetition; it does not remove the fact that four suites means four classpaths to keep aligned when a version moves.</p>\n\n<p>And a suite is a coarser unit than a tag. If half your integration tests need Postgres and half need Kafka, a suite per dependency is heavy and a tag inside one suite is lighter. The line to hold is the one from part one of this series: split into a suite when the <em>runtime contract</em> differs: different dependencies, different environment, different owner. Use tags to slice within a suite when everything except the selection is the same.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Run <code class=\"language-text\">./gradlew test --test-dry-run</code> and read the class list. Every class in it that needs a database, a broker, a browser or a network route is a class that is currently inside your fast feedback loop, and the count is usually higher than the team's estimate.</p>\n\n<p>Then create one suite named <code class=\"language-text\">integrationTest</code> and move exactly those classes into <code class=\"language-text\">src/integrationTest/java</code>. The compile errors you get are the finding: each one is a dependency that unit tests were carrying to serve integration tests. Add them to the new suite's <code class=\"language-text\">dependencies</code> block and delete them from <code class=\"language-text\">testImplementation</code>.</p>\n\n<p>Finish by wiring <code class=\"language-text\">check</code> to the new suite and adding <code class=\"language-text\">shouldRunAfter(test)</code>, then compare <code class=\"language-text\">./gradlew test</code> before and after. What you should be able to show is a shorter unit run, a <code class=\"language-text\">testImplementation</code> block with fewer entries in it, and a second results directory under <code class=\"language-text\">build/test-results/</code> that did not exist yesterday.</p>\n\n<h2 id=\"questions-about-modelling-jvm-test-layers\">Questions about modelling JVM test layers</h2>\n\n<h3 id=\"is-this-better-than-hand-rolling-a-source-set\">Is this better than declaring a source set and a Test task by hand?</h3>\n\n<p>Yes, for one reason that outlives the syntax: the outgoing variant. A hand-rolled task produces results that nothing else can find by suite name, so cross-project aggregation has to be wired manually with attributes you invent. The suite publishes <code class=\"language-text\">org.gradle.testsuite.name</code>, and <code class=\"language-text\">test-report-aggregation</code> consumes it. Everything else the plugin does (source set, configurations, task) you could write yourself in about fifteen lines.</p>\n\n<h3 id=\"where-should-shared-test-helpers-live\">Where should shared test helpers live?</h3>\n\n<p>In a place both suites depend on, which usually means Gradle's test fixtures support or a separate module. What does not work is <code class=\"language-text\">integrationTestImplementation sourceSets.test.output</code>, which reintroduces exactly the coupling the split existed to remove: the integration suite would then compile against the unit suite's dependencies, and a unit test could once again import a container library transitively.</p>\n\n<h3 id=\"how-many-suites-is-too-many\">How many suites is too many?</h3>\n\n<p>The count that matters is how many distinct <em>environments</em> your tests need, and for most services that is two or three. If two suites have the same dependencies, the same runtime requirements and the same owner, they are one suite with two tags. Creating <code class=\"language-text\">smokeTest</code> as a fifth suite when it runs in the same environment as <code class=\"language-text\">acceptanceTest</code> buys a task name and costs a classpath.</p>\n\n<h3 id=\"does-the-build-cache-still-work-across-suites\">Do the build cache and up-to-date checks still work?</h3>\n\n<p>Each suite's <code class=\"language-text\">Test</code> task is an ordinary Gradle task with its own inputs, so it is skipped when nothing it depends on changed. That is most of the benefit of the split on a developer machine. The caveat is the same one that applies to any test task: if a test reads something Gradle does not know about, such as a container image tag from the environment, Gradle cannot see that input changed and will happily report the task up to date. Declare those as inputs explicitly or accept that the suite needs <code class=\"language-text\">--rerun-tasks</code> occasionally.</p>\n\n<h3 id=\"can-a-suite-use-a-different-testing-framework\">Can one suite use a different testing framework from another?</h3>\n\n<p>Yes, and it is one of the cleaner migration paths off an old framework. <code class=\"language-text\">useJUnit()</code>, <code class=\"language-text\">useJUnitJupiter()</code>, <code class=\"language-text\">useTestNG()</code> and their versioned overloads are per suite, so a legacy suite can stay on JUnit 4 while new work goes into a Jupiter suite, with both wired to <code class=\"language-text\">check</code>. The framework choice then shrinks over time by moving classes between source directories rather than by a flag day.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://docs.gradle.org/current/userguide/jvm_test_suite_plugin.html\">The JVM Test Suite plugin: Gradle 9.7.1 user manual</a>. That the plugin \"is an incubating API and is subject to change in a future release\" and \"is applied automatically by the <code class=\"language-text\">java</code> plugin\"; the objects it adds, including the <code class=\"language-text\">test</code> suite, its source set and the <code class=\"language-text\">testImplementation</code>/<code class=\"language-text\">testCompileOnly</code>/<code class=\"language-text\">testRuntimeOnly</code> configurations; the current 1:1:1 relationship between suite, target and <code class=\"language-text\">Test</code> task; that \"it is always necessary to explicitly configure the built-in test suite even if JUnit 4 is desired\" while other suites \"by convention work as if <code class=\"language-text\">useJUnitJupiter()</code> was called\"; that \"by convention, only the built-in <code class=\"language-text\">test</code> suite will automatically have a dependency on the production code of the project\" and that <code class=\"language-text\">implementation project()</code> \"provides access to the project's outputs as well as any dependencies declared on its <code class=\"language-text\">api</code> and <code class=\"language-text\">compileOnlyApi</code> configurations\"; that a suite's <code class=\"language-text\">dependencies</code> block \"is already scoped for that test suite\"; that \"by convention, test suite targets have no relationship to other <code class=\"language-text\">Test</code> tasks\" and \"are not associated with the <code class=\"language-text\">check</code> task\", with the <code class=\"language-text\">shouldRunAfter</code> and <code class=\"language-text\">dependsOn</code> examples; the conventional <code class=\"language-text\">src/integrationTest/java</code> location and how to override it; the <code class=\"language-text\">configureEach</code> and <code class=\"language-text\">withType(...).matching(...).configureEach</code> sharing patterns; that <code class=\"language-text\">testTask</code> is a <code class=\"language-text\">TaskProvider&lt;Test&gt;</code> and tasks may also be configured by name; the difference between <code class=\"language-text\">JvmTestSuite</code>'s framework methods and the <code class=\"language-text\">Test</code> task's, since the former also apply the framework libraries to the suite's classpaths; and the outgoing variant carrying <code class=\"language-text\">org.gradle.testsuite.name</code>.</li>\n<li><a href=\"https://docs.gradle.org/current/userguide/java_testing.html\">Gradle 9.7.1: test reporting, JUnit XML options and dry runs</a>. That the HTML report \"automatically includes the results from all your <code class=\"language-text\">Test</code> tasks\", so a report covers unit and integration results together; that JUnit XML is written to <code class=\"language-text\">build/test-results/$testTaskName</code> with a file per class; the <code class=\"language-text\">outputPerTestCase</code> option, off by default, with the recommendation to enable it, and <code class=\"language-text\">mergeReruns</code> recording a retried pass as <code class=\"language-text\">&lt;flakyFailure&gt;</code>; <code class=\"language-text\">maxParallelForks</code> defaulting to 1 and <code class=\"language-text\">forkEvery</code> defaulting to 0; <code class=\"language-text\">dryRun</code> and <code class=\"language-text\">--test-dry-run</code>, which \"will still generate reports, allowing for inspection of what tests were selected\"; and that \"with JUnit Platform, only <code class=\"language-text\">includes</code> and <code class=\"language-text\">excludes</code> are used to filter test classes\". Gradle 9.7.1.</li>\n<li><a href=\"https://docs.gradle.org/current/userguide/test_report_aggregation_plugin.html\">The Test Report Aggregation plugin: Gradle 9.7.1 user manual</a>. That the plugin \"takes no action unless applied in concert with the JVM Test Suite Plugin\"; that it adds a <code class=\"language-text\">testSuiteAggregateTestReport</code> task per suite which matches variants on <code class=\"language-text\">org.gradle.category = verification</code>, <code class=\"language-text\">org.gradle.testsuite.name</code> and <code class=\"language-text\">org.gradle.verificationtype = test-results</code>; the <code class=\"language-text\">testReportAggregation</code> and <code class=\"language-text\">aggregateTestReportResults</code> configurations; and that because Gradle \"stops executing tasks when any task fails — including test failures\", generating aggregation reports requires <code class=\"language-text\">--continue</code>.</li>\n<li><a href=\"https://docs.junit.org/current/overview.html\">JUnit 6.1.3: supported Java versions and module layout</a>. The current JUnit version used in the <code class=\"language-text\">useJUnitJupiter('6.1.3')</code> example, and that JUnit requires Java 17 or higher at runtime, a constraint that belongs in the suite's toolchain decision.</li>\n<li><a href=\"https://java.testcontainers.org/modules/databases/postgres/\">Testcontainers 2.0.5: the Postgres module artefact</a>. The <code class=\"language-text\">org.testcontainers:testcontainers-postgresql:2.0.5</code> coordinate used in the integration suite's dependency block, and the reminder that \"adding this Testcontainers library JAR will not automatically add a database driver JAR to your project\", which is why the JDBC driver is declared beside it.</li>\n</ul>\n"}