为什么重新写一个

刚才 iOS 版的也测试稳定了,顺便分享艰辛的喜悦。
两周前写过一篇文章关于深度优先的探索性遍历工具的设计和实现,写明了原因。前期调研,这方面的资料不多,感谢 testerhome 的创始人 seveniruby,已经用 scala 开发完成品,还有两篇相关的文章,给我提供了很好的借鉴,没有他前面走过的路,我必定要走很多弯路,也不可能这么快就能做出来。所以,很荣幸能进入 testerhome 这个社区,开拓了的视野。在做的过程中踩了不少坑,以每周少睡 20 多个小时的代价寻求解决方案,今天主要是说一下设计思路和技术细节。

关于开源

我相信有很多代码能力在我之上的,开源的目的是为了提供参考方案和解决思路,目前只是基础版,还请提出宝贵建议,源码传送门

关于 Appium 参数

方案一、把支持的参数都封装成 bean 读取,后来想了想觉得不够灵活,如果以后有弃用或增加的参数还要改代码;
方案二、以键值对的方式存入字典对象,遍历读取,这样就不必担心扩展了。

关于窗口唯一性

方案一、用 md5,容易受到各种干扰,特别是窗口位置的细微变化。
方案二、用 activity,普通窗口没问题,带 TAB 页的窗口就无法区分,切换 TAB 页内容不同就不要区分。
方案三、用 xpath 表达式寻求能鉴别窗口唯一性的特征元素,例如:Title。有的 app 对于控件的命名很不规范,导致很难发现共性。
方案四、也就是现在的方案,从窗口头部开始往下取几个节点(可配置),去掉坐标干扰后再生成 md5,这个方案对于窗口鉴别还算稳定。

<!--窗口鉴定策略,默认取前8个节点生成md5-->
<identify-default>8</identify-default>
<!--Tab窗口用selected区别,可能要多选几个节点到达-->
<identify-special>
    <define>专题,直播,要闻,选股>>24</define>
    <define>简单理财,投资,保险,贷款,信用卡>>30</define>
</identify-special>

关于引导流

有两种场景需要用到:
1.欢迎界面划屏至登录成功
2.app 可能由多个项目组完成,每个项目组负责一个模块,需要引导到指定模块
考虑再三,用简单的关键字驱动方式实现引导

<iosGuideFlow>
    <!--滑动类型设置-->
    <step>slide>>2</step>
    <!--点击类型设置-->
    <step>click>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAScrollView[1]/UIAButton[1]</step>
    <step>click>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAButton[3]</step>
    <!--输入类型设置-->
    <step>input>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAScrollView[1]/UIATextField[1]|13012345678</step>
    <step>click>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAScrollView[1]/UIAButton[1]</step>
    <step>input>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAScrollView[1]/UIASecureTextField[1]|123456</step>
    <step>click>>xpath:://UIAApplication[1]/UIAWindow[1]/UIAScrollView[1]/UIAButton[1]</step>
    <step>input>>xpath:://UIAApplication[1]/UIAWindow[3]/UIATextField[1]|8888</step>
    <step>click>>xpath:://UIAApplication[1]/UIAWindow[3]/UIAButton[2]</step>
    <!--手势密码设置-->
    <step>gesture>>xpath:://UIAButton[@name='blue circle']</step>
</iosGuideFlow>

关于提升遍历效率

这个踩的坑最多,也耗费了我大部分时间。
1.获取可操作元素
a) 预加载:获取窗口后,用类似于这种方法取到当前窗口的所有可执行元素。对于 Android 遍历影响不大,对于 IOS 遍历简直就是灾难,慢的无法接受;

List<WebElement> elementList = ((AppiumDriver) driver).findElements(By.xpath("//*[@clickable='true' and @enabled='true']"));

b) 懒加载:获取窗口后,生成所有可执行元素的 xpath,遍历出栈后再根据 xpath 获取 WebElement。

2.减少无效操作
a) 引入控件白名单机制
名单内的类才会被允许去遍历。UIAStaticText 对 iOS 来说,误杀 1% 可以减少很多不必要的遍历

<!--控件白名单-->
<click>
    <class>UIAImage</class>
    <class>UIAButton</class>
    <class>UIASwitch</class>
    <class>UIATableCell</class>
    <!--<class>UIAStaticText</class>-->
    <class>UIAPickerWheel</class>
    <class>UIACollectionCell</class>
