lsekfe 发表于 2023-5-10 13:25:35

如何快速构造测试的前置条件?

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

页: [1]
查看完整版本: 如何快速构造测试的前置条件?