【SpringBoot】不依赖于 SpringBoot 启动的单元测试获取 Config 并进行配置 Bean 注入的一种实现

问题引入

在写单元测试,特别是测试中间功能层的一些逻辑代码时候,我们可能会通过 @SpringBootTest@MockBean 注解来 Mock 待测试类的注入依赖。但当单元测试类的数量上去以后,这些使用 @MockBean 的单元测试的测试类上下文是会重新加载的,这就会导致整个项目单元测试耗时长。其实有很多的单元测试是完全 Mock 的,它们可以不依赖 Spring 上下文,那我们一般就会用 Mockito 并通过 @ExtendWith(MockitoExtension.class)(Junit5), @InjectMocks, @Mock, @Spy 注解或编码的方式 Mock,这样就不需要启动 Spring 上下文,可以非常快的完成单元测试,举个例子如下:

待测试类

@Slf4j
@Service
public class UserServiceImpl implements UserService {

    @Autowired
    private UserDAO userDAO;
    @Autowired
    private ServerProps serverProps;

    public ServerProps getServerProps() {
        return serverProps;
    }
}

注入依赖的一个配置类

@Data
@NoArgsConstructor
@Component
@ConfigurationProperties("custom.service.server")
public class ServerProps {

    private String normalTest;
    private String easyPropertyPlaceHolderTest;
    private String easyPropertyPlaceHolderDefaultTest;
    private String combinedPropertyPlaceHolderTest;
    private Color enumTest;
    private List<String> listTest;
    private Map<String, String> mapTest;
    private Nested nested;

    @Data
    public static class Nested {

        private String stringData;
    }

    public ServerProps(String normalTest) {
        this.normalTest = normalTest;
    }

}

启动配置文件(包含两个文件,这里粘贴在一起)

# application.yaml
spring:
  profiles:
    include: jenkins
custom:
  service:
    server:
      normal-test: normalMessage
      easy-property-place-holder-test: ${EASY_PROPERTY_PLACE_HOLDER_TEST:defaultValue}
      easy-property-place-holder-default-test: ${EASY_PROPERTY_PLACE_HOLDER_DEFAULT_TEST:defaultValue}
      combined-property-place-holder-test: prefix-${COMBINED_PROPERTY_PLACE_HOLDER_TEST}
      enum-test: RED
      list-test:
        - listValue1
        - listValue2
        - listValue3
      map-test:
        key1: value1
        key2: value2
      nested:
        string-data: hello world!
---
# application-jenkins.yaml
EASY_PROPERTY_PLACE_HOLDER_TEST: easyPlaceHolderValue
#EASY_PROPERTY_PLACE_HOLDER_DEFAULT_TEST:
COMBINED_PROPERTY_PLACE_HOLDER_TEST: combinedPlaceHolderValue

对应单元测测试

@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {

    @InjectMocks
    private UserServiceImpl userService;
    @Mock
    private UserDAO userDAO;
    @Spy
    private ServerProps serverProps = new ServerProps("someValue");

    @Test
    void testGetServerProps() {
        ServerProps props = userService.getServerProps();
        assertEquals("someValue", props.getNormalTest());
    }

}

可以看到上面的单元测试代码,由于没有启动 Spring 上下文,ServerProps 需要我们手动构造。但如果是一个复杂嵌套的配置类而且我们需要保持这个 Mock 和我们写的配置文件一致的情况下,就成了一个难题。本文就这个问题进行探讨和解决。

实现方法

笔者 SpringBoot 版本:2.3.12.RELEASE

我们知道使用 @ConfigurationProperties 这个注解,Spring 会通过反射自动将读取的 properties 使用每个 field 的 setter 方法一个一个注入到对应类中。一开始我期待直接使用 snakeyaml 完成这个功能,但是很可惜 snakeyaml 只能完成一个文件到一个类的映射,不能很简单地实现一个文件各种复杂路径下对多个类的注入,而且通常我们的配置文件还有占位符 ${} 的需求,snakeyaml 是做不到的。

那 Spring 是怎么做的呢?我们可以参考 Spring @ConfigurationProperties 以及 ConfigurationPropertiesBindingPostProcessor 的实现原理,使用其中已经写好的类实现这个功能岂不美哉?研究一番后,废话不多说,直接上代码。

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.bind.PropertySourcesPlaceholdersResolver;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySourcesPropertyResolver;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.util.StringUtils;