</click>
<input>
    <class>UIATextField</class>
    <class>UIASearchBar</class>
    <class>UIASecureTextField</class>
</input>

b) 增加过滤策略
有的窗口会隐藏重复的入口,遍历工具能遍历到,这些过滤掉也能提升效率
以 iOS 为例,设计了 4 种过滤策略,不设置为默认不过滤

<!--1:id+clazz+name 2:id+clazz+name+label 3:id+clazz+name+label+value 4:no filter-->
<filter>1</filter>

c) runtime 黑名单机制,出栈的节点任务会加入黑名单列表,确保不再重复执行

关于提升稳定性

a) 黑名单机制
有些窗口想屏蔽掉,最好的办法是从根源解决,就是屏蔽入口,以 iOS 为例,支持 text,name,label 等属性的模糊匹配,xpath 精确匹配

<!--黑名单-->
<blackList>
    <item>相机</item>
    <item>相册</item>
    <item>照片</item>
    <item>退出登录</item>
    <item>拍摄名片</item>
    <item>credit card camera</item>
    <item>如您已完成添加,请重新登录</item>
    <item>重新登录</item>
    <item>//UIAApplication[1]/UIAWindow[1]/UIATableView[1]/UIAButton[1]</item>
    <item>//UIAApplication[1]/UIAWindow[1]/UIATableView[1]/UIAButton[2]</item>
    <item>//UIAApplication[1]/UIAWindow[1]/UIATableView[1]/UIAButton[3]</item>
</blackList>

b) 触发器机制
满足 xx 条件,触发 xx 操作。根据遍历中遇到的情况,支持返回、延时、点击、手势密码盘解锁。

<trigger>
     <item>分享到>>back</item>
     <item>我的权益>>delay->8</item>
     <item>温馨提示|立即开通|取消>>//UIAApplication[1]/UIAWindow[4]/UIAButton[1]</item>
     <item>更多解锁方式>>gesture->//UIAButton[@name='blue circle']</item>
 </trigger>

关于日志

日志分为系统日志和 APP 日志,系统日志又分为 info 和 error,error 的格式是固定的,便于分析出报告。
关于 APP 日志的设计
最初,如何打印日志,程序内部写死的,灵活性不高;
最终,如何打印日志,用什么工具、用哪些参数,支持用户定制化。

<log>
    <ios>idevicesyslog -u #udid#</ios>
    <android>adb -s #udid# logcat -v time -b events *:I | grep pingan</android>
</log>

关于截屏和录像

a) 截屏功能
Android 没有用 appium 的方法,直接调用命令比基于请求的效率更高;
iOS 使用 idevicescreenshot,比 appium 截图提高 10 倍效率。
b) 录像功能
把操作过程转化为流媒体,提升用户体验

关于系统参数

可定制化 Appium Server 的 port 和 host
可配置遍历深度和遍历时间
可配置截图和视频的目录

<global>
    <!--Appium port-->
    <port>5757</port>
    <!--Appium host-->
    <host>127.0.0.1</host>
    <!--测试类型 1.android 2.ios 3.web-->
    <mode>1</mode>
    <!--遍历深度-->
    <depth>0</depth>
    <!--截图和视频的目录-->
    <screenshot>/Users/mac/Desktop/png</screenshot>
    <!--遍历时间 分-->
    <duration>0</duration>
    <!--延时等待 秒-->
    <interval>3</interval>
    <!--超时 秒-->
    <timeout>30</timeout>
</global>

关于算法

之前测试用的非完整版,水平有限勿喷,主要分为节点任务执行前、执行后的处理,后进先出的结构

