TA的每日心情 | 无聊 3 天前 |
---|
签到天数: 1050 天 连续签到: 1 天 [LV.10]测试总司令
|
问题背景
考虑一个问题,如果已知一个对象的状态转移图,需要根据这个状态转移图来写单元测试,你会怎么做?
以呼叫为例子,假设状态转移图如下所示:
根据这个状态转移图,你可能可以很快就得出下面的测试用例样例:
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的其中一个特性,这里面还有很多有用的特性可以参考。部分程序员可能会认为写测试很重要,但是写测试的效率却不高,又没有好好挖掘测试框架中哪些特性可以提高自己的效率,也没有认识到测试代码也是工程中的代码,也需要有良好的设计和编码规范来支撑,不然就是在测试代码里面埋下了越来越多的焦油坑,最终变得不可维护。这篇文章的封装思路、使用的方法可能都还不够优雅,但这不重要,重要的是希望可以通过这篇文章抛砖引玉,让大家意识到对自己使用的测试工具越了解,在测试过程中遇到的问题总会有合适的解决方案,正所谓工欲善其事必先利其器。
|
|