单元测试技巧-如何快速构造测试的前置条件

374 阅读10分钟

大家在工作中或多或少都会写一些测试代码,在测试某些场景之前总是需要构造一些前置条件才能测试到自己想要测的那个函数。但是不知道你有没有想过,利用好已有的测试工具,可以让自己更快速地构造出这些前置条件?

文章内容遵循:

  1. 框架:SpringBoot 2.4.4 , Spring Data JPA
  2. 使用的需求样例参考:业务需求分析详解-如何精确识别并发问题

问题背景

考虑一个问题,如果已知一个对象的状态转移图,需要根据这个状态转移图来写单元测试,你会怎么做?

以呼叫为例子,假设状态转移图如下所示:

image.png

根据这个状态转移图,你可能可以很快就得出下面的测试用例样例:

case1: 被呼方空闲时,呼叫成功
	given: 呼叫方A,被呼方B,且被呼方空闲
	when: A 呼叫 B
	then: 呼叫成功,呼叫状态为CALLING,且呼叫方是A,被呼方都是B

case2: 呼叫后,被呼方拒绝接听
	given: 呼叫方A,被呼方B,A呼叫B
	when: B 拒绝接听
	then: 呼叫状态为忙碌,原因为被呼方拒绝接听

case3: ...

如果要测试 case1,测试代码也非常简单:


@Autowired  
private TestUserFactory userFactory;  
  
@Autowired  
private CallDomainService callDomainService;

@Test  
@DisplayName("呼叫成功")  
void should_call_success_when_callee_is_free() {  
	
	// given: 呼叫方A,被呼方B,且被呼方空闲
	// userB是新创建出来的,所以必定空闲
	User userA = userFactory.createUser();  
	User userB = userFactory.createUser(); 

	// when: A 呼叫 B
	Call call = callDomainService.call(userA, userB, CallValues.builder().build());  
	
	// then: 呼叫成功,呼叫状态为CALLING,且呼叫方是A,被呼方都是B
	assertThat(call.getStatus()).isEqualTo(CallStatus.CALLING);  
	assertThat(call.getFromId()).isEqualTo(userA.getUserId());  
	assertThat(call.getTargetId()).isEqualTo(userB.getUserId());  
}

这么写没什么问题。但是当实现case2的时候,问题就来了。要测试呼叫拒接的场景,测试的拒绝接听的功能。根据状态转移图,呼叫的状态要先达到CALLING 状态才可以测试。那么,可能你会写出这样的代码:


@Autowired  
private TestUserFactory userFactory;  
  
@Autowired  
private CallDomainService callDomainService;

@Test  
@DisplayName("呼叫后,被呼方拒绝接听")  
void should_call_success_when_callee_is_free() {  
	
	// given: 呼叫方A,被呼方B,A呼叫B
	User userA = userFactory.createUser();  
	User userB = userFactory.createUser(); 
	Call call = callDomainService.call(userA, userB, CallValues.builder().build());  

	// when: B 拒绝接听	
	callDomainService.rejectCall(call.getId(), userB, CallBusyReason.MANUAL);
	
	// then: 呼叫状态为忙碌,原因为被呼方拒绝接听
	assertThat(call.getStatus()).isEqualTo(CallStatus.BUSY);  
	assertThat(call.getBusyReason()).isEqualTo(CallBusyReason.MANUAL); 
}

接下来,你可能还需要测试 取消呼叫、连接失败、连接成功等场景,然后发现,要测试这些状态下的实体行为,就需要让这个实体先到达在这之前的某个状态才能测试。接着你就发现,这三行相同的代码充斥在各个测试用例中:

	User userA = userFactory.createUser();  
	User userB = userFactory.createUser(); 
	Call call = callDomainService.call(userA, userB, CallValues.builder().build());  

你可能会想着把它抽出来,作为一个公共方法:

private Call preparingCallingCall() {
	User userA = userFactory.createUser();  
	User userB = userFactory.createUser(); 
	Call call = callDomainService.call(userA, userB, CallValues.builder().build());  
	return call;
}

同理,要达到其他状态,也出现了很多次,于是,又将达到其他状态的方法进行了封装:

private Call preparingBusyCall() {
	Call call = preparingCallingCall();
	callDomainService.rejectCall(call.getId(), call.getTargetId(), CallBusyReason.MANUAL);
	return call;
}

