本文的目的
长久以来,码农们有一种默契的共识,只有写的好的代码才能写单测,代码的可测性也因此成为了评判好代码的一个标准。但是,同学有没有想过,我们手里有多少是写的好的代码,很多情况下,我们接手的代码都是又长又臭的“面条代码”,难道我们就不能对这些应用写单测了吗?难道这些应用不是更需要单测的保障吗?
在本文中,我将使用一种全新的单测解决方案,通过这个方案,再复杂的应用也能简单的实现单测覆盖,保障我们的业务正确性。
单测的定义
因为本方案可能会颠覆你对单测的理解,所以在描述方案之前,我有必要对单测作一个定义,当然这个定义不是我发明的,而是马丁·福勒(Martin Fowler)。微服务下的测试策略 by 马丁·福勒
单测大类
从大类上分,单测分为
社交型单测(Sociable unit testing)
独立型单测(solitary unit testing)
下图描述了这两种单测的测试边界:
单测细分
再往下就是针对各个细分场景的单测分类,比如微服务下的单测。他把微服务下的单测分成了三类:
单元测试(Unit Test):类或方法级别的单元测试
集成测试(Integration Test):对于外部系统的测试,只验证外部系统的正确性
组件测试(Component Test):基于完整用例的测试,从上到下贯穿系统,使用内存数据库和mock技术解决外部依赖
下图中Martin Fowler用不同颜色的框框,框出了每一种单测的边界。
我们要解决什么问题?
我们先回到我们要解决的问题,我们不是为了单测而单测,也不是为了覆盖率而写单测,我们是要验证我们“代码的正确性”。
而验证代码正确性,测试同学有非常有效的手段,那就是自动化的集成测试,在盒马,我们的测试同学投入了巨大的精力,构建了庞大的自动化集成测试套件,这些自动化case一次又一次的保障了我们的系统。但这套系统有一些难于解决的问题:
维护成本巨高
问题排查耗时
不能断言问题原因
运行时间长
多测试并行相互影响
因此,我们一直在想,是不是能借用“自动化集成测试套件”,同时又能解决它带来的问题呢?我们对这套系统提出了以下几个要求:
能最大程度的还原“自动化集成测试”
维护成本降到1/10
问题排查简单
能精确断言问题原因
运行时长<1分钟
拔掉网线仍然可正常运行
复杂应用的单测解决方案
我们采用了“组件测试”来解决复杂应用的单元测试,思路与测试同学的集成测试类似,区别在于我们使用了内存数据库并且mock了外部依赖。因为基于测试同学的自动化集成测试,我们使用运行中的数据快照来mock数据,让我们的单测能完整表达业务用例,同时又具有单元测试的诸多优点,比如:运行快、可精确断言、单机可运行等。
方案示意图如下:
当然,上图只是简单的表述了一下方案的思路,在真正实现中还需要解决很多问题。
一、单测容器的选择
使用SpringJUnit4ClassRunner作为测试容器,保证测试运行能在10s内完成。ComponentTestRunner中定制测试类中的方法的执行顺序,Junit默认每个类的方法运行顺序是随机的,为了能在不同机器上保证测试方法运行顺序都一致,我们做了一些扩展。当然最好的单测是可以无顺序执行的,但我们引入了组件测试,组件测试是依赖H2数据库的,测试用例之间有可能会相互影响,为了让单测的维护更简单,因此我们通过扩展Runner来保证了运行顺序。另外类之间的运行顺序,我们也会通过Test Suit来保证,这样就能保证单测在本机运行通过,任何环境上都能通过了。
@RunWith(ComponentTestRunner.class)
@DelegateTo(SpringJUnit4ClassRunner.class)
@ActiveProfiles("test")
@TestPropertySource("classpath:application-test.properties")
@ContextConfiguration(
classes = {
DataBaseConfig.class,
OrderController.class,
OrderCreateCmdService.class,
//测试所依赖的类
})
public abstract class AbstractTest {
}
二、数据库的封装
封装mysql与H2,同一套代码支持不同数据库,可灵活切换。通过设置isStartDatabaseWebServer=true,可以在运行时调出内存数据库web页面。
private DataSource getDataSource() {
if (dataSourceForWebServer == null) {
InMemoryDatabaseFactory factory = new InMemoryDatabaseFactory();
DataSource dataSource = factory.createDatabase(DatabaseType.H2, Arrays.asList("ddl/*.sql"), isStartDatabaseWebServer);
dataSourceForWebServer = dataSource;
}
return dataSourceForWebServer;
}
内存数据库web页面
三、Spring-Test的增强
在一些老应用中,一个service中可能依赖了几十个其它的service,而我们要测试的方法,可能只使用了一个,这需要我们手动去注入或mock其它不需要的service。因此我们扩展了Spring-Test,定制了一个可以按需自动mock的ContextLoader。
1、通过重写BeanFactory中的findAutowireCandidates方法,定制了一个AutoMockBeanFactory
2、通过重写ContextLoader中的loadContext方法,定制了一个测试用的loader
protected Map findAutowireCandidates(String beanName, Class requiredType, DependencyDescriptor descriptor) {
String mockBeanName = Introspector.decapitalize(requiredType.getSimpleName()) + "Mock";
Map autowireCandidates = new HashMap<>();
try {
autowireCandidates = super.findAutowireCandidates(beanName, requiredType, descriptor);
} catch (UnsatisfiedDependencyException e) {
if (e.getCause() != null && e.getCause().getCause() instanceof NoSuchBeanDefinitionException) {
mockBeanName = ((NoSuchBeanDefinitionException) e.getCause().getCause()).getBeanName();
}
this.registerBeanDefinition(mockBeanName, BeanDefinitionBuilder.genericBeanDefinition().getBeanDefinition());
}
//当spring容器中找不到指定类时,自动做mock
if (autowireCandidates.isEmpty()) {
//只有required是true时,才需要mock
if(descriptor.isRequired()) {
//自动moack需要的类
final Object mock = mock(requiredType);
autowireCandidates.put(mockBeanName, mock);
this.addSingleton(mockBeanName, mock);
}
}
return autowireCandidates;
}
public final ConfigurableApplicationContext loadContext(MergedContextConfiguration mergedConfig) throws Exception {
if (logger.isDebugEnabled()) {
logger.debug(String.format("Loading ApplicationContext for merged context configuration [%s].",
mergedConfig));
}
validateMergedContextConfiguration(mergedConfig);
GenericApplicationContext context = new GenericApplicationContext(new AutoMockBeanFactory());
ApplicationContext parent = mergedConfig.getParentApplicationContext();
if (parent != null) {
context.setParent(parent);
}
prepareContext(context);
prepareContext(context, mergedConfig);
customizeBeanFactory(context.getDefaultListableBeanFactory());
loadBeanDefinitions(context, mergedConfig);
AnnotationConfigUtils.registerAnnotationConfigProcessors(context);
customizeContext(context);
customizeContext(context, mergedConfig);
context.refresh();
context.registerShutdownHook();
return context;
}
四、Mock及测试数据
Mock和测试数据没有特别的好方式,对于Mock我们通常只对外部的依赖做mock,使用的是Mockito。测试数据,使用sql插入到内存数据库中,或通过json文件来mock
使用原型一键生成整个解决方案
我们为我们的测试方案制作了一个Archetype,通过以下命令你可能生成一个搭载了我们测试方案的Demo:
mvn archetype:generate \
-DarchetypeGroupId=testdemo \
-DarchetypeArtifactId=component-test-demo-archetype \
-DarchetypeVersion=1.0.0-SNAPSHOT \
-DgroupId=xxx \
-DartifactId=xxx \
-Dversion=xxx
这个Demo中还包括了,基于DDD依赖倒置的分层,CQRS,以及一个DDD的样例,在xxx-test模板中你能找到我们的测试方案