In this demo, there is DemoService injected in a test class using @SpyBean.
DemoService has a single @Async method someAsyncTask.
In a @Test method, someAsyncTask is mocked to throw a RuntimeException then called expecting this exception to be thrown.
This test fails.
When @EnableAsync is removed, this test passes.
Comment From: wilkinsona
Thanks for the sample. This is unrelated to @SpyBean. The problem also occurs with a plain Mockito spy and without Spring Boot being involved:
package com.example.demo;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import com.example.demo.DemoApplicationTests.ServiceConfiguration;
@SpringJUnitConfig(classes = ServiceConfiguration.class)
class DemoApplicationTests {
@Autowired
private DemoService demoService;
@Test
void whenThenThrowTest() throws InterruptedException, ExecutionException {
when(demoService.someAsyncTask()).thenThrow(RuntimeException.class);
Assertions.assertThrows(RuntimeException.class, () -> demoService.someAsyncTask().get());
}
@Test
void doThrowWhenTest() throws InterruptedException, ExecutionException {
Mockito.doThrow(RuntimeException.class).when(demoService).someAsyncTask();
Assertions.assertThrows(RuntimeException.class, () -> demoService.someAsyncTask().get());
}
@EnableAsync
@Configuration
static class ServiceConfiguration {
@Bean
DemoService demoService() {
return spy(DemoService.class);
};
}
static class DemoService {
@Async
public CompletableFuture<Void> someAsyncTask() {
return CompletableFuture.completedFuture(null);
}
}
}
I believe that part of the problem is that the test's expectations are incorrect. When the async method throws an exception, its future will be completed exceptionally and, therefore, getting the result will throw an ExecutionException. This is done by Spring Framework's org.springframework.util.concurrent.FutureUtils.toSupplier(Callable<T>, CompletableFuture<T>).
The other part of the problem is that demoService.someAsyncTask() is being called on the proxy that's created to support @Async and not on the spy. This can be overcome by getting the proxy's target. Here are the tests above updated with these changes such that both pass:
package com.example.demo;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.util.AopTestUtils;
import com.example.demo.DemoApplicationTests.ServiceConfiguration;
@SpringJUnitConfig(classes = ServiceConfiguration.class)
class DemoApplicationTests {
@Autowired
private DemoService demoService;
@Test
void whenThenThrowTest() throws InterruptedException, ExecutionException {
DemoService target = AopTestUtils.getUltimateTargetObject(demoService);
when(target.someAsyncTask()).thenThrow(RuntimeException.class);
Assertions.assertThrows(ExecutionException.class, () -> demoService.someAsyncTask().get());
}
@Test
void doThrowWhenTest() throws InterruptedException, ExecutionException {
Mockito.doThrow(RuntimeException.class).when(demoService).someAsyncTask();
Assertions.assertThrows(ExecutionException.class, () -> demoService.someAsyncTask().get());
}
@EnableAsync
@Configuration
static class ServiceConfiguration {
@Bean
DemoService demoService() {
return spy(DemoService.class);
};
}
static class DemoService {
@Async
public CompletableFuture<Void> someAsyncTask() {
return CompletableFuture.completedFuture(null);
}
}
}
Comment From: romainmoreau
Thanks for your detailed answer but the exceptions I was refering to really were synchronous exceptions not the ones throwed by the @Async method, for instance exceptions thrown by the Executor which are synchronously/directly thrown (sample here).
Comment From: wilkinsona
If you still think there's a bug then please open a Spring Framework issue as the problem occurs without Spring Boot. If you do so, you should take the time to update your example to remove Spring Boot, as I did above for the other tests.