可这么封装之后,并没有减少重复的代码,只是把重复代码挪到了一个具体的方法里面而已;而且还有一个问题,就是需要用到user信息来做判断的时候,又不得不把user的创建挪到封装的方法外,这样封装的内容就所剩无几了:


private Call preparingCallingCall(User caller, User callee, CallValues callValues) {
	Call call = callDomainService.call(caller, callee, callValues);  
	return call;
}


// 使用封装好的方法
@Test  
@DisplayName("呼叫成功")  
void should_call_success_when_callee_is_free() {  
	
	// given: 呼叫方A,被呼方B,且被呼方空闲
	// userB是新创建出来的,所以必定空闲
	User userA = userFactory.createUser();  
	User userB = userFactory.createUser(); 

	// when: A 呼叫 B
	Call call = preparingCallingCall(userA, userB, CallValues.builder().build());  
	
	// then: 呼叫成功,呼叫状态为CALLING,且呼叫方是A,被呼方都是B
	assertThat(call.getStatus()).isEqualTo(CallStatus.CALLING);  
	assertThat(call.getFromId()).isEqualTo(userA.getUserId());  
	assertThat(call.getTargetId()).isEqualTo(userB.getUserId());  
}

同样地,为了构造出一个CALLING 状态的呼叫实体,在封装其他状态的时候,又不得不把这些参数传进去:


private Call preparingBusyCall(User caller, User callee, CallValues callValues) {
	Call call = preparingCallingCall(call, callee, callValues);
	callDomainService.rejectCall(call.getId(), call.getTargetId(), CallBusyReason.MANUAL);
	return call;
}

这样明显达不到想要的效果,本来是想把复杂的构造逻辑封装进去的,现在却因为要用到相关的逻辑,不得不把这些挪出来,封装变得形同虚设了。

要解决这个问题,有没有什么办法呢?这就要用到JUnit5中的一个特性: ParameterResolver。

什么是ParameterResolver

ParameterResolver 提供了一种用于动态注入测试方法参数的机制。ParameterResolver 可以让我们在测试调用之前为测试方法提供必要的参数值,例如模拟对象、外部资源、自定义参数类型等,并可以通过注解和 SPI 扩展机制来实现各种不同的参数传递方式。

其接口定义如下:


public interface ParameterResolver {
    boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException;
    Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException;
}


  • supportsParameter 方法用于确定当前的 ParameterResolver 实例是否支持特定类型的参数,如果返回 true,则表示此 ParameterResolver 实例可以为该参数类型提供值。
  • resolveParameter 方法则用于根据给定的参数上下文和扩展上下文来解析参数,并返回该参数的值。

更多的介绍可以参考JUnit5的文档中关于 ParameterResolver的介绍。

ParameterResolver如何解决上述问题?

上面提到的问题矛盾点在于,我既想要把复杂的状态构造过程封装起来,避免每个测试方法都要写一遍参数构造,但是在这个过程中需要依赖的参数却又不得不从外部传入,导致封装的函数形同虚设。利用上ParameterResolver之后,我们可以完美解决这个问题。

还是以上面的代码作为例子,我们需要用到User对象,需要事先创建,那么我们可以先定义一个UserParameterResolver,代码如下所示:


public interface StoredBaseExtension extends Extension {  
  
	default ExtensionContext.Store getStore(ExtensionContext context) {  
		return context.getStore(ExtensionContext.Namespace.create(  
		context.getRequiredTestClass(),  
		context.getRequiredTestMethod()));  
	}  
	  
	default ApplicationContext getApplicationContext(ExtensionContext context) {  
		return SpringExtension.getApplicationContext(context);  
	}  
}


@AllArgsConstructor
@Getter
public class UserParameterResolver implements ParameterResolver, StoredBaseExtension {

    @Override  
	public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {  
		return parameterContext.getParameter().getType().equals(User.class);  
	}  
	  
	@Override  
	public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) throws ParameterResolutionException {  
		TestUserFactory userFactory = getApplicationContext(context).getBean(TestUserFactory.class);  
		User user = userFactory.createUser();  
		return user;  
	}
}

在这个UserParameterResolver 的例子中,我们通过SpringExtension获取到了ApplicationContext,然后得到了UserFactory这个Bean。通过这个Bean构造出了User对象。

