背景:
使用
@SpringBootTest
进行单元测试其实不符合单元测试理念,主要有以下几个原因:
- 测试范围过大:单元测试的目标是对应用中的单个“单元”进行测试,通常是类或方法级别的测试。使用
@SpringBootTest
会启动整个Spring应用程序上下文,这意味着测试的范围不仅包括目标单元,还包括其所有依赖项和配置。这更像是集成测试,而不是严格意义上的单元测试。 - 速度慢:由于
@SpringBootTest
会启动完整的Spring应用程序上下文,测试的执行速度会比纯单元测试慢得多。单元测试应该是快速的,以便能够频繁运行并快速反馈问题。 - 依赖环境:单元测试应该是独立的,不依赖外部环境或复杂的上下文配置。使用
@SpringBootTest
会引入对Spring上下文的依赖,使得测试可能会受到环境配置的影响,不再是独立和可重复的。 - 复杂性增加:Spring应用程序上下文的启动涉及大量的Bean初始化和配置,增加了测试的复杂性。单元测试应该尽量保持简单,专注于测试目标单元的逻辑。
单元测试的主要理念包括:
- 隔离:每个单元测试应该只测试一个独立的单元(例如一个类或一个方法),而不依赖于其他单元或外部系统(例如数据库、网络等)。
- 快速:单元测试应该执行得非常快,以便在开发过程中能够频繁运行,快速反馈。
- 可重复:单元测试应该是完全可重复的,任何时间在任何环境下运行结果都应该是一样的。
- 独立:每个单元测试应该是独立的,不应该依赖于其他测试或共享状态。
总的来说,使用
@SpringBootTest
适合于集成测试而不是单元测试。为了更好地遵循单元测试的理念,应避免在单元测试中使用
@SpringBootTest
,而是使用更轻量级的测试方法和工具。所以我们选取Mockito框架进行单元测试。
现象:
引入Mockito之后一些存量代码中带有@SpringBootTest注解的单元测试在打包时有可能会抛出空指针,并且在本地单个单元测试运行永远不会出现,只有使用maven -install时会出现。甚至报错的单元测试类也不一定,有时候是A测试类,有时候是B测试类,甚至有时候,A测试类的a方法还是正常的,b方法就开始报空指针,空指针定位最终原因全都是某个容器中的bean是空。
原因分析:
首先,需要明确一点
在使用
@SpringBootTest
进行单元测试时,Spring Boot 默认情况下会为每个测试类启动一次 Spring 应用程序上下文。这意味着如果你有多个带有
@SpringBootTest
注解的测试类,每个测试类在执行时都会各自启动一次应用程序上下文。
然而,Spring 框架非常智能,能够缓存应用程序上下文,以减少不必要的启动时间。具体来说:
上下文缓存机制
Spring Test 框架有一个默认的上下文缓存机制。默认情况下,Spring 会缓存相同配置的应用程序上下文,并在多个测试类之间共享它们。这意味着,如果两个测试类的配置完全相同,它们将共享同一个应用程序上下文,从而避免多次启动容器的开销。
示例
假设有两个测试类,它们都使用相同的
@SpringBootTest
配置:
@SpringBootTest
public class MyFirstTest {
@Test
void testSomething() { // 测试逻辑 }
}
@SpringBootTest
public class MySecondTest {
@Test
void testSomethingElse() { // 测试逻辑 }
}
在这种情况下,如果这两个测试类的配置相同,Spring 将只启动一次应用程序上下文,并在两个测试类之间共享。
如何确保上下文被缓存
为了确保上下文缓存机制正常工作,需要注意以下几点:
- 相同配置:测试类的配置必须完全相同,包括使用的注解和属性配置。
- 不同配置:如果两个测试类有不同的配置,例如使用不同的
@TestPropertySource
或者@MockBean
,Spring 将认为它们需要不同的应用程序上下文,因此会分别启动。
强制隔离上下文
在某些情况下,你可能希望确保每个测试类都使用一个新的应用程序上下文。你可以使用
@DirtiesContext
注解来强制 Spring 在测试完成后关闭并重新创建应用程序上下文。
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_CLASS)
public class MyFirstTest {
@Test
void testSomething() { // 测试逻辑 }
}
在上面的示例中,每个测试类在完成后,Spring 都会标记上下文为脏的,并在下一个测试类执行前重新创建上下文。
- 默认行为:如果多个
@SpringBootTest
注解的测试类配置相同,Spring 将只启动一次应用程序上下文,并在这些测试类之间共享。 - 上下文缓存:Spring 有一个上下文缓存机制,可以在相同配置的测试类之间共享上下文。
- 配置影响:如果测试类有不同的配置,Spring 将启动不同的上下文。
- 强制隔离:可以使用
@DirtiesContext
注解来强制每个测试类使用新的上下文。
通过了解和利用 Spring 的上下文缓存机制,可以有效地提高测试执行的效率,并减少不必要的资源消耗。
Mockito对容器Mock
了解了Spring单元测试的上下文缓存机制之后,还有一点就是项目中的代码使用Mockito框架对一个至关重要的工具类进行了Mock。相信大多数项目中都会由这样一个工具类吧,用静态方法获取容器中的Bean,因为某些代码在设计上是与容器时解耦的,但实际业务在运行到某处又需要容器里的一些Bean来处理,当然这是设计上的失误,此处先不提。
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringContextUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
SpringContextUtil.applicationContext = applicationContext;
}
public static <T> T getBean(Class<T> clazz) {
return applicationContext.getBean(clazz);
}
public static Object getBean(String name) {
return applicationContext.getBean(name);
}
public static <T> T getBean(String name, Class<T> clazz) {
return applicationContext.getBean(name, clazz);
}
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
}
我在使用Mockito对某些类写单元测试时,发现被测试的类引用了这个工具类,所以我需要对这个工具类进行Mock,因为如果我不Mock他,容器也没有启动,必然会造成空指针(ApplicationContextAware本身就是容器感知接口,容器创建后会触发setApplicationContext方法,这个方法如果没有执行,这个类其他方法肯定是会报空指针的)。但是由于项目中没有引入PowerMock框架,所以无法对静态方法进行Mock,我就退而求其次,对这个静态工具类的静态变量通过反射进行设置,我Mock了一个applicationContext,然后通过反射设置进了这个静态工具类。然后当时我要做的单元测试完美通过。隐患也就此埋下。
要知道打包时会跑所有的单元测试,如果此时已经有@SpringBootTest的单元测试运行过后,这个applicationContext其实就已经指向Spring容器,而所有的单元测试都是在同一个虚拟机运行,对容器的Mock导致了之前创建的Spring容器被我Mock掉了,所以后续的@SpringBootTest的单元测试由于上下文缓存机制,会沿用之前的Spring容器,必然会报空指针,而且每次单元测试运行的顺序不一定,导致了空指针出现的所在单元测试类也不确定。
以上就是单元测试空指针的根本原因。
解决方案:
首先强制隔离上下文理论上是可以的,但是每个单元测试都启动容器效率太低。
其次,引入PowerMock,对SpringContextUtil中静态方法进行Mock,这样可以避免Mock容器。但是需要注意,被Mock的静态方法可能会导致其他错误,还是因为那个问题,所有单元测试是在一个虚拟机里执行的,运行Mock单元测试前后的SpringContextUtil行为是不一致的,Mock之前他是真正的从容器拿Bean,Mock之后他返回的是一个假的Bean。还有就是PowerMock使用起来比Mockito更复杂,需要特别注意类加载器和其他配置。
或者对于引用SpringContextUtil的类进行单元测试继续沿用@SpringBootTest。我用的是这个方法。
版权归原作者 Zoro.Xu 所有, 如有侵权,请联系我们删除。