Java 21, released in September 2023, introduced several powerful features that can significantly enhance your testing capabilities with TestNG. In this chapter, we'll explore how to leverage these Java 21 features to write more concise, readable, and powerful tests.
Virtual Threads
Virtual threads, introduced as a preview feature in Java 19 and finalized in Java 21, provide a lightweight alternative to platform threads. They're particularly useful for testing concurrent code and simulating high-concurrency scenarios.
Testing with Virtual Threads
Let's see how to use virtual threads in TestNG tests:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
public class VirtualThreadsTest {
@Test
public void testVirtualThreads() throws Exception {
int threadCount = 10_000;
AtomicInteger counter = new AtomicInteger(0);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
// Launch 10,000 virtual threads
for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
// Simulate some work
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
counter.incrementAndGet();
return null;
}));
}
// Wait for all tasks to complete
for (Future<?> future : futures) {
future.get();
}
}
Assert.assertEquals(counter.get(), threadCount,
"All virtual threads should have executed");
}
}
Testing Concurrent Operations
Virtual threads make it easier to test concurrent operations:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
public class ConcurrentOperationsTest {
private final ConcurrentHashMap<Integer, String> map = new ConcurrentHashMap<>();
@Test
public void testConcurrentMapOperations() throws Exception {
int operationCount = 1000;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
// Launch threads for put operations
for (int i = 0; i < operationCount; i++) {
final int key = i;
futures.add(executor.submit(() -> {
map.put(key, "Value-" + key);
return null;
}));
}
// Launch threads for get operations
for (int i = 0; i < operationCount; i++) {
final int key = ThreadLocalRandom.current().nextInt(operationCount);
futures.add(executor.submit(() -> {
// Get might return null if the key hasn't been added yet
map.get(key);
return null;
}));
}
// Wait for all operations to complete
for (Future<?> future : futures) {
future.get();
}
}
Assert.assertEquals(map.size(), operationCount,
"Map should contain all inserted values");
for (int i = 0; i < operationCount; i++) {
Assert.assertEquals(map.get(i), "Value-" + i,
"Map should contain correct value for key " + i);
}
}
}
Testing Asynchronous Operations
Virtual threads are perfect for testing asynchronous operations:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
public class AsyncOperationsTest {
// Simulates an asynchronous service
static class AsyncService {
public CompletableFuture<Integer> calculateAsync(int input) {
return CompletableFuture.supplyAsync(() -> {
// Simulate variable processing time
try {
Thread.sleep(ThreadLocalRandom.current().nextInt(10, 100));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return input * 2;
});
}
}
@Test
public void testAsyncOperations() {
AsyncService service = new AsyncService();
int operationCount = 100;
// Create a list of CompletableFuture
List<CompletableFuture<Integer>> futures = new ArrayList<>();
for (int i = 1; i <= operationCount; i++) {
futures.add(service.calculateAsync(i));
}
// Combine all futures into a single CompletableFuture
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// Wait for all futures to complete
allFutures.join();
// Verify results
for (int i = 0; i < operationCount; i++) {
int input = i + 1;
int expected = input * 2;
int actual = futures.get(i).join();
Assert.assertEquals(actual, expected,
"Result for input " + input + " should be " + expected);
}
}
@Test
public void testAsyncOperationsWithVirtualThreads() throws Exception {
AsyncService service = new AsyncService();
int operationCount = 100;
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<Integer>> futures = new ArrayList<>();
// Submit tasks to the virtual thread executor
for (int i = 1; i <= operationCount; i++) {
final int input = i;
CompletableFuture<Integer> future = new CompletableFuture<>();
executor.submit(() -> {
try {
int result = service.calculateAsync(input).get(1, TimeUnit.SECONDS);
future.complete(result);
} catch (Exception e) {
future.completeExceptionally(e);
}
return null;
});
futures.add(future);
}
// Verify results as they complete
for (int i = 0; i < operationCount; i++) {
int input = i + 1;
int expected = input * 2;
int actual = futures.get(i).join();
Assert.assertEquals(actual, expected,
"Result for input " + input + " should be " + expected);
}
}
}
}
Performance Testing with Virtual Threads
Virtual threads can be used to simulate high load for performance testing:
import org.testng.Assert;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicLong;
public class PerformanceTest {
// System under test - a simple cache
static class SimpleCache {
private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
public String get(String key) {
return cache.get(key);
}
public void put(String key, String value) {
cache.put(key, value);
}
public int size() {
return cache.size();
}
}
@Test
public void testCachePerformance() throws Exception {
SimpleCache cache = new SimpleCache();
int threadCount = 1000;
int operationsPerThread = 100;
AtomicLong totalTime = new AtomicLong(0);
// Pre-populate cache with some values
for (int i = 0; i < 1000; i++) {
cache.put("key-" + i, "value-" + i);
}
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
// Launch threads for mixed operations
for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
long startTime = System.nanoTime();
for (int j = 0; j < operationsPerThread; j++) {
int operation = ThreadLocalRandom.current().nextInt(3);
int key = ThreadLocalRandom.current().nextInt(2000); // Some hits, some misses
switch (operation) {
case 0: // Get
cache.get("key-" + key);
break;
case 1: // Put
cache.put("key-" + key, "value-" + key);
break;
case 2: // Get then put
String value = cache.get("key-" + key);
if (value == null) {
cache.put("key-" + key, "value-" + key);
}
break;
}
}
long endTime = System.nanoTime();
totalTime.addAndGet(endTime - startTime);
return null;
}));
}
// Wait for all operations to complete
for (Future<?> future : futures) {
future.get();
}
}
long averageTimePerOperation = totalTime.get() / (threadCount * operationsPerThread);
System.out.println("Average time per operation: " + averageTimePerOperation + " ns");
// This is not a strict assertion, but a sanity check
Assert.assertTrue(averageTimePerOperation < 1_000_000,
"Average operation time should be less than 1ms");
}
}
Records
Records, introduced in Java 16 and enhanced in Java 21, provide a concise way to declare classes that are transparent holders for shallowly immutable data. They're particularly useful for test data and expected results.
Using Records for Test Data
Records can simplify test data creation:
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
import java.time.LocalDate;
public class RecordsForTestDataTest {
// Define a record for test data
record UserData(int id, String name, String email, LocalDate birthDate) {}
// Define a record for expected results
record ValidationResult(boolean valid, String errorMessage) {}
// System under test
static class UserValidator {
public ValidationResult validate(UserData user) {
if (user.name() == null || user.name().isBlank()) {
return new ValidationResult(false, "Name is required");
}
if (user.email() == null || !user.email().contains("@")) {
return new ValidationResult(false, "Invalid email");
}
if (user.birthDate() == null || user.birthDate().isAfter(LocalDate.now())) {
return new ValidationResult(false, "Invalid birth date");
}
return new ValidationResult(true, null);
}
}
@DataProvider(name = "validUsers")
public Object[][] createValidUsers() {
return new Object[][] {
{new UserData(1, "John Doe", "john@example.com", LocalDate.of(1990, 1, 1))},
{new UserData(2, "Jane Smith", "jane@example.com", LocalDate.of(1985, 5, 5))},
{new UserData(3, "Bob Johnson", "bob@example.com", LocalDate.of(2000, 10, 10))}
};
}
@DataProvider(name = "invalidUsers")
public Object[][] createInvalidUsers() {
return new Object[][] {
{new UserData(4, "", "invalid@example.com", LocalDate.of(1990, 1, 1)),
"Name is required"},
{new UserData(5, "Invalid Email", "invalid", LocalDate.of(1985, 5, 5)),
"Invalid email"},
{new UserData(6, "Future Birth Date", "future@example.com", LocalDate.now().plusDays(1)),
"Invalid birth date"}
};
}
@Test(dataProvider = "validUsers")
public void testValidUsers(UserData user) {
UserValidator validator = new UserValidator();
ValidationResult result = validator.validate(user);
Assert.assertTrue(result.valid(), "User should be valid: " + user);
}
@Test(dataProvider = "invalidUsers")
public void testInvalidUsers(UserData user, String expectedError) {
UserValidator validator = new UserValidator();
ValidationResult result = validator.validate(user);
Assert.assertFalse(result.valid(), "User should be invalid: " + user);
Assert.assertEquals(result.errorMessage(), expectedError,
"Error message should match for: " + user);
}
}