Testing Java 21 Virtual Threads and Project Loom with JUnit 5

Testing Java 21 Virtual Threads and Project Loom with JUnit 5

Java 21 made virtual threads a permanent feature (JEP 444), fundamentally changing how Java applications handle concurrency. Virtual threads are cheap enough to create one per task — no thread pools required. But testing concurrent code has always been tricky, and virtual threads introduce new patterns and new failure modes that require deliberate testing strategies.

What Makes Virtual Thread Testing Unique

Virtual threads behave like platform threads from the JUnit perspective — you can use standard assertions. The challenges arise from:

  1. Pinning — Virtual threads can be pinned to carrier threads (usually inside synchronized blocks or native method calls), causing the thread to block instead of yielding. Tests must verify your code doesn't inadvertently pin.
  2. Structured concurrency (JEP 453 in Java 21, finalized in Java 23) — new APIs for managing task scopes that need deliberate testing.
  3. Volume — Virtual threads handle thousands of concurrent tasks. Tests that work for 10 platform threads may reveal race conditions at 10,000 virtual threads.
  4. I/O behavior — Virtual threads unmount from the carrier thread during I/O. Tests using mocked I/O may not exercise the actual unmounting behavior.

Basic Virtual Thread Tests

Start by verifying your code runs correctly on virtual threads at all:

class VirtualThreadBasicsTest {

    @Test
    void testTaskRunsOnVirtualThread() throws Exception {
        Thread.Builder builder = Thread.ofVirtual().name("test-thread");
        
        AtomicReference<String> capturedThreadName = new AtomicReference<>();
        AtomicBoolean isVirtual = new AtomicBoolean();
        
        Thread thread = builder.start(() -> {
            capturedThreadName.set(Thread.currentThread().getName());
            isVirtual.set(Thread.currentThread().isVirtual());
        });
        
        thread.join();
        
        assertThat(isVirtual.get()).isTrue();
        assertThat(capturedThreadName.get()).isEqualTo("test-thread");
    }

    @Test
    void testVirtualThreadExecutorService() throws Exception {
        List<String> results = new ArrayList<>();
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = IntStream.range(0, 100)
                .mapToObj(i -> executor.submit(() -> "task-" + i))
                .toList();
            
            for (Future<String> future : futures) {
                results.add(future.get(5, TimeUnit.SECONDS));
            }
        }
        
        assertThat(results).hasSize(100);
        assertThat(results).containsExactlyInAnyOrderElementsOf(
            IntStream.range(0, 100).mapToObj(i -> "task-" + i).toList()
        );
    }
}

Testing Structured Concurrency

Java 21's StructuredTaskScope provides a framework for managing groups of subtasks:

class StructuredConcurrencyTest {

    // Service that fetches user data and orders in parallel
    record UserSummary(User user, List<Order> orders) {}
    
    UserSummary fetchUserSummary(String userId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<User> userTask = scope.fork(() -> userService.findById(userId));
            Subtask<List<Order>> ordersTask = scope.fork(() -> orderService.findByUser(userId));
            
            scope.join().throwIfFailed();
            
            return new UserSummary(userTask.get(), ordersTask.get());
        }
    }

    @Test
    void testParallelFetch() throws Exception {
        // Use in-memory implementations to avoid I/O
        userService = new InMemoryUserService(Map.of("u-1", new User("u-1", "Alice")));
        orderService = new InMemoryOrderService(Map.of("u-1", List.of(new Order("o-1"))));
        
        UserSummary summary = fetchUserSummary("u-1");
        
        assertThat(summary.user().getName()).isEqualTo("Alice");
        assertThat(summary.orders()).hasSize(1);
    }

    @Test
    void testShutdownOnFirstFailure() {
        userService = new FailingUserService(); // Always throws
        orderService = new SlowOrderService(1000); // Takes 1 second
        
        assertThatThrownBy(() -> fetchUserSummary("u-1"))
            .isInstanceOf(ExecutionException.class)
            .hasCauseInstanceOf(ServiceException.class);
    }

    @Test
    void testShutdownOnSuccess() throws Exception {
        // ShutdownOnSuccess cancels remaining tasks when first completes
        try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
            scope.fork(() -> { Thread.sleep(100); return "slow"; });
            scope.fork(() -> "fast");
            
            scope.join();
            
            assertThat(scope.result()).isEqualTo("fast");
        }
    }
}

