Timeout Assertions and Parallel Execution

8 min read

Two capabilities that TestNG made easy — test timeouts and parallel execution — are fully supported in JUnit 5 but configured differently. Timeouts live on annotations or in assertions; parallelism lives in a properties file. The key concept for both is thread safety: once tests run concurrently, shared mutable state becomes a bug waiting to happen. This lesson covers the mechanics and the guard you need: @ResourceLock.

@Timeout — enforcing a time budget

@Timeout on a test method declares a maximum duration. If the test exceeds it, JUnit fails the test with a TimeoutException:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import java.util.concurrent.TimeUnit;
 
class ApiTimeoutTest {
 
    @Test
    @Timeout(value = 5, unit = TimeUnit.SECONDS)
    void shouldCompleteWithin5Seconds() {
        Response response = apiClient.getUsers();
        assertEquals(200, response.getStatusCode());
    }
}

The default unit is TimeUnit.SECONDS, so @Timeout(5) is equivalent to @Timeout(value = 5, unit = TimeUnit.SECONDS).

@Timeout on the class applies to every test method within it:

@Timeout(10)
class ApiTests {
    @Test void getUsers() { ... }      // max 10s
    @Test void createUser() { ... }    // max 10s
 
    @Test
    @Timeout(30)                       // overrides class-level for this method
    void slowMigrationTest() { ... }
}

@Timeout runs the test in a separate thread and fails preemptively — it does not wait for the test to finish. This is the same behaviour as assertTimeoutPreemptively from Chapter 2, but applied declaratively.

Set a global default in junit-platform.properties:

junit.jupiter.execution.timeout.default=10s
junit.jupiter.execution.timeout.testmethod.default=5s

assertTimeout vs @Timeout

assertTimeoutassertTimeoutPreemptively@Timeout
WhereInside test methodInside test methodAnnotation
Preemptive stopNo — waits for codeYes — kills threadYes — kills thread
Returns valueYes — returns executable resultYesNo
ScopeOne code blockOne code blockWhole test method

Use assertTimeout when you need the return value of the timed code block. Use assertTimeoutPreemptively for a hard deadline on a code block. Use @Timeout as a safety net on the whole method — especially useful as a class-level default for slow test suites.

Enabling parallel execution

By default JUnit 5 runs tests sequentially. Parallel execution is opt-in via src/test/resources/junit-platform.properties:

# Enable parallel execution
junit.jupiter.execution.parallel.enabled=true
 
# Default mode for test methods within a class
junit.jupiter.execution.parallel.mode.default=concurrent
 
# Default mode for top-level test classes
junit.jupiter.execution.parallel.mode.classes.default=concurrent
 
# Thread pool size strategy
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4

With these settings, JUnit runs up to 4 test methods concurrently across all classes. A 20-test suite that takes 60 seconds sequentially can run in ~15 seconds with four threads.

Per-class and per-method execution mode

Override the global mode on individual classes or methods:

import org.junit.jupiter.api.parallel.*;
 
// Force concurrent execution of methods in this class
@Execution(ExecutionMode.CONCURRENT)
class ParallelApiTest {
    @Test void getUsers() { ... }
    @Test void createUser() { ... }
    @Test void deleteUser() { ... }
    // All three can run simultaneously
}
 
// Force sequential — useful for stateful workflow tests
@Execution(ExecutionMode.SAME_THREAD)
class OrderWorkflowTest {
    @Test @Order(1) void createOrder() { ... }
    @Test @Order(2) void payOrder() { ... }
    @Test @Order(3) void shipOrder() { ... }
}

Thread safety — the critical concern

When tests run in parallel, instance fields are shared if you're using @TestInstance(PER_CLASS). Even without PER_CLASS, static fields in test classes or extensions are shared across all concurrent tests. Use ThreadLocal for resources that must be per-thread:

// Thread-safe WebDriver management in parallel Selenium tests
public class ThreadLocalWebDriverExtension
        implements BeforeEachCallback, AfterEachCallback, ParameterResolver {
 
    private static final ThreadLocal<WebDriver> DRIVER = new ThreadLocal<>();
 
    @Override
    public void beforeEach(ExtensionContext context) {
        DRIVER.set(new ChromeDriver());
    }
 
    @Override
    public void afterEach(ExtensionContext context) {
        WebDriver driver = DRIVER.get();
        if (driver != null) {
            driver.quit();
            DRIVER.remove();
        }
    }
 
    @Override
    public boolean supportsParameter(ParameterResolutionContext param, ExtensionContext ext) {
        return param.getParameter().getType() == WebDriver.class;
    }
 
    @Override
    public Object resolveParameter(ParameterResolutionContext param, ExtensionContext ext) {
        return DRIVER.get();
    }
}

