unit-test-scheduled-async

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Unit Testing @Scheduled and @Async Methods

@Scheduled与@Async方法的单元测试

Test scheduled tasks and async methods using JUnit 5 without running the actual scheduler. Verify execution logic, timing, and asynchronous behavior.
无需运行实际调度器,使用JUnit 5测试定时任务与异步方法。验证执行逻辑、计时与异步行为。

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
在以下场景使用本技能:
  • 测试@Scheduled方法逻辑
  • 测试@Async方法行为
  • 验证CompletableFuture结果
  • 测试异步错误处理
  • 想要无需实际调度的快速测试
  • 隔离测试后台任务逻辑

Setup: Async/Scheduled Testing

设置:异步/定时任务测试

Maven

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>
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

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

测试@Async方法

Basic Async Testing with CompletableFuture

使用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");

    assertThat(result)
      .isCompletedWithValue(true);
  }
}
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");

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

Testing Async with Mocked Dependencies

含模拟依赖的异步测试

Async Service with Dependencies

带依赖的异步服务

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

测试@Scheduled方法

Mock Task Execution

模拟任务执行

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

使用Awaitility测试异步任务

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

处理异步方法中的异常

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

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

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

测试调度配置

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

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
  • 直接测试异步方法逻辑,无需使用Spring异步执行器
  • 使用CompletableFuture.get() 在测试中等待结果
  • 模拟异步方法依赖的组件
  • 测试异步操作的错误路径
  • 当需要测试实际异步行为时使用Awaitility
  • 通过直接调用方法模拟定时任务
  • 验证任务执行次数 以测试调度逻辑

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)
  • 使用实际@Async执行器进行测试(应改为直接调用方法)
  • 测试中未等待CompletableFuture完成
  • 忘记测试异步方法中的异常处理
  • 未模拟异步方法调用的依赖组件
  • 尝试测试实际调度计时(应测试逻辑而非计时)

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.
CompletableFuture在测试中挂起:确保方法完成,或使用
.get(timeout, unit)
设置超时时间。
异步方法未执行:直接调用方法,而非依赖测试中的@Async。
Awaitility超时:增加超时时长或缩短轮询间隔。

References

参考资料