Testing for Thread Pinning

Thread pinning prevents virtual thread suspension and degrades throughput. Detect it with JVM flags:

class PinningDetectionTest {

    @Test
    void testNoSynchronizedBlocksInHotPath() throws Exception {
        // Enable pinning diagnostics:
        // Add JVM arg: -Djdk.tracePinnedThreads=full
        
        CountDownLatch latch = new CountDownLatch(100);
        long startTime = System.currentTimeMillis();
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                executor.submit(() -> {
                    try {
                        performOperation(); // Your method under test
                        latch.countDown();
                    } catch (Exception e) {
                        latch.countDown();
                    }
                });
            }
        }
        
        boolean completed = latch.await(5, TimeUnit.SECONDS);
        long elapsed = System.currentTimeMillis() - startTime;
        
        assertThat(completed).isTrue();
        // If pinning occurs with 100 tasks and default carrier threads (~8),
        // completion should still be fast — pinning would cause ~12x slowdown
        assertThat(elapsed).isLessThan(2000); // Should complete in under 2s
    }
}

For deterministic pinning detection, configure the JVM in your maven-surefire-plugin:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>-Djdk.tracePinnedThreads=short</argLine>
    </configuration>
</plugin>

Testing Concurrent State with Race Conditions

Virtual threads make it easy to run thousands of concurrent operations. Use this to surface race conditions in your code:

class ConcurrentCounterTest {

    @Test
    void testThreadSafeCounter() throws Exception {
        AtomicLong counter = new AtomicLong(0);
        int taskCount = 10_000;
        CountDownLatch done = new CountDownLatch(taskCount);
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                executor.submit(() -> {
                    counter.incrementAndGet();
                    done.countDown();
                });
            }
        }
        
        done.await(10, TimeUnit.SECONDS);
        assertThat(counter.get()).isEqualTo(taskCount);
    }

    @Test
    void testUnsafeCounterFails() throws Exception {
        // This demonstrates the problem with non-thread-safe code
        long[] unsafeCounter = {0};
        int taskCount = 10_000;
        CountDownLatch done = new CountDownLatch(taskCount);
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                executor.submit(() -> {
                    unsafeCounter[0]++; // Race condition!
                    done.countDown();
                });
            }
        }
        
        done.await(10, TimeUnit.SECONDS);
        // This assertion demonstrates that the counter is wrong
        // In a real test, you'd assert the safe version works correctly
        assertThat(unsafeCounter[0]).isLessThanOrEqualTo(taskCount);
    }
}

Testing HTTP Services with Virtual Threads

Spring Boot 3.2+ enables virtual threads with a single property. Test that your service handles concurrent requests correctly:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = "spring.threads.virtual.enabled=true")
class VirtualThreadHttpTest {

    @Autowired
    TestRestTemplate restTemplate;

    @LocalServerPort
    int port;

    @Test
    void testConcurrentRequests() throws Exception {
        int concurrentRequests = 200;
        CountDownLatch latch = new CountDownLatch(concurrentRequests);
        List<Integer> statusCodes = new CopyOnWriteArrayList<>();
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < concurrentRequests; i++) {
                executor.submit(() -> {
                    try {
                        ResponseEntity<String> response = restTemplate
                            .getForEntity("/api/data", String.class);
                        statusCodes.add(response.getStatusCode().value());
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }
        
        latch.await(30, TimeUnit.SECONDS);
        
        assertThat(statusCodes).hasSize(concurrentRequests);
        assertThat(statusCodes).allMatch(code -> code == 200);
    }
}

Testing ScopedValue (Java 21+)

ScopedValue is the virtual-thread-aware replacement for ThreadLocal:

class ScopedValueTest {

    private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

    @Test
    void testScopedValueBinding() throws Exception {
        String requestId = "req-12345";
        
        ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
            assertThat(REQUEST_ID.get()).isEqualTo(requestId);
            
            // ScopedValue is inherited by forked virtual threads
            try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
                Subtask<String> task = scope.fork(() -> REQUEST_ID.get());
                scope.join();
                assertThat(task.get()).isEqualTo(requestId);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });
        
        // Outside the scope, ScopedValue is not bound
        assertThat(REQUEST_ID.isBound()).isFalse();
    }

