Claude Code Plugins

Community-maintained marketplace

Feedback

unit-test-scheduled-async

@giuseppe-trisciuoglio/developer-kit
14
0

Unit tests for scheduled and async tasks using @Scheduled and @Async. Mock task execution and timing. Use when validating asynchronous operations and scheduling behavior.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name unit-test-scheduled-async
description Unit tests for scheduled and async tasks using @Scheduled and @Async. Mock task execution and timing. Use when validating asynchronous operations and scheduling behavior.
category testing
tags junit-5, scheduled, async, concurrency, completablefuture
version 1.0.1

Unit Testing @Scheduled and @Async Methods

Test scheduled tasks and async methods using JUnit 5 without running the actual scheduler. Verify execution logic, timing, and asynchronous behavior.

When to Use This Skill

Use this skill when:

  • Testing @Scheduled method logic
  • Testing @Async method behavior
  • Verifying CompletableFuture results
  • Testing async error handling
  • Want fast tests without actual scheduling
  • Testing background task logic in isolation

Setup: Async/Scheduled Testing

Maven

<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

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

// 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");

    assertThat(result)
      .isCompletedWithValue(true);
  }
}

Testing Async with Mocked Dependencies

Async Service with Dependencies

@Service
public class UserNotificationService {

  private final EmailService emailService;
  private final SmsService smsService;

  public UserNotificationService(EmailService emailService, SmsService smsService) {
    this.emailService = emailService;
    this.smsService = smsService;
  }

  @Async
  public CompletableFuture<String> notifyUserAsync(String userId) {
    return CompletableFuture.supplyAsync(() -> {
      emailService.send(userId);
      smsService.send(userId);
      return "Notification sent";
    });
  }
}

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

Testing @Scheduled Methods

Mock Task Execution

// Scheduled task
@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

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

Testing Async Error Handling

Handle Exceptions in Async Methods

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

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

Testing Scheduled Task Timing

Test Schedule Configuration

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

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

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)

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.

References