Spring Boot Junit4单元测试时两个问题的解决(参数化测试,Mock用户信息结构不兼容

最近在做Spring Boot的开发,发现单元测试有两个问题,第一个问题是参数化测试——测试用例数据和测试逻辑分离。第二个问题是Junit自带的@WithMockUser 无法满足系统测试——mock的用户信息结构和实现系统的用户信息结构不一致导致测试失败。

基于这两个问题我做了以下的扩展

参数化测试

之前做Junit4单元测试时把测试用例数据和测试代码都耦合到了一起,导致测试代码看起来很乱难以维护,所以就想用参数化测试来解决测试代码和单元测试用例数据分离问题。

  • 调研Junit4自带的参数化测试发现存在两个问题

1、自带的参数化测试和Spring整合的不好Spring自动注入等强大的功能不能使用(虽然可以通过额外的代码来弥补但是个人感觉不太好)

2、第一个问题虽然可以解决但是这个问题就比较麻烦了,测试时@Transactional不再生效也就是对数据库的修改不能回滚

通过研究Parameterized 的以下代码:

/**
     *这个是Parameterized类的一段代码实现
     * Only called reflectively. Do not use programmatically.
     */
    public Parameterized(Class<?> klass) throws Throwable {
        super(klass, NO_RUNNERS);
        ParametersRunnerFactory runnerFactory = getParametersRunnerFactory(
                klass);
        Parameters parameters = getParametersMethod().getAnnotation(
                Parameters.class);
        runners = Collections.unmodifiableList(createRunnersForParameters(
                allParameters(), parameters.name(), runnerFactory));
    }

    private ParametersRunnerFactory getParametersRunnerFactory(Class<?> klass)
            throws InstantiationException, IllegalAccessException {
        //这里通过注解 UseParametersRunnerFactory来配置自定义的RunnerFactory
        UseParametersRunnerFactory annotation = klass
                .getAnnotation(UseParametersRunnerFactory.class);
        if (annotation == null) {
            //这里取的是默认值 BlockJUnit4ClassRunnerWithParametersFactory
            return DEFAULT_FACTORY;
        } else {
            Class<? extends ParametersRunnerFactory> factoryClass = annotation
                    .value();
            return factoryClass.newInstance();
        }
    }

那么BlockJUnit4ClassRunnerWithParametersFactory中都做了什么呢?

public class BlockJUnit4ClassRunnerWithParametersFactory implements
        ParametersRunnerFactory {//实现了接口ParametersRunnerFactory
    public Runner createRunnerForTestWithParameters(TestWithParameters test)
            throws InitializationError {
        //返回了BlockJUnit4ClassRunnerWithParameters实例
        return new BlockJUnit4ClassRunnerWithParameters(test);
    }
}

可以发现Parameterized 默认使用的是BlockJUnit4ClassRunnerWithParametersFactory来创建runner 并且默认用不用 的是 BlockJUnit4ClassRunnerWithParameters,而BlockJUnit4ClassRunnerWithParameters和SpringJUnit4ClassRunner都是BlockJUnit4ClassRunner子类,并且Parameterized 是可以配置自定义RunnerFactory。

了解到这里之后那么问题变得简单多了,我们只需要自己定义一个RunnerFactory 来实现基于SpringRunner的参数化测试就可以了

定义自己的RunnerFactory

/**
 * 自定义支持Spring的参数化Runner工厂
 * @author Alen
 * @date 2021-01-12 17:49
 * @since 2021-01-12 17:49
 */
public class SpringRunnerWithParametersFactory implements ParametersRunnerFactory {
    @Override
    public Runner createRunnerForTestWithParameters(TestWithParameters test) throws InitializationError {
        
        return new SpringParametersRunner(test);
    }
}

如上代码,我们自己定义的RunnerFactory也是非常简单的只创建了SpringParametersRunner,下面看一下SpringParametersRunner的实现:

public class SpringParametersRunner extends SpringJUnit4ClassRunner { //首先继承自SpringJUnit4ClassRunner

    private final Object[] parameters;//保存传入的参数

    private final String name;//保存测试用例名

    public SpringParametersRunner(TestWithParameters test) throws InitializationError {
        super(test.getTestClass().getJavaClass());
        this.parameters = test.getParameters().toArray();
        this.name = test.getName();
    }
    //这里的createTest实际是将SpringJUnit4ClassRunner 和 BlockJUnit4ClassRunnerWithParameters的该方法给合并在一起了
    @Override
    public Object createTest() throws Exception {
        Object object = null;
        if (fieldsAreAnnotated()) {
            object = createTestUsingFieldInjection();
        } else {
            object = createTestUsingConstructorInjection();
        }
        //这一步很关键如果没有这段代码将不支持自动注入
        getTestContextManager().prepareTestInstance(object);
        return object;
    }

    private Object createTestUsingConstructorInjection() throws Exception {
        return getTestClass().getOnlyConstructor().newInstance(parameters);
    }

    private Object createTestUsingFieldInjection() throws Exception {
        List<FrameworkField> annotatedFieldsByParameter = getAnnotatedFieldsByParameter();
        if (annotatedFieldsByParameter.size() != parameters.length) {
            throw new Exception(
                    "Wrong number of parameters and @Parameter fields."
                            + " @Parameter fields counted: "
                            + annotatedFieldsByParameter.size()
                            + ", available parameters: " + parameters.length
                            + ".");
        }
        Object testClassInstance = getTestClass().getJavaClass().newInstance();
        for (FrameworkField each : annotatedFieldsByParameter) {
            Field field = each.getField();
            Parameterized.Parameter annotation = field.getAnnotation(Parameterized.Parameter.class);
            int index = annotation.value();
            try {
                field.set(testClassInstance, parameters[index]);
            } catch (IllegalArgumentException iare) {
                throw new Exception(getTestClass().getName()
                        + ": Trying to set " + field.getName()
                        + " with the value " + parameters[index]
                        + " that is not the right type ("
                        + parameters[index].getClass().getSimpleName()
                        + " instead of " + field.getType().getSimpleName()
                        + ").", iare);
            }
        }
        return testClassInstance;
    }
    private List<FrameworkField> getAnnotatedFieldsByParameter() {
        return getTestClass().getAnnotatedFields(Parameterized.Parameter.class);
    }

    private boolean fieldsAreAnnotated() {
        return !getAnnotatedFieldsByParameter().isEmpty();
    }

    /**
    *
    *这个方法必须重写否则会报异常
    **/
    @Override
    protected void validateConstructor(List<Throwable> errors) {
        validateOnlyOneConstructor(errors);
        if (fieldsAreAnnotated()) {
            validateZeroArgConstructor(errors);
        }
    }

    @Override
    protected void validateFields(List<Throwable> errors) {
        super.validateFields(errors);
        if (fieldsAreAnnotated()) {
            List<FrameworkField> annotatedFieldsByParameter = getAnnotatedFieldsByParameter();
            int[] usedIndices = new int[annotatedFieldsByParameter.size()];
            for (FrameworkField each : annotatedFieldsByParameter) {
                int index = each.getField().getAnnotation(Parameterized.Parameter.class)
                        .value();
                if (index < 0 || index > annotatedFieldsByParameter.size() - 1) {
                    errors.add(new Exception("Invalid @Parameter value: "
                            + index + ". @Parameter fields counted: "
                            + annotatedFieldsByParameter.size()
                            + ". Please use an index between 0 and "
                            + (annotatedFieldsByParameter.size() - 1) + "."));
                } else {
                    usedIndices[index]++;
                }
            }
            for (int index = 0; index < usedIndices.length; index++) {
                int numberOfUse = usedIndices[index];
                if (numberOfUse == 0) {
                    errors.add(new Exception("@Parameter(" + index
                            + ") is never used."));
                } else if (numberOfUse > 1) {
                    errors.add(new Exception("@Parameter(" + index
                            + ") is used more than once (" + numberOfUse + ")."));
                }
            }
        }
    }

    @Override
    protected String getName() {
        return name;
    }
    @Override
    protected String testName(FrameworkMethod method) {
        return method.getName() + getName();
    }

    @Override
    protected Statement classBlock(RunNotifier notifier) {
        return childrenInvoker(notifier);
    }

    @Override
    protected Annotation[] getRunnerAnnotations() {
        return new Annotation[0];
    }

到此已经完成了自定义部分的编写那么怎么让他起作用呢,请看下面的代码:

@RunWith(Parameterized.class)//这里的RunWith由@RunWith(SpringRunner.class)变成了@RunWith(Parameterized.class)
//这个注解是配置自定义RunnerFactory的必须要配置
@Parameterized.UseParametersRunnerFactory(SpringRunnerWithParametersFactory.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Transactional
@AutoConfigureMockMvc
public class ApplicationTest {//这个是测试类的基础类
    ...
}

测试类的编写

public class DefaultYwbhScqTest extends ApplicationTest
{
    private String param;
    private String excep;

    //构造方法需要是有参的并且参数个数必须和data返回的集合中元素数组个数一致
   public DefaultYwbhScqTest(String param,String excep){
       super();
        this.param = param;
        this.excep = excep;
    }
    @Test
    public void get() {//测试逻辑中需要参数的地方都以变量的形式传入
        DefaultYwbhScq ywbhScq = new DefaultYwbhScq();
        String ywhm = ywbhScq.get(param);
        assertNotNull(ywhm);
        assertEquals(true,ywhm.startsWith(excep));
    }
    //该方法必须是静态方法返回测试需要的数据
    @Parameterized.Parameters
    public static Collection<String[]> data(){
        Collection<String[]> ds = new ArrayList<>();
        ((ArrayList<String[]>) ds).add(new String[]{Constants.DD,"DD"});
        ((ArrayList<String[]>) ds).add(new String[]{Constants.FF,"FF"});
        ((ArrayList<String[]>) ds).add(new String[]{Constants.GG,"GG"});
        ((ArrayList<String[]>) ds).add(new String[]{Constants.HH,"HH"});
        ((ArrayList<String[]>) ds).add(new String[]{"","DE"});
        return ds;
    }

Ok,到这里一个即支持参数化测试双兼容Spring Test的测试框架就完成了!

Mock用户信息

在进行单元测试时有一些接口是被保护的这些接口Junit提供了注解 WithMockUser 但是这个注解只能配置用户名和角色等信息,但是在我们实际使用的时候可能还需要一些读取当前用户的额外信息这样就会导致正常业务是没有问题的,但是做单元测试的时候由于用户信息同导致测试失败,通过研究WithMockUeser的实现可以发现如下代码:

@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
//关键所在,通过该工厂来创建并填充测试时的用户信息
@WithSecurityContext(factory = WithMockUserSecurityContextFactory.class)
public @interface WithMockUser {
	
	String value() default "user";

	...省略部分
}

WithMockUserSecurityContextFactory的实现如下:

final class WithMockUserSecurityContextFactory implements
		WithSecurityContextFactory<WithMockUser> {

	public SecurityContext createSecurityContext(WithMockUser withUser) {
		String username = StringUtils.hasLength(withUser.username()) ? withUser
				.username() : withUser.value();
		if (username == null) {
			throw new IllegalArgumentException(withUser
					+ " cannot have null username on both username and value properites");
		}

		List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
		for (String authority : withUser.authorities()) {
			grantedAuthorities.add(new SimpleGrantedAuthority(authority));
		}

		if (grantedAuthorities.isEmpty()) {
			for (String role : withUser.roles()) {
				if (role.startsWith("ROLE_")) {
					throw new IllegalArgumentException("roles cannot start with ROLE_ Got "
							+ role);
				}
				grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
			}
		} else if (!(withUser.roles().length == 1 && "USER".equals(withUser.roles()[0]))) {
			throw new IllegalStateException("You cannot define roles attribute "+ Arrays.asList(withUser.roles())+" with authorities attribute "+ Arrays.asList(withUser.authorities()));
		}

		User principal = new User(username, withUser.password(), true, true, true, true,
				grantedAuthorities);
		Authentication authentication = new UsernamePasswordAuthenticationToken(
				principal, principal.getPassword(), principal.getAuthorities());
		SecurityContext context = SecurityContextHolder.createEmptyContext();
		context.setAuthentication(authentication);
		return context;
	}
}

可以看到工厂里只有一个方法 createSecurityContext() 该方法返回了一个SecurityContext其中包含了我们需要的用户信息,

到这里那么我们是不是可以换成自己的WithMockUser呢,当然是可以的

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockMyUser ContextFactory.class)
@WithMockUser
public @interface WithMockMyUser {

    /**
     * 用户名
     */
    String username() default "admin";
}

WithMockCloudhisContextFactory 的实现如下:

public class WithMockMyUserContextFactory implements WithSecurityContextFactory<WithMockMyUser> {

    @Override
    public SecurityContext createSecurityContext(WithMockMyUser annotation) {
        //通过Spring容器获取容器中的UserDetailsService
        UserDetailsService userDetailsService = SpringUtils.getBeanByType(UserDetailsService.class);
        //加载用户信息
        UserDetails userDetails =  userDetailsService.loadUserByUsername(annotation.username());
        //将用户信息保存到 UsernamePasswordAuthenticationToken
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        //将用户信息保存到SecurityContext 并返回到测试环境中
        context.setAuthentication(authentication);
        return context;
    }
}

上面的加载用户的过程和程序里的是一样的,再也不用担心结构不一样了!

到此以上的两个问题都已经解决!