[Spring] [最佳实践] 如何对 @Async 标记的异步方法进行单元测试

阿里云推广 (opens new window)

欢迎加入 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 即可像平常一样对业务进行断言了, 既不用修改业务代码, 也不用浪费时间做不必要的等待.

# 参考内容

# 推广

欢迎加入 Spring cloud 交流群: 617143034 (opens new window)

欢迎大家使用 阿里云优惠券 (opens new window), 新购续费更优惠: 限量阿里云优惠券 (opens new window)