Each thread gets its own WebDriver. The remove() call in afterEach prevents memory leaks — without it, the ThreadLocal entry persists for the thread's lifetime in the pool.

@ResourceLock — preventing concurrent access to shared resources

When two tests must not run simultaneously — because they both write to the same database table, or both modify a shared file — annotate them with @ResourceLock:

import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
 
@Test
@ResourceLock("orders-table")
void testCreateOrder() {
    // Only one test with @ResourceLock("orders-table") runs at a time
    db.insert("orders", order);
    assertEquals(1, db.count("orders"));
}
 
@Test
@ResourceLock("orders-table")
void testDeleteOrder() {
    // Waits for testCreateOrder to finish before starting
    db.delete("orders", orderId);
    assertEquals(0, db.count("orders"));
}
 
@Test
void testGetUsers() {
    // No @ResourceLock — runs concurrently with other unlocked tests
    assertFalse(db.findAll("users").isEmpty());
}

Two tests sharing the same lock name are serialised. Tests without the lock (or with a different lock name) still run concurrently. The ResourceAccessMode.READ_WRITE (default) gives exclusive access; ResourceAccessMode.READ allows concurrent readers:

@ResourceLock(value = "config", mode = ResourceAccessMode.READ)
void readConfig() { ... }          // concurrent with other READ locks
 
@ResourceLock(value = "config", mode = ResourceAccessMode.READ_WRITE)
void updateConfig() { ... }        // exclusive

Sequential vs parallel execution

Sequential vs parallel test execution

Sequential (default)

  • Tests run one at a time

    Simple, predictable, easy to debug — no thread interference.

  • Safe for shared state

    Instance fields, static fields, and DB state are never accessed concurrently.

  • Slow for large suites

    20 tests × 5 seconds each = 100 seconds. Parallelism can cut this to 25s with 4 threads.

  • No @ResourceLock needed

    Serialised by default — locking primitives have no effect.

Parallel (opt-in)

  • Tests run concurrently

    Multiple threads execute test methods simultaneously — up to parallelism count.

  • Dramatic speed improvement

    API test suites with network I/O benefit most — threads wait on I/O, not CPU.

  • Requires thread-safe code

    Shared static fields, ThreadLocal resources, and @ResourceLock for DB contention.

  • Configure in junit-platform.properties

    One file enables it project-wide; @Execution overrides per class or method.

⚠️ Common mistakes

  • Enabling parallel execution without auditing for shared static state. The most common parallel failure is a static field written by one test and read by another concurrently. Before enabling parallelism, grep for static fields in your test classes and verify they are either immutable or replaced with ThreadLocal.
  • @ResourceLock with different lock name strings that look the same. @ResourceLock("Database") and @ResourceLock("database") are different locks. Tests using each will run concurrently — they do not share a lock. Centralise lock names as constants in a shared class to avoid silent mismatches.
  • @Timeout on @BeforeAll setup that legitimately takes long. If your database migration takes 30 seconds on first run, a 10-second class-level timeout fails the @BeforeAll. Either exempt setup methods from the timeout or set the class-level timeout generously and rely on method-level timeouts for the individual tests.

🎯 Practice task

Enable parallel execution and guard shared resources. 25–35 minutes.

  1. Create src/test/resources/junit-platform.properties with junit.jupiter.execution.parallel.enabled=true, mode.default=concurrent, and parallelism=4.
  2. Write a test class with four @Test methods, each with Thread.sleep(1000) inside. Run with parallelism disabled (comment out the properties), record the time. Re-enable parallelism, run again. Confirm it runs in roughly a quarter of the time.
  3. Introduce a race condition. Add a static int counter = 0 field. Each test does counter++; assertEquals(expected, counter). Run in parallel and observe the test failures caused by the race. Then fix it: use AtomicInteger and adjust assertions.
  4. @ResourceLock. Create two tests that both read and write to a shared List. Add @ResourceLock("shared-list") to both. Confirm they stop failing when parallelism is enabled.
  5. @Timeout. Add @Timeout(2) to the class. Write one test with Thread.sleep(3000) — confirm it fails with a timeout. Write another with Thread.sleep(1000) — confirm it passes.
  6. Stretch — ThreadLocal WebDriver. Implement ThreadLocalWebDriverExtension from the lesson. Write three parallel Selenium tests, each receiving a WebDriver parameter. Add System.out.println(Thread.currentThread().getName()) inside each test — confirm three different thread names appear, proving concurrent execution.

This completes Chapter 4. Chapter 5 brings it all together with Selenium WebDriver, Maven Surefire configuration, and test report generation.

// tip to track lessons you complete and pick up where you left off across devices.