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.
When to Use
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
Instructions
Test async methods directly
Call @Async methods directly instead of relying on Spring's async executor
Use CompletableFuture.get()
Wait for async operations to complete with explicit timeout
Mock async dependencies
Use @Mock for services that async methods depend on
Use Awaitility for assertions
Apply Awaitility.await() when testing actual async behavior
Test scheduled methods directly
Call @Scheduled methods directly instead of waiting for cron expressions
Verify mock interactions
Ensure dependencies were called correctly after async completion
Test exception handling
Verify exceptions in async methods propagate correctly
Set appropriate timeouts
Always use timeout with CompletableFuture.get() to avoid hanging tests
Examples
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)
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.
References
Spring @Async Documentation
Spring @Scheduled Documentation
Awaitility Testing Library
CompletableFuture API