    @Test
    void testScopedValueNotLeakedAcrossRequests() throws Exception {
        List<String> capturedIds = new CopyOnWriteArrayList<>();
        
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 5; i++) {
                final int requestNum = i;
                executor.submit(() -> {
                    ScopedValue.runWhere(REQUEST_ID, "req-" + requestNum, () -> {
                        capturedIds.add(REQUEST_ID.get());
                    });
                });
            }
        }
        
        // Each execution had its own scoped value
        assertThat(capturedIds).hasSize(5);
        assertThat(capturedIds).containsExactlyInAnyOrder(
            "req-0", "req-1", "req-2", "req-3", "req-4"
        );
    }
}

Timeout Testing with Virtual Threads

Virtual threads make timeout testing more realistic — you can actually spin up thousands of "hanging" threads without exhausting OS resources:

class TimeoutServiceTest {

    @Test
    void testRequestTimeout() throws Exception {
        TimeoutService service = new TimeoutService(Duration.ofMillis(500));
        
        // Simulate slow downstream with virtual thread
        Supplier<String> slowOperation = () -> {
            try {
                Thread.sleep(2000); // Much longer than timeout
                return "too late";
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return "interrupted";
            }
        };
        
        assertThatThrownBy(() -> service.executeWithTimeout(slowOperation))
            .isInstanceOf(TimeoutException.class);
    }
}

Verifying Thread Type in Service Code

When writing libraries or services that should work on virtual threads, add assertions to your tests:

class VirtualThreadServiceTest {

    @Test
    void testServiceRunsOnVirtualThreads() throws Exception {
        AtomicBoolean executedOnVirtualThread = new AtomicBoolean(false);
        
        MyService service = new MyService() {
            @Override
            protected void onExecution() {
                executedOnVirtualThread.set(Thread.currentThread().isVirtual());
            }
        };
        
        Thread.ofVirtual().start(() -> service.execute()).join();
        
        assertThat(executedOnVirtualThread.get()).isTrue();
    }
}

Running Virtual Thread Tests in CI

Add JVM args to your Surefire configuration for better diagnostics:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>
            --enable-preview
            -Djdk.tracePinnedThreads=short
            -Djdk.virtualThreadScheduler.maxPoolSize=8
        </argLine>
    </configuration>
</plugin>

Setting maxPoolSize=8 makes tests deterministic — it controls how many carrier threads are available, making pinning effects reproducible.

Beyond Unit Tests: Monitoring Virtual Thread Applications

Virtual thread unit tests verify correctness. Verifying that your production service actually handles the expected concurrent load — that database connections aren't exhausted, that response times remain acceptable at scale — requires live behavioral testing.

HelpMeTest provides continuous monitoring for Java services. Write behavioral test scenarios in plain English that run against your deployed service, catching regressions between releases before users do. Combine JUnit 5 unit tests with HelpMeTest end-to-end scenarios for comprehensive coverage at every layer.

Summary

  • Virtual threads behave like platform threads in JUnit — standard assertions work
  • Test for pinning with volume tests and -Djdk.tracePinnedThreads=short
  • StructuredTaskScope needs tests for both success paths and failure propagation
  • ScopedValue tests should verify isolation across concurrent executions
  • Set jdk.virtualThreadScheduler.maxPoolSize in tests for reproducible concurrency behavior
  • High-volume tests (10,000+ tasks) are practical with virtual threads — use them to surface race conditions

Read more