Parallel Test Execution in Java Projects with TestNG
Test suites in Java projects grow the moment a team commits to quality. A small suite that finishes in seconds quickly becomes a multi-minute one once integration tests, REST calls, and database roundtrips pile up. Australian engineering teams in Sydney and Brisbane often point at their CI dashboards and ask why feedback takes that long. With Atlassian and Canva prioritising rapid release cadence, distributed teams across Melbourne and Perth cannot afford to wait ten minutes for a green tick before merging a pull request, especially on a Friday arvo when most people want to head off.
TestNG is one of the few Java testing frameworks that ships parallel execution as a first-class citizen rather than a bolt-on. With a short XML configuration and a handful of annotations, a slow sequential suite becomes a concurrent runner that scales with the available cores. The trick is to set it up deliberately, watch the trade-offs, and keep an eye on thread safety so the time saved does not vanish into flaky tests.
Why parallelism matters for modern Java test suites
Sequential execution was a reasonable default when CI servers were single-core agents and JUnit 4 was the lingua franca. The world has moved on. A modern laptop or a self-hosted runner in an AWS Sydney region usually exposes at least four cores, and many cloud agents sit on machines with eight or sixteen. Running a single test thread on that hardware wastes most of the capacity on offer, which is exactly why TestNG parallel execution is worth considering.
The real cost of slow tests is rarely about raw compute. It is about the cognitive switch that happens while a developer waits for a build to finish. A team in Adelaide that wants to deploy a payment change before the Open starts on a Friday afternoon is unlikely to wait around for a one-hour suite. When feedback drags, engineers batch up commits, code review slows, and bugs slip into production. Compressing the feedback loop through parallelism is usually a higher-leverage investment than adding more RAM to a database.
There is also an observability argument. Once a suite runs in parallel, distinct failure patterns start to surface. Order-dependent tests, accidental shared state, and race conditions show up the moment two threads touch the same fixture. Catching them at the test stage is dramatically cheaper than discovering them in a customer-facing workflow. Concepts borrowed from broader concurrency tutorials, such as those at free tutorial guides, can help teams reason about parallelism even when the goal is functional coverage.
Core XML configuration for TestNG parallel runs
The centrepiece of parallel execution is the TestNG suite file, usually named testng.xml. Inside the suite tag, the parallel attribute accepts methods, tests, classes, or instances. Setting parallel="methods" thread-count="8" dispatches each annotated method to a pool of eight workers, the right starting point for a CPU-bound unit suite.
A minimal file looks like this:
<!DOCTYPE suite SYSTEM "https://testng.org/testng-1.0.dtd">
<suite name="Regression" parallel="methods" thread-count="8">
<test name="Login and Accounts">
<classes>
<class name="com.example.checkout.LoginTests"/>
<class name="com.example.checkout.AccountTests"/>
</classes>
</test>
</suite>
Choosing between modes is more art than science. Methods mode is the fastest on paper but exposes the suite to any thread-safety gaps inside fixtures. Classes mode gives each class a dedicated thread, safer for suites with shared static fields. Tests mode treats each <test> block as a thread boundary, fitting independent scenarios like separate browser configurations for cross-browser checks. Instances mode is rare but useful when a class is instantiated once and reused across methods.
Threading model and DataProvider parallelism
DataProviders deserve a closer look because they are the most common way teams feed many data sets into a single method. Marking a provider with parallel=true turns the framework into a worker that drains the iterator concurrently. Combined with invocationCount and invocationTimeOut, a single test becomes a small stress scenario without changing the assertion logic. Australian teams running payment, GST, or address-validation logic get useful CI coverage in a reasonable window.
Thread reuse is controlled via the ITestContext, shared per suite, and the ITestResult, unique per test. The framework pools threads based on the configured count, so spinning up twenty methods with thread-count=8 queues the extras rather than create new ones. Tests that internally launch their own executors should still be guarded; otherwise the actual parallelism balloons past the configured ceiling and exhausts the heap on smaller build agents.
A practical pattern is to keep DataProviders stateless. Avoid touching external resources inside the provider itself, since providers may run on a different thread than the test method. Static caches populated before the test runs can introduce hard-to-spot flakiness when a parallel run hits a different ordering than a local dev run. Keeping the provider pure and the test responsible for setup solves most of this.
| Aspect | Methods mode | Classes mode | Tests mode |
|---|---|---|---|
| Thread assignment | One per @Test method | One per test class | One per <test> block |
| Best for | Small, fast unit tests | Suites with shared fixtures | Independent scenarios |
| Risk of order coupling | High | Medium | Low |
| Typical thread-count | 6-16 on a CI runner | 2-6 | 2-4 |
| Isolation level | Method | Class | Logical group |
Handling shared state and thread safety
The most cited reason teams abandon parallelism is shared mutable state. TestNG does not isolate threads automatically, so anything in a static field, a singleton bean, or a Spring application context shared across tests becomes a hazard. A common fix is to use @BeforeMethod to instantiate fresh collaborators rather than reusing a single instance, which keeps each thread operating on its own copy of the data graph.
Database state is the trickiest case. A test that truncates a table to reset state will race with any parallel test that reads the same table. Australian teams operating under the Privacy Act and the Consumer Data Right obligations already maintain dedicated test schemas, the right place to start. Wrapping each test in a transaction that rolls back at the end, or using TestContainers to spin per-thread databases, gives a clean isolation model. The exact technique matters less than the discipline of writing each test as if it could be the only one running.
Logging and reporting also need attention. Stdout from thread A and thread B interleaves when buffers flush, which makes debugging harder than necessary. Logback and Log4j2 can be configured with thread-bound MDC keys so each line is traceable. TestNG's HTML reporter threads output reasonably well, but custom listeners aggregating results should avoid shared non-thread-safe collections without synchronisation.
Integration with Maven, Gradle, and CI pipelines
The Maven Surefire plugin needs a small nudge to honour the TestNG suite file. Adding <suiteXmlFiles> pointing at testng.xml, plus the right TestNG dependency, is usually enough. Surefire itself does not thread tests; it leaves the concurrency to TestNG, which means forked JVM behaviour can multiply thread counts in unexpected ways. Setting forkCount to match the available cores and reuseForks to true prevents fresh JVMs from being spawned mid-suite, keeping memory usage predictable on hosted runners.
Gradle users get a slightly different model with the Test task. Configuring useTestNG() and maxParallelForks controls how many test JVMs run, while TestNG inside each fork controls its own thread pool. Stacking the two layers is powerful: outer forks for isolation, inner threads for speed. Teams that target East-coast deployments often align the CI matrix with AEST, running the heavy suite overnight and the smoke tier during business hours so feedback lands while the team is still at their desks.
CI runners carry their own constraints. Hosted services such as GitHub Actions, Bitbucket Pipelines, and GitLab CI expose a fixed number of vCPUs per build, so thread-count should be tuned against that ceiling rather than against developer laptops. A Sydney-based team on a 4 vCPU self-hosted runner rarely benefits from thread-count=16, because context switching starts to dominate and the average per-test cost climbs. Measuring once and reusing the same number across pipelines keeps the configuration honest.
Common pitfalls and how to avoid them
The first pitfall is ordering. Any code that relies on a test method running before another one, with no explicit dependency declared through dependsOnMethods, will fail randomly in parallel mode. The cure is either to express the dependency properly or to redesign the tests so they are independent. Local dev runs on a single thread will mask the problem and give a false sense of security until CI fires up multiple workers.
The second pitfall is resource leakage. File handles, JDBC connections, and HTTP clients that are not closed inside the test method stay open between invocations and exhaust the pool once several threads reuse them. Wrapping collaborators in try-with-resources blocks, or letting an @AfterMethod handle teardown, removes the issue. The same principle applies to ThreadLocal cleanup; missing a remove() call leaks state across tests in the same thread and turns into ghost failures that disappear on the next run.
A third pitfall is misleading timings. A single slow test in the middle of a parallel run can hide the win because it dictates the total runtime. Profiling the suite with TestNG's native reporter reveals the long pole. Sometimes the fastest mode is classes rather than methods, simply because classes mode reduces contention around shared fixtures. Treat the suite as a living artefact: record timings, tune thread-count, revisit the configuration after major refactors.
For teams that have not yet run their suite in parallel, the simplest first move is to copy testng.xml, set parallel="methods" thread-count="4", rerun locally, and time the difference. Once the timing story is clear, the next concrete step is to wire the file into Maven Surefire and let CI produce a one-week parallel run alongside the sequential baseline so the team can compare flakiness and runtime head-to-head before making parallel the default.