How do you test asynchronous or scheduled code in Spring Boot?
Quick Answer
Testing @Async methods typically means either calling the method and using a mechanism like Awaitility (or a CountDownLatch/CompletableFuture) to wait for the asynchronous work to actually complete before asserting on its effects, since a naive immediate assertion right after the call would race against the background thread. Testing @Scheduled methods usually means extracting the method's actual logic into a plain, directly-callable method invoked by a thin @Scheduled wrapper, so the test can call the logic method directly and synchronously rather than waiting for the real cron/fixed-rate trigger to fire.
Detailed Answer
Both @Async and @Scheduled methods run outside the calling thread's normal, synchronous control flow, which means naive test assertions immediately following a call to one of them will often race against work that hasn't actually finished yet.
Testing @Async methods:
A test that calls an @Async method and immediately asserts on its side effects is racing against a background thread:
@Test
void sendsWelcomeEmailAsync() {
userService.registerAndNotify(newUser); // returns immediately — @Async method runs on another thread
assertThat(emailSender.wasCalled()).isTrue(); // FLAKY — might run before the async method actually completes
}
Common fixes:
- Have the method return a
CompletableFuture/Future, and.get()(or.join()) on it in the test — this blocks until the async work genuinely completes, giving a deterministic test:
CompletableFuture<Void> future = userService.registerAndNotifyAsync(newUser);
future.get(2, TimeUnit.SECONDS); // blocks until done, with a timeout as a safety net
assertThat(emailSender.wasCalled()).isTrue();
- Use Awaitility to poll for an expected condition with a timeout, when the method's return type can't easily be changed to something awaitable:
await().atMost(2, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(emailSender.wasCalled()).isTrue());
- Configure a synchronous
TaskExecutorfor the test profile — for tests where the asynchronous timing genuinely doesn't matter, swapping in a same-thread executor (e.g., Spring'sSyncTaskExecutor) just for tests removes the asynchrony entirely, making the method effectively synchronous and simplifying the test — though this doesn't verify actual concurrent behavior, only the logic itself.
Testing @Scheduled methods:
Waiting for a real cron/fixed-rate trigger to fire in a test is slow and awkward. The standard approach is to extract the actual logic into a plain, directly-callable method, with the @Scheduled annotation on a thin wrapper that just delegates to it:
@Component
class ReportGenerator {
@Scheduled(cron = "0 0 1 * * *")
void scheduledRun() {
generateDailyReport(); // thin wrapper — the annotation itself isn't what needs testing
}
void generateDailyReport() { /* the actual logic under test */ }
}
@Test
void generatesDailyReportCorrectly() {
reportGenerator.generateDailyReport(); // call the logic directly and synchronously — no waiting for a trigger
// assert on the report's contents
}
This tests the actual business logic thoroughly and quickly, while the @Scheduled wrapper itself (just "does this run on the intended schedule") is a thin enough layer that it's usually left unverified by automated tests, or checked separately via a lightweight scheduling-configuration review rather than by literally waiting for a cron trigger to fire during a test run.