51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

查看: 532|回复: 0
打印 上一主题 下一主题

[资料] 如何快速构造测试的前置条件?

[复制链接]
  • TA的每日心情
    无聊
    4 天前
  • 签到天数: 941 天

    连续签到: 3 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2023-5-10 13:25:35 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
    问题背景
      考虑一个问题,如果已知一个对象的状态转移图,需要根据这个状态转移图来写单元测试,你会怎么做?
      以呼叫为例子,假设状态转移图如下所示:

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

    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-5-4 19:26 , Processed in 0.066187 second(s), 22 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表