如何使用这个Resovler呢?ParameterResolver本质上是JUnit5里的Extension,所以只需要用上@ExtendedWith 注解即可。代码如下:


@Test  
@DisplayName("呼叫成功")  
@ExtendWith(UserParameterResolver.class)  
void should_call_success_when_callee_is_free(User userA, User userB) {  
	Call call = callDomainService.call(userA, userB, CallValues.builder().build());  
	assertThat(call.getStatus()).isEqualTo(CallStatus.CALLING);  
	assertThat(call.getFromId()).isEqualTo(userA.getUserId());  
	assertThat(call.getTargetId()).isEqualTo(userB.getUserId());  
}

好像这么改造了之后,也没啥变化,参数该从外部传进来的还是从外面传进来。不过,既然User能用ParameterResolver构造出来,那我们所需要的特定状态的Call对象是否也可以通过ParameterResolver构造出来呢?于是,就有了这样的代码:



@Target({ElementType.PARAMETER})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface RequireCallRole {  
  
	CallRoleTypeEnum value() default CallRoleTypeEnum.CALLER;
}



@AllArgsConstructor  
@Getter  
public class UserParameterResolver implements ParameterResolver, StoredBaseExtension {  
  
  
	@Override  
	public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {  
		return parameterContext.getParameter().getType().equals(User.class);  
	}  
	  
	@Override  
	public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) throws ParameterResolutionException {  
		TestUserFactory userFactory = getApplicationContext(context).getBean(TestUserFactory.class);  
		User user = userFactory.createUser();  
		RequireCallRole userRole = parameterContext.getParameter().getAnnotation(RequireCallRole.class);  
		if (userRole != null) {  
			getStore(context).put(userRole.value(), user);  
		} else {  
			getStore(context).put(CallRoleTypeEnum.CALLER, user);  
		}  
		return user;  
	}  
}


public class CallParameterResolver implements ParameterResolver, StoredBaseExtension {  
  
	@Override  
	public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {  
		return parameterContext.getParameter().getType().equals(Call.class);  
	}  
  
	@Override  
	public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {  
		CallDomainPrepare prepare = getApplicationContext(extensionContext).getBean(CallDomainPrepare.class);  
		User caller = getStore(extensionContext).get(CallRoleTypeEnum.CALLER, User.class);  
		User callee = getStore(extensionContext).get(CallRoleTypeEnum.CALLEE, User.class);  

		TestUserFactory userFactory = getApplicationContext(extensionContext).getBean(TestUserFactory.class);  
		if (caller == null) {  
			caller = userFactory.createUser();  
		}  
		  
		if (callee == null) {  
			callee = userFactory.createUser();  
		}
		Call call = prepare.prepareCallingCall(caller, callee, CallValues.builder().build());  
		return call;  
	}  
}



这里还需要对UserParameterResolver做一些改造,需要用@RequireCallRole注解对呼叫双方做一下分类;另外,Extension之间支持用Store来传递参数,在这段代码中也用到了这个机制,因为Call对象的构造需要用到User对象,所以在UserParameterResolver构造User的时候,将User存进Store中,然后在CallParameterResolver构造Call对象的时候,按照既定的协议,从Store中取出User,从而实现了对Call对象的构造。

在测试中使用的代码如下:


    @Test
    @DisplayName("呼叫成功")
    @ExtendWith({UserParameterResolver.class, CallParameterResolver.class})
    void should_call_success_when_callee_is_free(User userA,
                                                 @RequireCallRole(CallRoleTypeEnum.CALLEE) User userB,
                                                 Call call) {
        assertThat(call.getStatus()).isEqualTo(CallStatus.CALLING);
        assertThat(call.getFromId()).isEqualTo(userA.getUserId());
        assertThat(call.getTargetId()).isEqualTo(userB.getUserId());
    }

但这样还不完善,我们想要的效果是,可以快速地构造出一个指定状态的Call对象,而现在的写法只能 构造出CALLING状态的Call对象。于是,我们可以仿照User对象的做法,也增加一个注解来指定:


@Target({ElementType.PARAMETER})  
@Retention(RetentionPolicy.RUNTIME)  
public @interface RequireCallState {  
  
	CallStatus value() default CallStatus.CALLING;  
}



