欢迎加入 Spring cloud 交流群: 617143034 (opens new window)
# 简介
近期公司里有一些业务需要服务端异步执行, 于是开发团队自热而然的使用了 Spring 自带的 @Async
注解标记了业务方法,
并使用 @EnableAsync
注解启用了异步功能, 但是编写单元测试时却遇到了由于异步引发的问题: 测试用例线程与业务线程异步,
导致无法正确的对业务进行断言.
于是我们尝试了多种不同的方式来对异步业务进行调试与测试, 最终得出了以下几种测试方式, 为了方便演示, 以如下 AsyncService
为例进行介绍:
@Service
class AsyncService { // 为了简洁省去部分访问修饰符
boolean done = false;
@Async
void myAsyncMethod() {
Thread.sleep(1000); // 模拟 1 秒钟的业务耗时
done = true; // 模拟异步方法造成的业务影响
}
}
# 方式一: 阻塞测试用例线程
思路非常简单: 既然测试用例线程比业务线程要提前结束, 那直接阻塞测试用例线程, 让它跑得比业务线程慢不就行了.
@SpringBootTest
class BlockUnitTestThreadAsyncServceTest {
@Autowired
AsyncService service;
@Test
void test() {
service.myAsyncMethod();
// 强制测试用例线程等待一定时间后再进行断言
Thread.sleep(2000);
assertTrue(service.done);
}
}
可以看出这种方式比较笨拙 🤦♂️, 因为业务耗时一旦变长, 测试用例随时可能会断言失败, 而且如果业务耗时显著小于测试用例中等待的时间, 则整体的测试耗时又会被不必要的等待拉长.
# 方式二: 分离异步线程与业务逻辑
思路: 既然异步不好测试, 那直接把业务拆出来, 同步测试业务不就行了.
于是新增一个 SyncService
:
@Service
class SyncService {
boolean done = false;
void mySyncMethod() {
Thread.sleep(1000); // 模拟 1 秒钟的业务耗时
done = true; // 模拟业务影响
}
}
同时改造 AsyncService
:
@Service
class AsyncService { // 使用 AsyncService 套壳
@Autowired
SyncService service;
@Async
void myAsyncMethod() {
service.mySyncMethod();
}
}
编写测试用例:
@SpringBootTest
class SyncServceTest {
@Autowired
SyncService service;
@Test
void test() {
service.mySyncMethod();
// 由于是同步执行, 因此与常规 Service 对象的测试方式没有什么差别
assertTrue(service.done);
}
}
这种方式需要对业务代码进行一定量的修改, 而且随着需要异步执行的业务增加, 势必会出现越来越多的套壳类/方法. 同时,
由于 AsyncService
的存在, 会出现缺少测试用例导致整体测试覆盖率下降, 或出现仅调用 AsyncService
方法但不进行断言的无实际意义的 AsyncServiceTest
的情况.
# 方式三: 偷梁换柱, 替换测试用例的 Executor
由于上述两种方式都存在一定的缺陷, 因此我们不得不进一步的研究文档和源码, 以寻求更合适的测试方式.
查看 Spring 官方提供的引导教程 Creating Asynchronous Methods (opens new window)
后发现, 这篇文章中仅介绍了如何开启异步功能并如何使用 @Async
注解, 但是完全没有介绍如何对其进行测试.
前往其对应的仓库 https://github.com/spring-guides/gs-async-method (opens new window) ,
也没有发现测试相关的内容. 😢
考虑到平时学习异步相关的内容时都会牵扯到 Executor
, 因此以它为切入点, 想办法把测试用例中的异步执行器替换成同步执行器,
但是怎么去替换, 大家都不懂, 看来只能研究源码了 🕵️♂️.
以 spring-context-5.3.10.jar
为例, 查看 @EnableAsync
注解发现它导入了一个 AsyncConfigurationSelector
类:
// ...
@Import(AsyncConfigurationSelector.class)
public @interface EnableAsync { ... }
查看 AsyncConfigurationSelector
后发现其默认使用了 ProxyAsyncConfiguration
提供的配置:
// ...
public class AsyncConfigurationSelector extends AdviceModeImportSelector<EnableAsync> {
// ...
public String[] selectImports(AdviceMode adviceMode) {
switch (adviceMode) {
case PROXY: // 默认使用 JDK 的 Proxy 实现代理功能
return new String[] {ProxyAsyncConfiguration.class.getName()};
case ASPECTJ:
return new String[] {ASYNC_EXECUTION_ASPECT_CONFIGURATION_CLASS_NAME};
default:
return null;
}
}
}
查看 ProxyAsyncConfiguration
时终于出现了 Executor
相关的内容:
// ...
public class ProxyAsyncConfiguration extends AbstractAsyncConfiguration {
// ...
public AsyncAnnotationBeanPostProcessor asyncAdvisor() {
Assert.notNull(this.enableAsync, "@EnableAsync annotation metadata was not injected");
AsyncAnnotationBeanPostProcessor bpp = new AsyncAnnotationBeanPostProcessor();
bpp.configure(this.executor, this.exceptionHandler); // OHHHHHHHHHHHHHHHHHHHHHHHHH
Class<? extends Annotation> customAsyncAnnotation = this.enableAsync.getClass("annotation");
if (customAsyncAnnotation != AnnotationUtils.getDefaultValue(EnableAsync.class, "annotation")) {
bpp.setAsyncAnnotationType(customAsyncAnnotation);
}
bpp.setProxyTargetClass(this.enableAsync.getBoolean("proxyTargetClass"));
bpp.setOrder(this.enableAsync.<Integer>getNumber("order"));
return bpp;
}
}
ProxyAsyncConfiguration
使用了父类 AbstractAsyncConfiguration
提供的 executor
用于配置 AsyncAnnotationBeanPostProcessor
, 而这个 executor
又是由一个带有 @Autowired
注解的方法初始化的 😉.
// ...
public abstract class AbstractAsyncConfiguration implements ImportAware {
@Nullable
protected Supplier<Executor> executor;
// ...
@Autowired(required = false)
void setConfigurers(Collection<AsyncConfigurer> configurers) {
if (CollectionUtils.isEmpty(configurers)) {return;}
if (configurers.size() > 1) {throw new IllegalStateException("Only one AsyncConfigurer may exist");}
AsyncConfigurer configurer = configurers.iterator().next();
this.executor = configurer::getAsyncExecutor;
this.exceptionHandler = configurer::getAsyncUncaughtExceptionHandler;
}
}
这样的话, 我们只需要向 Spring 提供一个 AsyncConfigurer
对象, 然后在 getAsyncExecutor
方法中返回一个同步执行的 Executor
是不是就行了呢?
尝试添加一个 AsyncConfig
到测试代码集:
@Component
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
// 测试环境下立即执行异步任务
return new org.springframework.core.task.SyncTaskExecutor();
}
}
这里我们借用了 Spring 提供的 SyncTaskExecutor
, 它的实现方式非常简单:
public class SyncTaskExecutor implements TaskExecutor, Serializable {
/**
* Executes the given {@code task} synchronously, through direct
* invocation of it's {@link Runnable#run() run()} method.
* @throws IllegalArgumentException if the given {@code task} is {@code null}
*/
@Override
public void execute(Runnable task) {
Assert.notNull(task, "Runnable must not be null");
task.run();
}
}
然后将 AsyncConfig
导入到我们的测试用例中:
@SpringBootTest
@Import(AsyncConfig.class)
class ReplaceExecutorAsyncServceTest {
@Autowired
AsyncService service;
@Test
void test() {
service.myAsyncMethod();
// 原本的异步业务现在已经是同步执行了
assertTrue(service.done);
}
}
再次运行测试发现测试通过, 说明测试用例已经使用我们提供的同步执行器在运行原本的异步业务了🎉.
这样, 只需要在希望同步测试的用例中导入 AsyncConfig
即可像平常一样对业务进行断言了, 既不用修改业务代码, 也不用浪费时间做不必要的等待.
# 参考内容
- Creating Asynchronous Methods @Spring.io (opens new window)
- Task Execution and Scheduling @Spring.io (opens new window)
# 推广
欢迎加入 Spring cloud 交流群: 617143034 (opens new window)
欢迎大家使用 阿里云优惠券 (opens new window), 新购续费更优惠: (opens new window)