Back to Chapters

Chapter 14: Java 21 Features in Testing

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);
    }
}