对应地,CallParameterResolver也要做出相应地改造:

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException {
        CallDomainPrepare prepare = getApplicationContext(extensionContext).getBean(CallDomainPrepare.class);
        User caller = getStore(extensionContext).get(CallRoleTypeEnum.CALLER, User.class);
        User callee = getStore(extensionContext).get(CallRoleTypeEnum.CALLEE, User.class);
        TestUserFactory userFactory = getApplicationContext(extensionContext).getBean(TestUserFactory.class);
        if (caller == null) {
            caller = userFactory.createUser();
        }

        if (callee == null) {
            callee = userFactory.createUser();
        }

        RequireCallState annotation = parameterContext.getParameter().getAnnotation(RequireCallState.class);
        if (annotation == null) {
            Call call = prepare.prepareCallingCall(caller, callee, CallValues.builder().build());
            return prepareByState(extensionContext, CallStatus.CALLING, caller, callee);
        }
        
        return prepareByState(extensionContext, annotation.value(), caller, callee);
    }

    private Call prepareByState(ExtensionContext context, CallStatus callStatus, User caller, User callee) {
        CallDomainPrepare prepare = getApplicationContext(context).getBean(CallDomainPrepare.class);
        switch (callStatus) {
            case BUSY:
                return prepare.prepareBusyCall(caller, callee, CallValues.builder().build());
            case CONNECTED:
                // ...
            case CALLING:
            default:
                return prepare.prepareCallingCall(caller, callee, CallValues.builder().build());
        }
    }

原本的拒绝接听的测试代码,就会写成:


@Test  
@DisplayName("呼叫成功")  
@ExtendWith({UserParameterResolver.class, CallParameterResolver.class})  
void should_call_success_when_callee_is_free(User userA, @RequireCallRole(CallRoleTypeEnum.CALLEE) User userB,  @RequireCallState(CallStatus.BUSY) Call call) {  
  
	// when: B 拒绝接听  
	callDomainService.rejectCall(call.getId(), userB, CallBusyReason.MANUAL);  
	  
	// then: 呼叫状态为忙碌,原因为被呼方拒绝接听  
	Call savedCall = callRepository.findById(call.getId());
	assertThat(savedCall.getStatus()).isEqualTo(CallStatus.BUSY);  
	assertThat(savedCall.getBusyReason()).isEqualTo(CallBusyReason.MANUAL);  
}

这么做了之后,把准备不同状态的Call对象的复杂度都放在了CallParameterResolver 中处理,写测试时就不用过多关注如何构造一个特定状态的Call对象了;同时,在参数构造器中写了一遍构造特定状态的Call对象之后,后续每个测试都不需要再写重复的代码,只需要引入这样一个Extension就可以了。

对应上述说的问题,需要依赖的参数本身就是要从外部指定的,这一点不可避免。而使用ParameterResolver实际上是解决了参数构造的封装问题,不需要每个测试方法都对依赖的参数写一遍构造过程,而把这些构造过程封装到Extension中,而且对于构造过程来说也不需要写多次,有效地复用了参数构造的代码。通过这种方法,编写测试用例代码的人不需要再关心如何构造特定的对象,直接在参数上指定状态即可,使用起来非常快捷方便。

如果构造的特定状态的实体不符合用例要求怎么办?因为是不符合特定某个用例的要求,所以只需要在测试用例上自己做修改即可,对既有的默认值不受影响。设计方案如果可以满足百分之八十的场景,就基本上是可行的了,剩下不满足的部分,要么特殊情况特殊处理,要么就用最原始的方法重新构造,这样也是可以接受的。

总结

ParamResolver 只是JUnit5的其中一个特性,这里面还有很多有用的特性可以参考。部分程序员可能会认为写测试很重要,但是写测试的效率却不高,又没有好好挖掘测试框架中哪些特性可以提高自己的效率,也没有认识到测试代码也是工程中的代码,也需要有良好的设计和编码规范来支撑,不然就是在测试代码里面埋下了越来越多的焦油坑,最终变得不可维护。这篇文章的封装思路、使用的方法可能都还不够优雅,但这不重要,重要的是希望可以通过这篇文章抛砖引玉,让大家意识到对自己使用的测试工具越了解,在测试过程中遇到的问题总会有合适的解决方案,正所谓工欲善其事必先利其器。