Back to Testing & Quality Assurance

unit-test-scheduled-async

Spring BootJUnitUnit TestingAsynchronousScheduled TasksJavaTesting StrategiesConcurrency
282📄 MIT🕒 2026-06-15Source ↗

Install this skill

npx skills add giuseppe-trisciuoglio/developer-kit

Works across Claude Code, Cursor, Codex, Copilot & Antigravity

This skill facilitates the testing of Spring-based background processes without requiring a full application context or the actual Spring scheduler. By decoupling scheduled task logic and asynchronous service calls from the framework's runtime environment, you can validate business logic rapidly using standard JUnit 5 patterns. The approach emphasizes isolation, allowing developers to trigger @Async methods directly or call @Scheduled methods as standard void methods within unit tests. It incorporates Awaitility to handle timing-sensitive assertions for concurrent operations and uses Mockito to verify internal dependencies. This method minimizes test execution time by avoiding thread pools or fixed-delay cycles, ensuring tests remain predictable, fast, and deterministic for CI/CD pipelines.

When to Use This Skill

  • Validating data cleanup logic triggered by cron expressions
  • Testing complex notification flows that run in background threads
  • Verifying that cache refresh methods correctly interact with repositories
  • Confirming error handling behavior in asynchronous service calls

How to Invoke This Skill

Example prompts that trigger this skill in Claude Code, Cursor, or Antigravity:

  • how to unit test spring async methods
  • mocking scheduled tasks in java
  • test completion of completablefuture in unit test
  • validate logic inside @Scheduled spring method
  • verify async method calls with mockito

Pro Tips

  • 💡Utilize `Awaitility` effectively to wait for asynchronous results or state changes, avoiding flaky tests and race conditions.
  • 💡Isolate the component under test by mocking external dependencies to focus purely on the async/scheduled logic and its side effects.
  • 💡Consider using `TestScheduler` or similar mocks from reactive frameworks for precise control over time-sensitive operations in advanced scenarios.
  • 💡Always check for exceptions and timeouts in asynchronous operations to ensure robust error handling.

What this skill does

  • Execute @Async service methods synchronously in unit test threads
  • Call @Scheduled business methods manually to verify logic execution
  • Assert CompletableFuture completion states and returned values
  • Verify dependency interactions within asynchronous blocks using Mockito
  • Use Awaitility to poll for state changes in non-blocking tasks
  • Capture and assert exceptions thrown from background threads

When not to use it

  • Testing end-to-end scheduling integration where real cron trigger timing matters
  • Validating infrastructure-level thread pool configuration or exhaustion

Example workflow

  1. Define the component containing @Async or @Scheduled annotations.
  2. Create a test class using @ExtendWith(MockitoExtension.class).
  3. Mock the necessary dependencies required by the target service.
  4. Inject the mocked dependencies into the target class.
  5. Call the target method directly as a normal Java method.
  6. Perform assertions on the returned Future or the mocked dependency interaction.

Prerequisites

  • JUnit 5
  • Mockito
  • AssertJ
  • Awaitility

Pitfalls & limitations

  • !Ignoring the fact that @Async methods run in the same thread during unit tests
  • !Overlooking the need for .get() on CompletableFutures in synchronous tests
  • !Misinterpreting scheduling delays as method logic errors

FAQ

Do I need a Spring context to test these methods?
No, because you are testing the method logic as a plain Java object. You can inject mocks manually, bypassing the need for an application context.
How does this handle the @Async annotation during testing?
The @Async annotation is ignored during a standard unit test because Spring's proxy mechanism is not active. The method executes synchronously on the test thread.
Why use Awaitility if the method is synchronous?
Awaitility is useful when the business logic involves internal background threads or when you want to verify that a state eventually changes after a side-effect is triggered.

How it compares

Unlike manual testing which relies on waiting for clock triggers or complex integration tests that spin up the full context, this skill isolates logic for high-speed, deterministic verification.

Source & trust

282 stars📄 MIT🕒 Updated 2026-06-15
📄 Full skill instructions — original source: giuseppe-trisciuoglio/developer-kit
# 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("[email protected]", "Hello");

Boolean success = result.get(); // Wait for completion
assertThat(success).isTrue();
}

@Test
void shouldCompleteWithinTimeout() {
EmailService service = new EmailService();

CompletableFuture<Boolean> result = service.sendEmailAsync("[email protected]", "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

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

How to Use This Skill Unit

Option A: Project-Specific (Recommended)

  1. Click "Download" above
  2. In your project, create the directory: .agent/skills/unit-test-scheduled-async/
  3. Save the file as SKILL.md
  4. The agent will automatically discover the skill based on its description.

Option B: Global Installation (All Agents)

Save the file to these locations to make it available across all projects:

  • Claude Code: ~/.claude/skills/giuseppe-trisciuoglio/developer-kit/unit-test-scheduled-async/SKILL.md
  • Cursor: ~/.cursor/skills/giuseppe-trisciuoglio/developer-kit/unit-test-scheduled-async/SKILL.md
  • Antigravity: ~/.gemini/antigravity/skills/giuseppe-trisciuoglio/developer-kit/unit-test-scheduled-async/SKILL.md

🚀 Install with CLI:
npx skills add giuseppe-trisciuoglio/developer-kit

Read the Master Guide: Mastering Agent Skills

Recommended Rules

View more rules

Recommended Workflows

View more workflows

Recommended MCP Servers

View more MCP servers

Take It Further

Maximize your productivity with these powerful resources

📋

Define Your Standards

Set up coding standards to ensure this workflow produces consistent, high-quality results.

Browse Rules Library
📖

Master Workflows

Learn how to create custom workflows, use Turbo Mode, and build your automation library.

Complete Guide

How to use this Skill in Claude Code & Cursor

For Claude Code (CLI)

To use this skill in Claude Code, copy the rule content into your project's custom instructions or follow our Add-Skill CLI guide. This ensures Claude follows your standards during every code generation.

For Cursor & Windsurf

For Cursor or Windsurf, individual skills are best used in the "Rules for AI" section. This specific unit helps the agent avoid testing & quality assurance issues, leading to cleaner, more efficient code.

Why the skill format matters: the standardized Agent Skills format lets your AI agent load detailed instructions only when they are relevant, keeping your prompt clean while improving results.

Source & attribution

This skill is categorized under Testing & Quality Assurance and is published by Giuseppe Trisciuoglio, maintained in giuseppe-trisciuoglio/developer-kit.

← Browse All Agent Skills
Sponsored AI assistant. Recommendations may be paid.