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. That 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 (like Spring'sSyncTaskExecutor) just for tests removes the asynchrony entirely, making the method effectively synchronous and simplifying the test. 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. 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.