git:20260316.91dc3c4 to git:20260324.a02e282

73 added, 406 removed. Audit A to A.

---
name: unit-test-scheduled-async
- description: Provides patterns for unit testing scheduled and async tasks using `@Scheduled` and `@Async`. Handles mocking task execution and timing. Use when validating asynchronous operations and scheduling behavior.
+ description: Provides patterns for unit testing Spring `@Scheduled` and `@Async` methods using JUnit 5, CompletableFuture, Awaitility, and Mockito. Covers mocking task execution and timing, verifying execution counts, testing cron expressions, validating retry behavior, and simulating thread pool behavior. Use when testing background tasks, cron jobs, periodic execution, scheduled tasks, or thread pool behavior.
allowed-tools: Read, Write, Bash, Glob, Grep
---
# Unit Testing `@Scheduled` and `@Async` Methods
## Overview
- This skill provides patterns for unit testing `@Scheduled` and `@Async` methods using JUnit 5. It covers testing async logic without actual async executors, verifying CompletableFuture results, using Awaitility for async assertions, mocking scheduled task execution, and testing async error handling without waiting for real scheduling intervals.
+ Patterns for unit testing Spring `@Scheduled` and `@Async` methods with JUnit 5. Test `CompletableFuture` results, use Awaitility for race conditions, mock scheduled task execution, and validate error handling — without waiting for real scheduling intervals.
## When to Use
- Use this skill when:
- - Testing `@`Scheduled method logic
- - Testing `@`Async method behavior
- - Verifying CompletableFuture results
+ - Testing `@Scheduled` method logic
+ - Testing `@Async` method behavior
+ - Verifying `CompletableFuture` results
- Testing async error handling
- - Want fast tests without actual scheduling
+ - Testing cron expression logic without waiting for actual scheduling
+ - Validating thread pool behavior and execution counts
- Testing background task logic in isolation
## Instructions
- 1. **Test async methods directly**: Call `@`Async methods directly instead of relying on Spring's async executor
- 2. **Use CompletableFuture.get()**: Wait for async operations to complete with explicit timeout
- 3. **Mock async dependencies**: Use `@`Mock for services that async methods depend on
- 4. **Use Awaitility for assertions**: Apply Awaitility.await() when testing actual async behavior
- 5. **Test scheduled methods directly**: Call `@`Scheduled methods directly instead of waiting for cron expressions
- 6. **Verify mock interactions**: Ensure dependencies were called correctly after async completion
- 7. **Test exception handling**: Verify exceptions in async methods propagate correctly
- 8. **Set appropriate timeouts**: Always use timeout with CompletableFuture.get() to avoid hanging tests
-
- ## Examples
-
- ## Setup: Async/Scheduled Testing
-
- ### Maven
- ```xml
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter</artifactId>
- </dependency>
- <dependency>
- <groupId>org.junit.jupiter</groupId>
- <artifactId>junit-jupiter</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.awaitility</groupId>
- <artifactId>awaitility</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.assertj</groupId>
- <artifactId>assertj-core</artifactId>
- <scope>test</scope>
- </dependency>
- ```
-
- ### Gradle
- ```kotlin
- dependencies {
- implementation("org.springframework.boot:spring-boot-starter")
- testImplementation("org.junit.jupiter:junit-jupiter")
- testImplementation("org.awaitility:awaitility")
- testImplementation("org.assertj:assertj-core")
- }
- ```
-
- ## Testing `@`Async Methods
-
- ### Basic Async Testing with CompletableFuture
-
- ```java
- // Service with async methods
- @Service
- public class EmailService {
-
- @Async
- public CompletableFuture<Boolean> sendEmailAsync(String to, String subject) {
- return CompletableFuture.supplyAsync(() -> {
- // Simulate email sending
- System.out.println("Sending email to " + to);
- return true;
- });
- }
-
- @Async
- public void notifyUser(String userId) {
- System.out.println("Notifying user: " + userId);
- }
- }
-
- // Unit test
- import java.util.concurrent.CompletableFuture;
- import static org.assertj.core.api.Assertions.*;
-
- class EmailServiceAsyncTest {
-
- @Test
- void shouldReturnCompletedFutureWhenSendingEmail() throws Exception {
- EmailService service = new EmailService();
-
- CompletableFuture<Boolean> result = service.sendEmailAsync("test@example.com", "Hello");
-
- Boolean success = result.get(); // Wait for completion
- assertThat(success).isTrue();
- }
-
- @Test
- void shouldCompleteWithinTimeout() {
- EmailService service = new EmailService();
-
- CompletableFuture<Boolean> result = service.sendEmailAsync("test@example.com", "Hello");
+ 1. **Call `@Async` methods directly** — bypass Spring's async proxy; the annotation is irrelevant in unit tests
+ 2. **Mock dependencies** with `@Mock` and `@InjectMocks` (Mockito)
+ 3. **Wait for completion** — use `CompletableFuture.get(timeout, unit)` or `await().atMost(...).untilAsserted(...)`
+ 4. **Call `@Scheduled` methods directly** — do not wait for cron/fixedRate; the annotation is ignored in unit tests
+ 5. **Test exception paths** — verify `ExecutionException` wrapping on `CompletableFuture.get()`
- assertThat(result)
- .isCompletedWithValue(true);
- }
- }
- ```
+ **Validation checkpoints:**
+ - After `CompletableFuture.get()`, assert the returned value before verifying mock interactions
+ - If `ExecutionException` is thrown, check `.getCause()` to identify the root exception
+ - If Awaitility times out, increase `atMost()` duration or reduce `pollInterval()` until the condition is reachable
+ - After multiple task invocations, assert execution counts before `verify()` calls
- ## Testing Async with Mocked Dependencies
+ ## Examples
- ### Async Service with Dependencies
+ Key patterns — complete examples in `references/examples.md`:
```java
+ // @Async: call directly, wait with CompletableFuture.get(timeout, unit)
@Service
- public class UserNotificationService {
-
- private final EmailService emailService;
- private final SmsService smsService;
-
- public UserNotificationService(EmailService emailService, SmsService smsService) {
- this.emailService = emailService;
- this.smsService = smsService;
- }
-
+ class EmailService {
@Async
- public CompletableFuture<String> notifyUserAsync(String userId) {
- return CompletableFuture.supplyAsync(() -> {
- emailService.send(userId);
- smsService.send(userId);
- return "Notification sent";
- });
+ public CompletableFuture<Boolean> sendEmailAsync(String to) {
+ return CompletableFuture.supplyAsync(() -> true);
}
}
-
- // Unit test
- import org.junit.jupiter.api.extension.ExtendWith;
- import org.mockito.InjectMocks;
- import org.mockito.Mock;
- import org.mockito.junit.jupiter.MockitoExtension;
-
- @ExtendWith(MockitoExtension.class)
- class UserNotificationServiceAsyncTest {
-
- @Mock
- private EmailService emailService;
-
- @Mock
- private SmsService smsService;
-
- @InjectMocks
- private UserNotificationService notificationService;
-
- @Test
- void shouldNotifyUserAsynchronously() throws Exception {
- CompletableFuture<String> result = notificationService.notifyUserAsync("user123");
-
- String message = result.get();
- assertThat(message).isEqualTo("Notification sent");
-
- verify(emailService).send("user123");
- verify(smsService).send("user123");
- }
-
- @Test
- void shouldHandleAsyncExceptionGracefully() {
- doThrow(new RuntimeException("Email service failed"))
- .when(emailService).send(any());
-
- CompletableFuture<String> result = notificationService.notifyUserAsync("user123");
-
- assertThatThrownBy(result::get)
- .isInstanceOf(ExecutionException.class)
- .hasCauseInstanceOf(RuntimeException.class);
- }
+ @Test
+ void shouldReturnCompletedFuture() throws Exception {
+ EmailService service = new EmailService();
+ Boolean result = service.sendEmailAsync("test@example.com").get(5, TimeUnit.SECONDS);
+ assertThat(result).isTrue();
}
- ```
- ## Testing `@`Scheduled Methods
-
- ### Mock Task Execution
-
- ```java
- // Scheduled task
+ // @Scheduled: call directly, mock the repository
@Component
- public class DataRefreshTask {
-
- private final DataRepository dataRepository;
-
- public DataRefreshTask(DataRepository dataRepository) {
- this.dataRepository = dataRepository;
- }
-
- @Scheduled(fixedDelay = 60000)
- public void refreshCache() {
- List<Data> data = dataRepository.findAll();
- // Update cache
- }
-
- @Scheduled(cron = "0 0 * * * *") // Every hour
- public void cleanupOldData() {
- dataRepository.deleteOldData(LocalDateTime.now().minusDays(30));
- }
- }
-
- // Unit test - test logic without actual scheduling
- import org.junit.jupiter.api.extension.ExtendWith;
- import org.mockito.InjectMocks;
- import org.mockito.Mock;
- import org.mockito.junit.jupiter.MockitoExtension;
-
- @ExtendWith(MockitoExtension.class)
- class DataRefreshTaskTest {
-
- @Mock
- private DataRepository dataRepository;
-
- @InjectMocks
- private DataRefreshTask dataRefreshTask;
-
- @Test
- void shouldRefreshCacheFromRepository() {
- List<Data> expectedData = List.of(new Data(1L, "item1"));
- when(dataRepository.findAll()).thenReturn(expectedData);
-
- dataRefreshTask.refreshCache(); // Call method directly
-
- verify(dataRepository).findAll();
- }
-
- @Test
- void shouldCleanupOldData() {
- LocalDateTime cutoffDate = LocalDateTime.now().minusDays(30);
-
- dataRefreshTask.cleanupOldData();
-
- verify(dataRepository).deleteOldData(any(LocalDateTime.class));
- }
- }
- ```
-
- ## Testing Async with Awaility
-
- ### Wait for Async Completion
-
- ```java
- import org.awaitility.Awaitility;
- import java.util.concurrent.atomic.AtomicInteger;
-
- @Service
- public class BackgroundWorker {
-
- private final AtomicInteger processedCount = new AtomicInteger(0);
-
- @Async
- public void processItems(List<String> items) {
- items.forEach(item -> {
- // Process item
- processedCount.incrementAndGet();
- });
- }
-
- public int getProcessedCount() {
- return processedCount.get();
- }
+ class DataRefreshTask {
+ @InjectMocks private DataRepository dataRepository;
+ @Scheduled(fixedDelay = 60000) public void refreshCache() { /* ... */ }
}
-
- class AwaitilityAsyncTest {
-
- @Test
- void shouldProcessAllItemsAsynchronously() {
- BackgroundWorker worker = new BackgroundWorker();
- List<String> items = List.of("item1", "item2", "item3");
-
- worker.processItems(items);
-
- // Wait for async operation to complete (up to 5 seconds)
- Awaitility.await()
- .atMost(Duration.ofSeconds(5))
- .pollInterval(Duration.ofMillis(100))
- .untilAsserted(() -> {
- assertThat(worker.getProcessedCount()).isEqualTo(3);
- });
- }
-
- @Test
- void shouldTimeoutWhenProcessingTakesTooLong() {
- BackgroundWorker worker = new BackgroundWorker();
- List<String> items = List.of("item1", "item2", "item3");
-
- worker.processItems(items);
-
- assertThatThrownBy(() ->
- Awaitility.await()
- .atMost(Duration.ofMillis(100))
- .until(() -> worker.getProcessedCount() == 10)
- ).isInstanceOf(ConditionTimeoutException.class);
- }
+ @Test
+ void shouldRefreshCache() {
+ when(dataRepository.findAll()).thenReturn(List.of(new Data(1L, "item1")));
+ dataRefreshTask.refreshCache();
+ verify(dataRepository).findAll();
}
- ```
- ## Testing Async Error Handling
-
- ### Handle Exceptions in Async Methods
-
- ```java
- @Service
- public class DataProcessingService {
-
- @Async
- public CompletableFuture<Boolean> processDataAsync(String data) {
- return CompletableFuture.supplyAsync(() -> {
- if (data == null || data.isEmpty()) {
- throw new IllegalArgumentException("Data cannot be empty");
- }
- // Process data
- return true;
- });
- }
-
- @Async
- public CompletableFuture<String> safeFetchData(String id) {
- return CompletableFuture.supplyAsync(() -> {
- try {
- return fetchData(id);
- } catch (Exception e) {
- return "Error: " + e.getMessage();
- }
- });
- }
+ // Awaitility: use for race conditions with shared mutable state
+ @Test
+ void shouldProcessAllItems() {
+ BackgroundWorker worker = new BackgroundWorker();
+ worker.processItems(List.of("item1", "item2", "item3"));
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(5))
+ .pollInterval(Duration.ofMillis(100))
+ .untilAsserted(() -> assertThat(worker.getProcessedCount()).isEqualTo(3));
}
- class AsyncErrorHandlingTest {
-
- @Test
- void shouldPropagateExceptionFromAsyncMethod() {
- DataProcessingService service = new DataProcessingService();
-
- CompletableFuture<Boolean> result = service.processDataAsync(null);
-
- assertThatThrownBy(result::get)
- .isInstanceOf(ExecutionException.class)
- .hasCauseInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("Data cannot be empty");
- }
-
- @Test
- void shouldHandleExceptionGracefullyWithFallback() throws Exception {
- DataProcessingService service = new DataProcessingService();
-
- CompletableFuture<String> result = service.safeFetchData("invalid");
-
- String message = result.get();
- assertThat(message).startsWith("Error:");
- }
+ // Mocked dependencies with exception handling
+ @Test
+ void shouldHandleAsyncExceptionGracefully() {
+ doThrow(new RuntimeException("Email failed")).when(emailService).send(any());
+ CompletableFuture<String> result = service.notifyUserAsync("user123");
+ assertThatThrownBy(result::get)
+ .isInstanceOf(ExecutionException.class)
+ .hasCauseInstanceOf(RuntimeException.class);
}
```
- ## Testing Scheduled Task Timing
-
- ### Test Schedule Configuration
-
- ```java
- @Component
- public class HealthCheckTask {
-
- private final HealthCheckService healthCheckService;
- private int executionCount = 0;
-
- public HealthCheckTask(HealthCheckService healthCheckService) {
- this.healthCheckService = healthCheckService;
- }
-
- @Scheduled(fixedRate = 5000) // Every 5 seconds
- public void checkHealth() {
- executionCount++;
- healthCheckService.check();
- }
-
- public int getExecutionCount() {
- return executionCount;
- }
- }
-
- class ScheduledTaskTimingTest {
-
- @Test
- void shouldExecuteTaskMultipleTimes() {
- HealthCheckService mockService = mock(HealthCheckService.class);
- HealthCheckTask task = new HealthCheckTask(mockService);
-
- // Execute manually multiple times
- task.checkHealth();
- task.checkHealth();
- task.checkHealth();
-
- assertThat(task.getExecutionCount()).isEqualTo(3);
- verify(mockService, times(3)).check();
- }
- }
- ```
+ Full Maven/Gradle dependencies, additional test classes, and execution count patterns: see `references/examples.md`.
## Best Practices
- - **Test async method logic directly** without Spring async executor
- - **Use CompletableFuture.get()** to wait for results in tests
- - **Mock dependencies** that async methods use
- - **Test error paths** for async operations
- - **Use Awaitility** when testing actual async behavior is needed
- - **Mock scheduled tasks** by calling methods directly in tests
- - **Verify task execution count** for testing scheduling logic
+ - Always set a **timeout** on `CompletableFuture.get()` to prevent hanging tests
+ - **Mock all dependencies** — never call real external services in unit tests
+ - Use **Awaitility** only for race conditions; prefer direct calls for simple async methods
+ - Test `@Scheduled` **logic** directly — the annotation is ignored in unit tests
+ - Assert values before verifying mock interactions; verify **after** async completion
## Common Pitfalls
- - Testing with actual `@`Async executor (use direct method calls instead)
- - Not waiting for CompletableFuture completion in tests
- - Forgetting to test exception handling in async methods
- - Not mocking dependencies that async methods call
- - Trying to test actual scheduling timing (test logic instead)
+ - Relying on Spring's async executor instead of calling methods directly
+ - Missing timeout on `CompletableFuture.get()`
+ - Forgetting to test exception propagation in async methods
+ - Not mocking dependencies that async methods invoke internally
+ - Waiting for actual cron/fixedRate timing instead of testing logic in isolation
## Constraints and Warnings
- - **`@`Async requires proxy**: Spring's `@`Async works via proxies; direct method calls bypass async behavior
- - **ThreadPoolTaskScheduler**: Scheduled tasks use a thread pool; order of execution is not guaranteed
- - **CompletableFuture chaining**: Chain operations carefully; exceptions in intermediate stages can be lost
- - **Awaitility timeout**: Set reasonable timeouts; infinite waits can hang test suites
- - **Scheduled task timing**: Don't test actual cron/fixedRate timing; test the method logic directly
- - **Thread safety**: Async code must be thread-safe; verify behavior under concurrent access
- - **`@`Async on same class**: Calling `@`Async method from another method in same class won't be async
-
- ## Troubleshooting
-
- **CompletableFuture hangs in test**: Ensure methods complete or set timeout with `.get(timeout, unit)`.
-
- **Async method not executing**: Call method directly instead of relying on `@`Async in tests.
-
- **Awaitility timeout**: Increase timeout duration or reduce polling interval.
+ - **`@Async` self-invocation**: calling `@Async` from another method in the same class executes synchronously — the Spring proxy is bypassed
+ - **Thread pool ordering**: `ThreadPoolTaskScheduler` does not guarantee execution order
+ - **CompletableFuture chaining**: exceptions in intermediate stages can be silently lost — test each stage
+ - **Awaitility timeout**: always set a reasonable `atMost()`; infinite waits hang the test suite
+ - **No actual scheduling**: `@Scheduled` is ignored in unit tests — call methods directly
## References
- - [Spring `@`Async Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Async.html)
- - [Spring `@`Scheduled Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html)
+ - [Spring `@Async` Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Async.html)
+ - [Spring `@Scheduled` Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/scheduling/annotation/Scheduled.html)
- [Awaitility Testing Library](https://github.com/awaitility/awaitility)
- [CompletableFuture API](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/CompletableFuture.html)
+ - Code examples: `references/examples.md`