A test suite that takes 20 minutes to run does not get run. Developers skip it, work around it, or disable it entirely. Speed is not a nice-to-have in automated testing β it is fundamental to whether your tests actually provide value. This guide diagnoses every common cause of slow JUnit 6 tests and provides concrete, measurable optimisations with real performance numbers.
Measuring First: Identify Your Bottlenecks
Before optimising anything, measure. You need to know which tests are slowest before you can improve them.
# Maven: run tests and capture timing in the Surefire report
mvn test
# Check: target/surefire-reports/*.xml β each test has 'time' attribute
# Find the slowest tests across all XML reports
grep -h 'time=' target/surefire-reports/*.xml |
grep -oP 'time="[0-9.]+"' |
sort -t'"' -k2 -rn |
head -20
# Gradle: generate a test report with timing
./gradlew test
# Check: build/reports/tests/test/index.html
# Sort by duration in the 'Classes' tab
Problem 1: Spring Context Starts on Every Test Class
Starting a Spring ApplicationContext takes 3β10 seconds. If 20 test classes each trigger a fresh context, that is 60β200 seconds of pure startup overhead before a single assertion runs.
// SLOW: Different @MockBean in each class = unique context = fresh startup each time
@SpringBootTest
class OrderControllerTest {
@MockBean PaymentGateway gateway; // unique context config A
}
@SpringBootTest
class InvoiceControllerTest {
@MockBean TaxCalculator taxCalc; // unique context config B = ANOTHER startup
}
// FAST: Shared context configuration = Spring caches and reuses it
// Define all @MockBeans in ONE base class β all subclasses share the same context
@SpringBootTest
@MockBeanAnnotationsFromClass // or simply put all @MockBeans here
public abstract class BaseIntegrationTest {
// Declare ALL mocks used by ANY integration test here
@MockBean protected PaymentGateway paymentGateway;
@MockBean protected TaxCalculator taxCalculator;
@MockBean protected EmailService emailService;
// Spring starts ONE context for all subclasses = startup cost paid ONCE
}
class OrderControllerTest extends BaseIntegrationTest { /* tests */ }
class InvoiceControllerTest extends BaseIntegrationTest { /* tests */ }
// Context reused β 3-10 second startup paid ONCE instead of per class
Problem 2: Using @SpringBootTest Where @WebMvcTest Would Do
// SLOW: Full context for a controller test that only needs the web layer
@SpringBootTest // loads EVERYTHING: JPA, security, services, caches, Kafka...
class ProductControllerTest {
// Test only calls GET /api/products and checks JSON output
// Does NOT need JPA, Kafka, or cache layers
}
// FAST: Slice test loads only what is needed
@WebMvcTest(ProductController.class) // loads: controller, security, JSON converters
class ProductControllerTest {
@MockBean private ProductService productService; // mock the service
// Startup: ~300ms vs 5s for full @SpringBootTest
// Net saving: 4.7 seconds per class
}
Problem 3: Tests Are Not Truly Unit Tests
// SLOW: Using @SpringBootTest for service logic that has no Spring dependency
@SpringBootTest // 5 second startup for pure calculation logic
class TaxCalculatorTest {
@Autowired TaxCalculator taxCalculator; // could just be: new TaxCalculator()
@Test
void vatAppliedAtTwentyPercent() {
assertEquals(120.00, taxCalculator.withVat(100.00), 0.001);
}
}
// FAST: Pure unit test β no Spring, no @Autowired
class TaxCalculatorTest {
private final TaxCalculator taxCalculator = new TaxCalculator();
@Test
void vatAppliedAtTwentyPercent() {
assertEquals(120.00, taxCalculator.withVat(100.00), 0.001);
}
// Execution: <5ms vs 5000ms. 1000x faster.
}
Problem 4: Testcontainers Starting Per Test Class
// SLOW: Non-static @Container restarts Docker container for every test
@Testcontainers
class OrderRepositoryTest {
// non-static = new container for each test METHOD = 3-5s per test
@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
}
// FAST: Static @Container shared across all tests in the class
@Testcontainers
class OrderRepositoryTest {
// static = one container for all tests in this class = 3-5s TOTAL
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
}
// FASTEST: Singleton container shared across ALL test classes
public abstract class AbstractPostgresTest {
static final PostgreSQLContainer<?> POSTGRES;
static {
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
POSTGRES.start(); // start once for the entire JVM session
}
}
Problem 5: Tests Run Sequentially When They Could Be Parallel
# Add to src/test/resources/junit-platform.properties
# Enable class-level parallelism (safest, biggest win)
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.classes.default=concurrent
junit.jupiter.execution.parallel.mode.default=same_thread
junit.jupiter.execution.parallel.config.strategy=dynamic
junit.jupiter.execution.parallel.config.dynamic.factor=1.5
# Typical result on an 8-core machine:
# Before parallel: 180 seconds
# After parallel: 48 seconds (3.75x speedup)
Performance Optimisation Summary
| Problem | Typical cost | Fix | Typical saving |
|---|---|---|---|
| Multiple Spring contexts | 5s Γ N classes | Shared base class with all @MockBeans | 60β200s total |
| @SpringBootTest for controllers | 5s vs 300ms per class | Switch to @WebMvcTest | 4.7s per class |
| Spring context in unit tests | 5s vs <5ms | Use plain new Object() | ~5s per class |
| Testcontainers per method | 3β5s per test | Static container or singleton | Minutes total |
| Sequential execution | N Γ average time | Parallel execution (class level) | 60β80% reduction |
| Heavy @BeforeEach | 100β1000ms per test | Move to @BeforeAll or test data builders | Depends on suite size |
Frequently Asked Questions (FAQs)
Q1: What is a realistic target for total test suite execution time?
Unit tests should run in under 30 seconds for any project size. The full suite (unit + integration) should run in under 5 minutes for a medium-sized project. If your full suite takes longer, it is a signal to: (1) run integration tests separately from unit tests in CI, (2) enable parallel execution, and (3) audit whether Spring contexts are being unnecessarily duplicated.
Q2: How do I identify which specific test is slowing down the build?
In Maven, parse the Surefire XML reports with the grep command shown at the top of this guide. In Gradle, open build/reports/tests/test/index.html and sort by the “Duration” column under the “Classes” tab. IntelliJ IDEAβs test runner panel also shows elapsed time per test and per class β sort by time descending to find the bottlenecks immediately.
Q3: Does increasing the JVM heap size help with test performance?
Sometimes. If the JVM is spending significant time in garbage collection during tests, increasing heap size can help. Add -Xmx2g to the Surefire configuration: <argLine>-Xmx2048m</argLine>. However, heap size rarely fixes slow tests caused by Spring context duplication or sequential execution β address those first. GC overhead becomes visible when -verbose:gc shows frequent full GCs during test runs.
Q4: Should I use @DirtiesContext in my tests?
Use @DirtiesContext only as a last resort. It forces Spring to create a new ApplicationContext for the next test class, destroying the cached context. This is sometimes necessary when a test genuinely modifies the context (e.g., alters a singleton beanβs state). But every @DirtiesContext adds a full context startup cost. Look for alternative approaches first: cleanup in @AfterEach, or restructuring the test to not modify shared state.
Q5: How do I separate slow integration tests from fast unit tests in CI?
Tag unit tests with @Tag("unit") and integration tests with @Tag("integration"). In CI, run mvn test -Dgroups=unit on every push (fast, <30s) and mvn verify -Dgroups=integration only on pull requests to main or nightly. This gives developers immediate feedback on unit test failures without waiting for the full integration suite. See Running JUnit 6 Tests in CI/CD Pipelines for the complete pipeline configuration.
See Also
- Parallel Test Execution in JUnit 6: Configuration and Pitfalls
- JUnit 6 with Testcontainers: Real Database Integration Testing
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
- Running JUnit 6 Tests in CI/CD Pipelines (GitHub Actions, Jenkins)
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Slow tests have one of five root causes: unnecessary Spring context startups, wrong test type for the scenario, Testcontainers restarting per test, sequential execution, or heavy setup in @BeforeEach. Measure first to find your bottlenecks. Fix Spring context duplication and parallel execution for the biggest wins. The goal is a unit test suite that runs in under 30 seconds and a full integration suite that completes in under 5 minutes β fast enough to run on every commit.
Next: Common JUnit Testing Mistakes and How to Avoid Them β the mistakes every Java developer makes at least once, and how to never make them again.