/**
 * 不依赖于 Spring 启动的单元测试用于获取 Config 的工具类,可以在纯 Mock 的环境下执行获取到必要配置.
 *
 * @author ******
 * @version 1.0
 * @since 2022/7/2
 */
public class PropertyUtils {

    private static final MutablePropertySources propertySources = new MutablePropertySources();
    private static final PropertySourcesPropertyResolver resolver =
        new PropertySourcesPropertyResolver(propertySources);

    static {
        init();
    }

    private static void init() {
        loadProperties(new ClassPathResource("application.yaml"));
        loadProperties(new ClassPathResource("application-jenkins.yaml"));
    }

    public static void loadProperties(Resource resource) {
        YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
        try {
            loader.load(resource.getFilename(), resource).forEach(propertySources::addLast);
        } catch (Exception e) {
            throw new IllegalArgumentException(e);
        }
    }

    public static String getProperty(String key) {
        return resolver.getProperty(key);
    }

    public static <T> T getProperty(String key, Class<T> targetValueType) {
        return resolver.getProperty(key, targetValueType);
    }

    public static <T> T bindOrCreate(String name, Class<T> bean) {
        Binder binder = new Binder(ConfigurationPropertySources.from(propertySources),
            new PropertySourcesPlaceholdersResolver(propertySources));
        return binder.bindOrCreate(name, bean);
    }

    public static <T> T bindOrCreate(Class<T> configurationPropertiesBean) {
        String propertyPrefix = getConfigurationPropertiesPrefix(configurationPropertiesBean);
        return bindOrCreate(propertyPrefix, configurationPropertiesBean);
    }

    public static String getConfigurationPropertiesPrefix(Class cl) {
        ConfigurationProperties annotation = getConfigurationPropertiesAnnotation(cl);
        String propertyPrefix = annotation.value();
        if (StringUtils.isEmpty(propertyPrefix)) {
            propertyPrefix = annotation.prefix();
        }
        return propertyPrefix;
    }

    public static ConfigurationProperties getConfigurationPropertiesAnnotation(Class cl) {
        ConfigurationProperties annotation = (ConfigurationProperties) cl.getAnnotation(ConfigurationProperties.class);
        if (null == annotation) {
            throw new IllegalArgumentException("Class does not have @ConfigurationProperties annotation: " + cl.getSimpleName());
        }
        return annotation;
    }

    private PropertyUtils() {
        throw new IllegalStateException("UtilityClass: " + this.getClass().getName());
    }
}

使用方法

class PropertyUtilsTest {

    @Test
    void TestConfig() throws Exception {

        ServerProps serverProps = PropertyUtils.bindOrCreate(ServerProps.class);
        System.out.println(serverProps);

        boolean showSql = PropertyUtils.getProperty("spring.jpa.show-sql", Boolean.class);
        assertTrue(showSql);

        String propertyPrefix = PropertyUtils.getConfigurationPropertiesPrefix(ServerProps.class);
        String property = PropertyUtils.getProperty(propertyPrefix + ".easy-property-place-holder-test");
        System.out.println(property);
    }

}

单元测试执行结果

ServerProps(normalTest=normalMessage, easyPropertyPlaceHolderTest=easyPlaceHolderValue, easyPropertyPlaceHolderDefaultTest=defaultValue, combinedPropertyPlaceHolderTest=prefix-combinedPlaceHolderValue, enumTest=RED, listTest=[listValue1, listValue2, listValue3], mapTest={key1=value1, key2=value2}, nested=ServerProps.Nested(stringData=hello world!))
easyPlaceHolderValue

完美!占位符以及占位符默认值都正确注入了,嵌套类,List,Map 都有正确映射

原理简述

那这是怎么做到的呢,我们一个一个来看。首先这个 PropertyUtils 有两个静态成员

private static final MutablePropertySources propertySources = new MutablePropertySources();
private static final PropertySourcesPropertyResolver resolver = new PropertySourcesPropertyResolver(propertySources);

static {
    init();
}

private static void init() {
    loadProperties(new ClassPathResource("application.yaml"));
    loadProperties(new ClassPathResource("application-jenkins.yaml"));
}

public static void loadProperties(Resource resource) {
    YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
    try {
        loader.load(resource.getFilename(), resource).forEach(propertySources::addLast);
    } catch (Exception e) {
        throw new IllegalArgumentException(e);
    }
}