/**
   * 基于dfs的探索性遍历
   *
   * @param taskStack
   * @param depth
   */
  public void dfsSearch(Stack<UiNode> taskStack, int depth) {
      int thisDepth, newTaskCount, repeatCount = 0;
      UiNode thisNode;
      WebElement element;
      Stack<UiNode> children;
      Stack<UiNode> existsTaskStack;
      List<String> triggerList;
      List<UiNode> blackList = new ArrayList<>();
      String xpath, thisWindow, preWindow, thisPageSource, doBackWin;

      // 首次获取窗口内容和窗口标识
      thisPageSource = driver.getPageSource();
      thisWindow = parser.getCurrentWindowID(thisPageSource);
      preWindow = thisWindow;
      triggerList = config.getTriggerList();

      while (!taskStack.isEmpty()) {
          if (repeatCount > config.getAllowSameWinTimes())
              break;
          thisNode = taskStack.pop();
          blackList.add(thisNode);

          try {
              // 截图
              screenShot();

              // 触发器预处理
              if (triggerProcessing(driver.getPageSource(), triggerList)) {
                  screenShot();
                  thisPageSource = driver.getPageSource();
                  thisWindow = parser.getCurrentWindowID(thisPageSource);
              }

              if (!thisWindow.equals(thisNode.getWindowID())) {
                  // 在任务栈中搜索当前窗口,如果存在,则获取该窗口下所有任务节点
                  existsTaskStack = searchByWindowID(thisWindow, taskStack);

                  // 如果当前窗口已存在任务栈中
                  if (null != existsTaskStack) {
                      repeatCount = 0;
                      resetTaskStack(taskStack, existsTaskStack);
                  } else {
                      Log.logInfo(thisNode.getWindowID() + " >> " + thisWindow + ", 窗口迁移至新窗口......");
                      if (preWindow.equals(thisWindow)) {
                          repeatCount = repeatCount + 1;
                      } else {
                          repeatCount = 0;
                      }
                      preWindow = thisWindow;
                      thisDepth = thisNode.getDepth();

                      // 遍历深度控制,0表示未限制
                      if (depth == 0 || thisDepth < depth) {
                          thisPageSource = driver.getPageSource();
                          thisWindow = parser.getCurrentWindowID(thisPageSource);
                          children = getTaskStack(Type.XML, thisPageSource, thisNode.getDepth() + 1);

                          // 是否获取到新窗口节点任务
                          newTaskCount = null != children ? children.size() : 0;

                          Log.logInfo(newTaskCount + "个新任务准备入栈......");
                          children = removeNodes(blackList, children);
                          children = filterNodes(taskStack, children);
                          children = updateTaskStack(children, thisNode);

                          // 如果有新的节点任务生成,把当前节点任务先压栈,新生成的节点任务出栈
                          if (null != children && children.size() > 0) {
                              Log.logInfo(children.size() + "个新任务允许入栈......");
                              taskStack.push(thisNode);
                              taskStack.addAll(children);

                              // 更新任务栈后,新任务出栈
                              thisNode = taskStack.pop();
                              blackList.add(thisNode);
                          }

                          if (newTaskCount == 0 || children.size() == 0 && needBack(thisWindow, taskStack)) {
                              doBack();
                          }

                      } else {
                          doBack();
                      }
                  }
              }

              // 每次迭代懒加载元素对象
              xpath = thisNode.getId().split("-")[3];
              element = driver.findElement(By.xpath(xpath));
              if (thisNode.getAction().equals(Action.CLICK)) {
                  element.click();
              } else if (thisNode.getAction().equals(Action.INPUT)) {
              // todo something
              }

              // 任务执行后获取窗口内容和窗口标识
              TimeUnit.SECONDS.sleep(config.getInterval());
              thisPageSource = driver.getPageSource();
              thisWindow = parser.getCurrentWindowID(thisPageSource);

              // 如果同窗口的任务栈已处理完毕,并且还停留在该窗口,返回至上一个窗口
              if (thisNode.getWindowID().equals(thisWindow) && needBack(thisNode.getWindowID(), taskStack)) {
                  // 获取返回后的窗口内容和窗口标识
                  doBackWin = doBack();
                  thisPageSource = null == doBackWin ? thisPageSource : doBackWin;
                  thisWindow = parser.getCurrentWindowID(thisPageSource);
              }
          } catch (NoSuchElementException e) {
              continue;
          } catch (org.openqa.selenium.ElementNotVisibleException e) {
              continue;
          } catch (org.openqa.selenium.NoSuchSessionException e) {
              break;
          } catch (org.openqa.selenium.SessionNotCreatedException e) {
              break;
          } catch (org.openqa.selenium.NotFoundException e) {
              continue;
          } catch (Exception e) {
              continue;
          }
      }
  }

转载请注明作者和出处


↙↙↙阅读原文可查看相关链接,并与作者交流