public static String getProperty(String key) {
    return resolver.getProperty(key);
}

public static <T> T getProperty(String key, Class<T> targetValueType) {
    return resolver.getProperty(key, targetValueType);
}

其中 propertySources 用于存储读取到的配置,resolver 用于提供单个的 getProperty 功能该 resolver 能正确解析存在占位符的情况。而实现 Bean 注入使用的是 Binder,Bean 注入的实现如下:

public static <T> T bindOrCreate(String name, Class<T> bean) {
    Binder binder = new Binder(ConfigurationPropertySources.from(propertySources),
        new PropertySourcesPlaceholdersResolver(propertySources));
    return binder.bindOrCreate(name, bean);
}

public static <T> T bindOrCreate(Class<T> configurationPropertiesBean) {
    String propertyPrefix = getConfigurationPropertiesPrefix(configurationPropertiesBean);
    return bindOrCreate(propertyPrefix, configurationPropertiesBean);
}

提供了两个重载方法,一个是自定义输入 config property 前缀,并指定类型完成值注入,另一个是获取类 ConfigurationProperties 注解的值拿到 config property 前缀再调用前面的方法完成注入。

关于 @ConfigurationProperties 的原理可以参考这篇文章:https://blog.csdn.net/qq_36789243/article/details/119429594;下面我们简单看下 Spring 提供的这个 Binder 类最终调用的构造函数,可以看到一共有 6 个参数

public Binder(Iterable<ConfigurationPropertySource> sources, PlaceholdersResolver placeholdersResolver,
		ConversionService conversionService, Consumer<PropertyEditorRegistry> propertyEditorInitializer,
		BindHandler defaultBindHandler, BindConstructorProvider constructorProvider) {
	Assert.notNull(sources, "Sources must not be null");
	this.sources = sources;
	this.placeholdersResolver = (placeholdersResolver != null) ? placeholdersResolver : PlaceholdersResolver.NONE;
	this.conversionService = (conversionService != null) ? conversionService
			: ApplicationConversionService.getSharedInstance();
	this.propertyEditorInitializer = propertyEditorInitializer;
	this.defaultBindHandler = (defaultBindHandler != null) ? defaultBindHandler : BindHandler.DEFAULT;
	if (constructorProvider == null) {
		constructorProvider = BindConstructorProvider.DEFAULT;
	}
	ValueObjectBinder valueObjectBinder = new ValueObjectBinder(constructorProvider);
	JavaBeanBinder javaBeanBinder = JavaBeanBinder.INSTANCE;
	this.dataObjectBinders = Collections.unmodifiableList(Arrays.asList(valueObjectBinder, javaBeanBinder));
}

其中

  • ConfigurationPropertySources: 外部配置文件的属性源
  • PropertySourcesPlaceholdersResolver: 解析属性源中的占位符 ${}
  • ConversionService: 对属性类型进行转换
  • PropertyEditorInitializer: 用来配置 property editors,通常可以用来转换值
  • BindHandler: 接口定义了onStart, onSuccess, onFailure 和 onFinish 方法,这四个方法分别会在执行外部属性绑定时的不同时机会被调用,在属性绑定时用来添加一些额外的处理逻辑,比如在 onSuccess 方法改变最终绑定的属性值或对属性值进行校验,在 onFailure 方法 catch 住相关异常或者返回一个替代的绑定的属性值
  • BindConstructorProvider: 用于构造 ValueObjectBinder,提供不同的策略

我的只提供了最基本的两个 ConfigurationPropertySources,和 PropertySourcesPlaceholdersResolver 目前能满足我们的需求

可以进一步实现的地方

我们都知道,使用 @ConfigurationPropertiesBinding 和实现 org.springframework.core.convert.converter.Converter<S, T> 可以做到让 Spring 将一个值映射到我们自定义的类型,那目前 PropertyUtils 应该是做不到的。但根据传入 Binder 的其他构造参数是可以实现的,可以进一步探究。

另外就是这个代码大家都知道是依赖于 SpringBoot 框架的,无法在没有 Spring 框架下使用。

参考

  1. SpringBoot的配置属性值是如何绑定的? SpringBoot源码(五):https://blog.csdn.net/qq_36789243/article/details/119429594
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值