国际化分布式WEB UI自动化测试平台搭建

国际化分布式WEB UI自动化平台

一 背景

随着互联网行业的快速发展,web端业务及流程更加繁琐,迭代更加快速。传统的手工测试以无法满足市场需求。为降低回归的人力成本,快速迭代,自动化测试是必然趋势。此文主要介绍webUI自动化平台。

二 特色功能

  • 平台化,实现数据分离
  • 国际化,支持模拟海外用户,多语言校验
  • 分布式并发集群
  • 跨环境分支的用例管理
  • 支持Online & H5页面UI自动化
  • 自定义定位表达式
  • 动态生成的期望值
  • 前/后置动作,支持用例/sql根据业务需求组合使用
  • 接口&html Mock
  • 图片比对
  • 接口数据比对
  • selenium IDE录制脚本转为平台用例
  • 公共参数
  • 数据加密解密等

三 系统架构设计&框架选型

国际化分布式WEB UI自动化测试平台搭建

自动化测试框架

市场上主流的测试框架一般有三种:数据驱动、页面对象、行为驱动

  • 数据驱动
    相同的测试脚本使用不同的测试数据来执行,测试数据和测试行为完全分离,这样的设计模式称为数据驱动。
  • 页面对象模式
    页面对象模式将测试代码和被测试页面的页面元素极其操作方法进行分离,以此来降低页面元素变化对测试代码的影响。
  • 行为驱动
    行为驱动模式,可以将用户故事或者需求和测试用例之间建立一一对应的映射关系,保证开发和测试的目标与范围严格地和需求保持一致,可以更好的让需求方、开发方和测试人员用唯一的需求进行相关开发工作,防止对需求理解的不一致。

本文中,我们同时使用了数据驱动和页面对象模式。将测试数据与测试行为分离,并且将页面元素作为测试数据一部分,与测试方法,测试代码分离。以实现当需求发生变更时,最大程度的降低用例维护成本。

实现方法:

  1. 将被测页面单独管理,包括页面url、页面类型等
  2. 将被测元素与页面关联
  3. 每个用例由多个步骤组成,每个步骤又由被测元素、操作等组成
    国际化分布式WEB UI自动化测试平台搭建
    国际化分布式WEB UI自动化测试平台搭建
    国际化分布式WEB UI自动化测试平台搭建
    国际化分布式WEB UI自动化测试平台搭建
    国际化分布式WEB UI自动化测试平台搭建

Selenium

为什么选择selenium?
selenium已有十几年的历史,目前已经到了selenium3。这是一个非常成熟的工具,它的用户量很大,开发团队也一直在维护,社区十分活跃,基本上大家发邮件问的问题都会有人回答。并且其他人问的问题,它也会抄送给你,你可以从中看到其他人用了什么功能,遇到了什么问题,又是如何解决的。这些都可以帮助我们更加熟悉selenium这个工具。

再者,它支持了大部分的主流浏览器,Firefox, ie, chrome, safari, opera等都在它的支持范围内。
而且,它的配套工具非常完善。selenium IDE可以通过用户行为录制和回放用例,并且可以转成各种语言的测试脚本。selenium Grid可以在多个测试环境以并发的方式执行测试脚本,实现测试脚本的并发执行,大大缩短了用例的执行时间。
而且,selenium支持几乎所有的编程语言,如java、javaScript、Ruby、PHP、Python、Perl、C#。

关于selenium的原理,网上有大量的文档。此处不再赘述。

WebDriver

WebDriver实际上是selenium2,它和selenium1相比,selenium1是通过js注入去控制页面元素,而WebDriver是利用浏览器的内部接口来操作页面元素。它会先找到这个元素的坐标位置,再在这个坐标点去执行相应的操作。

Selenium Grid

Selenium Grid是Selenium套件的一部分,它专门用于并行运行多个测试用例在不同的浏览器、操作系统和机器上。
国际化分布式WEB UI自动化测试平台搭建

四 功能介绍

平台化

为实现以下目标
业务通用性。即不同的业务都可以通过我们的平台录入数据,实现ui自动化。
数据可视化。使测试人员可以通过平台录入查看数据,降低沟通成本。

前端框架:飞冰(https://ice.work/)
为什么选择飞冰?
首先飞冰是由阿里开发的前端开发框架,该框架有详细的文档,和以及丰富的组件,大大降低了我们的学习成本和开发成本。
其次飞冰的内核是react, react作为一个非常成熟的框架,用户量非常大,网上也有非常多的资料,可以让我们在遇到问题的时候有料可查。

国际化

为什么要做国际化?
由于我们部门的业务主要针对国际市场,大部分用户来源于海外,故对我们来说,能够模仿海外用户,进行多语言测试,是必不可少的。
如何实现呢?
我们在初期调研的时候,发现有三种方式可以模拟海外用户。

  1. 修改测试机的hosts文件,将ip改为海外ip
  2. 使用部署在海外的机器作为测试机,去执行用例
  3. 使用vpn
    经过实践,我们发现服务器不允许修改hosts文件,故排除方案1。方案2最贴近真实用户,但需要在海外部署测试环境机器,成本高,且不太现实。故最终我们选择方案3。
    实现方式:
    1 将vpn封装成浏览器插件
    新增文件manifest.json, 写入以下内容
{
  "version": "1.0.0",
  "manifest_version": 2,
  "name": "Chrome Proxy",
  "permissions": [
    "proxy",
    "tabs",
    "unlimitedStorage",
    "storage",
    "<all_urls>",
    "webRequest",
    "webRequestBlocking"
  ],
  "background": {
    "scripts": ["background.js"]
  },
  "minimum_chrome_version":"22.0.0"
}
  1. 新增文件background.js, 写入以下内容
var sessionId = Math.ceil(Math.random()*10000000);

var config = {
    mode: "fixed_servers",
    rules: {
        singleProxy: {
            scheme: "https",
            host: "vpn地址",
            port: parseInt(22225)
        }
    }
};

chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});

function callbackFn(details) {
    return {
        authCredentials: {
            username: "vpn账号",
            password: "vpn账号密码"
        }
    };
}

chrome.webRequest.onAuthRequired.addListener(
    callbackFn,
    {urls: ["<all_urls>"]},
    ['blocking']
);
  1. 安装插件
    将以上两个文件打包成xx.zip文件(文件名可自定义),放在resources/vpnPlugins目录下。在启动浏览器前,写入Resource中
ChromeOptions options = new ChromeOptions();
String proxyPath = BrowserStartUtil.class.getClassLoader().getResource("/vpnPlugins/xx.zip").getPath();
            options.addExtensions(new File(proxyPath));
driver = new RemoteWebDriver(new URL(String.format("http://%s/wd/hub", hubIp)), options);
            driver.get(url);
// do-something()
driver.quit()

此时,浏览器启动后便会自动安装插件
假设我们想要模拟法国的用户,又想模拟美国的用户,该怎么办呢?
因为每个国家的vpn账号不同,故我们将每个vpn账号封装成不同的zip。当需要模拟法国用户时,则调用法国的zip文件;当需要模拟美国用户时,则调用美国的zip文件。

if (countryCode.contains("au")) {
            String proxyPath = BrowserStartUtil.class.getClassLoader().getResource(IBU_AU_RESIDENTIAL_PROXY_PATH).getPath();
            options.addExtensions(new File(proxyPath));
            return options;
        }

        if (countryCode.contains("us")) {
            String proxyPath = BrowserStartUtil.class.getClassLoader().getResource(IBU_US_RESIDENTIAL_PROXY_PATH).getPath();
            options.addExtensions(new File(proxyPath));
            return options;
        }

        if (countryCode.contains("sg")) {
            String proxyPath = BrowserStartUtil.class.getClassLoader().getResource(IBU_SG_RESIDENTIAL_PROXY_PATH).getPath();
            options.addExtensions(new File(proxyPath));
            return options;
        }

        if (countryCode.contains("fr")) {
            String proxyPath = BrowserStartUtil.class.getClassLoader().getResource(IBU_FR_RESIDENTIAL_PROXY_PATH).getPath();
            options.addExtensions(new File(proxyPath));
            return options;
        }

        if (countryCode.contains("de")) {
            String proxyPath = BrowserStartUtil.class.getClassLoader().getResource(IBU_DE_RESIDENTIAL_PROXY_PATH).getPath();
            options.addExtensions(new File(proxyPath));
            return options;
        }

        return options;

到这里,如果你只是在自己本机执行的话,已经足够了。
但如果要发到服务器,你会发现,插件没安装成功。这是为什么呢?
首先因为linux服务器一般是虚拟镜像,所以无法真实的打开浏览器执行case,所以我们只能使用无头模式去执行case。
而插件安装必须要打开真实的浏览器,这又该怎么办呢?请参考【并发执行】,使用远程有真实浏览器的设备去执行用例。

分布式并发执行

本文分布式并发环境,主要使用selenium Grid集群。
如国际化所述,为模拟海外用户,势必要使用有真实环境的浏览器。并且,当测试用例较多时,执行时间对于自动化能否应用于实际迭代流程起决定性的作用。基于以上原因,我们决定搭建分布式并发环境。

  1. 建立hub节点
    编写sh脚本,使服务部署时,可通过执行脚本将linux服务器设置为hub节点
java -jar /opt/tomcat/bin/selenium-server-standalone-3.141.59.jar -role hub -timeout 300000 -browserTimeout 900000
  1. 准备node机
    ● 下载jdk, 配置java环境变量
    ● 下载selenium-server-standalone-3.141.59.jar(可自行百度搜索)
    ● 根据被测浏览器版本,下载对应的驱动
    ● 打开dos窗口,执行以下命令(根据自己的实际情况修改driver驱动的位置,xxx为hub机的ip)
java -jar selenium-server-standalone-3.141.59.jar -role node -hub http://xxx:4444/grid/register -port 80  -Dwebdriver.chrome.driver="D:\node\chromedriver75.0.3770.90_win32.exe"

当出现如下提示( The node is registered to the hub and ready to use),则表示节点注册成功
国际化分布式WEB UI自动化测试平台搭建
此时,使用RemoteWebDriver即可打开node机的浏览器进行测试(将hubIp替换为自己hub机所在的ip)

WebDriver driver = new RemoteWebDriver(new URL(String.format("http://%s/wd/hub", hubIp)), options);

跨环境分支的用例管理

在实际应用中,我们遇到了一个问题。一个业务通常会有多个开发并行参与。每个开发在提测后也只会测试自己的分支。当这些子分支测完后,又会把代码合并到一起进行集成测试。
那么如何把多个子分支的自动化用例合并到一起,又在代码发布后,同步到生产,作为生产日常巡检用例呢?跨环境分支的用例管理则实现了这样的功能。国际化分布式WEB UI自动化测试平台搭建

合并

方向:仅支持将测试环境非release分支用例合并到release分支
原理:
1.将待合并用例和主分支用例对比,若待合并用例的父用例和主分支用例的父用例相同则合并,否则复制待合并用例,关联到主分支所在计划
2.合并:对比上述两个用例的步骤,如果步骤完全相同,则合并成功;如果待合并用例的步骤数大于主分支用例步骤数,且前n个步骤和主分支完全相同,则复制多出来的复制关联到主分支用例上;如果主分支用例步骤数大于待合并用例,则合并失败
3.合并失败响应数据:失败的计划,用例及步骤

同步

国际化分布式WEB UI自动化测试平台搭建
原理(c3->c4):
1.如果c3 发生变化,且c3的父用例不等于c4或c3的父用例不存在或为空,则复制c3生成新用例c5
2.如果c3发生变化,且c3的父用例为c4,则删除c4,复制c3生成新的c4

H5 UI自动化

h5的自动化,采用浏览器实验室自带的h5模式。如chrome浏览器

ChromeOptions options = new ChromeOptions();
Map<String, String> mobile = new HashMap<>();
mobile.put("deviceName", "iphone X");
options.setExperimentalOption("mobileEmulation", mobile);

自定义定位表达式

如下图,当我们需要定位第一个可点击的日期时,我们会发现最强大的xpath也略有些力所不及。
国际化分布式WEB UI自动化测试平台搭建
这种情况下,我们增加了自定义定位表达式:filter(grapMth,grapValue,logicalExp,index)。该表达式可以根据grapMth、grapValue获取元素列表A,再根据指定的表达式进行过滤得到元素列表B,最后获取B中第Index个元素。
其中:

  1. ocatorType: 元素的抓取方式,支持id/name/className/tagName/linkText/partialLinkText/cssSelector,
  2. locatorValue: 元素的定位表达式,支持其中一级元素使用[]代表该级所有元素,如://div/ul[]/li ,则表示获取div下所有ul标签的li标签元素
  3. logicalExp: 逻辑表达式,支持传入多个逻辑表达式
  4. index:索引
    那么传入 xpath, //[@id=‘searchFormWrapper’]/ul/li[2]/div[3]/div/div[1]/div[2]/div/ul[]/li, [class != is-disable and text != null and class != null],2 便可以定位到2021/6/11

动态的预期值

大部分时候,我们页面中的值,包括url都是动态变化的,比如可能会传入动态的日期,但这些数据又有一定的规律。那么我们就可以根据这个规律动态的去获取或生成期望值。
这里大部分的动态预期值都是我们自己封装的接口。如随机生成指定格式的日期、指定长度的字符串、邮箱地址等。以下为部分动态期望生成方法。
国际化分布式WEB UI自动化测试平台搭建
为了支持有数学计算逻辑的期望值,我们也引入了强大的表达式引擎Aviator
Aviator是一个高性能、轻量级的基于java实现的表达式引擎,它动态地将String类型的表达式编译成Java ByteCode并交给JVM执行。
Aviator支持所有的关系运算符和算术运算符,不支持位运算,同时支持表达式的优先级,优先级跟Java的运算符一样,并且支持通过括号来强制优先级。

<dependency>
      <groupId>com.googlecode.aviator</groupId>
      <artifactId>aviator</artifactId>
      <version>5.1.3</version>
    </dependency>
AviatorEvaluator.execute(destValue)

前/后置动作

如果你要测的每个case都有共同部分,那么就可以将这个共同部分提出来作为组件case。其他的case需要使用的时候,仅将该case配置为前置动作即可。这样的流程可以降低公共组件的case维护成本,当这部分业务发生变化时,将只需要维护组件case即可,而不需要去每个case中修改这部分用例。
但这里比较复杂的是,你的前置动作,可以也有前置动作。前置动作可以是case,也可以是sql。那么case/sql的执行节点就十分重要。

本文采用了队列+树的结构,利用树和队列的特别对case的前后置动作进行排序。然后按照顺序执行。
如:
已有case a(Ca), Ca有前置动作1,2,3,其中1为sql, 2为case(C2), C2由前置动作4, 5, 4&5均为sql, 3为Sql Queue存储前置动作(1,2,3) Tree根节点Ca

当Queue不为空时,进入循环
第一次循环:国际化分布式WEB UI自动化测试平台搭建
难点:

  1. parentNode如何获取,如何判断此次循环的parentNode应该是什么?
    answer1: 将有前置动作的父节点记录在map中,key使用caseId, value为每个父节点的前置动作长度,该长度为每个父节点的循环次数
    answer2: Queue中数据类型为CasePrework,走到每一个前置动作时,根据CasePrework.caseId查找父节点
  2. 本地调试Case没有id, 即使赋予临时id,也不能根据CasePrework.caseId获取父节点,那么本地调试case如何获取当前用例的父节点呢?
    answer: 根据Queue初始长度,在该长度内循环不更换parentNode
  3. 最终生成的树如下:国际化分布式WEB UI自动化测试平台搭建
    排序算法:使用树的后根排序法,生成前置动作的执行顺序list, 遍历list执行前置动作
    destList: [1, 4, 5, 2, 3, Ca]

数据Mock

我们在UI自动化中遇到最频繁的变化,其实并不是来源于页面样式交互等的变更,而是数据变化导致case不可用。为了解决这一问题,我们也接入了机票同学开发的接口Mock服务。可通过数据Mock,使页面静态化,通过图片比对,对整个页面进行校验。

页面Mock

当测试开发并行时,即开发coding时,测试即进行编写用例。这种情况下,测试属于盲写,无法调试,等开发提测,联调的成本就会比较高。
因此,我们提供了页面Mock功能,测试可以根据与开发同学的约定的属性id搭建简易版页面或让开发同学提供简易版html,进行case调试。降低后期联调成本。

页面数据校验

在UI自动化测试中,除了页面功能,样式、交互、兼容性等校验外,必不可少的还有数据准确性的校验。为校验数据,我们也自定义了操作方法去获取接口响应数据,并通过jsonPath去获取指定属性的值与页面数据比对。

接口数据比对

本平台因为提供了跨环境的用例管理功能,即该平台上存在测试环境用例&生产环境用例

国际化分布式WEB UI自动化测试平台搭建
又因为种种原因,该平台暂未发布到生产环境。而我们测试环境和生产环境的业务数据又是隔离。当执行测试环境用例时,需要拿测试环境的接口数据和页面对比;当执行生产用例时又需要调用生产接口拿数据进行对比。
因此,我们必须提供一个服务,使用户既可以调用测试环境的接口,又可以调用生产环境的接口。为此我们提供了跳板机服务。
跳板机服务主要实现了根据用户提供的服务id(appId)& 环境 去查找该服务在对应环境的ip地址,再根据ip+port去调用对应的接口获取接口响应数据。
but接口响应数据一般为json对象,用户不可能拿整个对象对比,所以我们也在这里引入了JsonPath.
使用户可以*的获取到所需要的数据

    <dependency>
      <groupId>com.jayway.jsonpath</groupId>
      <artifactId>json-path</artifactId>
      <version>2.4.0</version>
    </dependency>

图片比对

目前市场上比较流行的算法有: 欧氏距离、余弦距离、汉明距离

欧式距离

欧氏距离是最常见的距离度量(用于衡量个体在空间上存在的距离,距离越远说明个体间的差异越大),衡量的是n维空间中两个点之间的实际距离。

余弦距离

余弦相似度用向量空间中两个向量夹角的余弦值作为衡量两个个体间差异的大小。两个向量越相似夹角越小,余弦值越接近1。相比距离度量,余弦相似度更加注重两个向量在方向上的差异,而非距离或长度上。

汉明距离

汉明距离表示两个(相同长度)字对应位不同的数量,我们以d(x,y)表示两个字x,y之间的汉明距离。对两个字符串进行异或运算,并统计结果为1的个数,那么这个数就是汉明距离。
向量相似度越高,对应的汉明距离越小。如10001001和10010001有2位不同。

其中汉明距离具有效率、计算速度快两大优点,且精度也不低。对于大套件的自动化用例,测试效率是不可忽略的一部分。故本文选择了汉明距离法。

public ResponseResult getSimilarity(MultipartFile[] files) {
    try {
        int[] pixels1 = getImgFinger(files[0]);
        int[] pixels2 = getImgFinger(files[1]);
        int hammingDistance = getHammingDistance(pixels1, pixels2);
        BigDecimal similarity = new BigDecimal(calSimilarity(hammingDistance)*100).setScale(2, BigDecimal.ROUND_HALF_UP);
        return new ResponseResult(ResponseResult.SUCCESS_CODE, String.valueOf(similarity));
    } catch (IOException e) {
        logger.error("imgCompare exception", e);
        return new ResponseResult(ResponseResult.EXCEPTION_CODE,e.getMessage());
    }
}

 private int getHammingDistance(int[] a, int[] b) {
     int sum = 0;
     for (int i = 0; i < a.length; i++) {
         sum += a[i] == b[i] ? 0 : 1;
     }
     return sum;
 }

private int[] getImgFinger(MultipartFile file) throws IOException {
    Image image = ImageIO.read(file.getInputStream());
    image = toGrayscale(image);
    image = scale(image);
    int[] pixels1 = getPixels(image);
    int averageColor = getAverageOfPixelArray(pixels1);
    pixels1 = getPixelDeviateWeightsArray(pixels1, averageColor);
    return pixels1;
}

private BufferedImage convertToBufferedFrom(Image srcImage) {
    BufferedImage bufferedImage = new BufferedImage(srcImage.getWidth(null),
                                                    srcImage.getHeight(null), BufferedImage.TYPE_INT_ARGB);
    Graphics2D g = bufferedImage.createGraphics();
    g.drawImage(srcImage, null, null);
    g.dispose();
    return bufferedImage;
}

private BufferedImage toGrayscale(Image image) {
    BufferedImage sourceBuffered = convertToBufferedFrom(image);
    ColorSpace cs = ColorSpace.getInstance(ColorSpace.CS_GRAY);
    ColorConvertOp op = new ColorConvertOp(cs, null);
    BufferedImage grayBuffered = op.filter(sourceBuffered, null);
    return grayBuffered;
}

private Image scale(Image image) {
    image = image.getScaledInstance(32, 32, Image.SCALE_SMOOTH);
    return image;
}

private int[] getPixels(Image image) {
    int width = image.getWidth(null);
    int height = image.getHeight(null);
    int[] pixels = convertToBufferedFrom(image).getRGB(0, 0, width, height,
                                                       null, 0, width);
    return pixels;
}

private int getAverageOfPixelArray(int[] pixels) {
    Color color;
    long sumRed = 0;
    for (int i = 0; i < pixels.length; i++) {
        color = new Color(pixels[i], true);
        sumRed += color.getRed();
    }
    int averageRed = (int) (sumRed / pixels.length);
    return averageRed;
}

private int[] getPixelDeviateWeightsArray(int[] pixels,final int averageColor) {
    Color color;
    int[] dest = new int[pixels.length];
    for (int i = 0; i < pixels.length; i++) {
        color = new Color(pixels[i], true);
        dest[i] = color.getRed() - averageColor > 0 ? 1 : 0;
    }
    return dest;
}

五 疑难杂症 & 常见问题

  • 元素定位不到
    自定义定位表达式,或通过js定位。
  1. 用例运行结束后,残余driver和浏览器进程,如何处理?
    设置定时任务每天定时清理残留的进程
  2. 用例执行时间过长,导致用例未执行完成,连接已断开,收不到执行结果
    可通过设置timeOut解决该问题,但timeOut不可设置过长
  3. 大批量用例执行一段时间后,机器卡顿,内存不足,怎么办?
    在清理残留进程的定时任务增加命令清理磁盘缓存
  4. 服务运行一段时间后,selenium grid集群节点机无法连接到主节点
    •查看主服务是否正常运行
    •查看主节点selenium服务是否正常运行(ps -ef | grep java )
    •查看启动的端口是否正常 (netstat-ant | grep4444)
    此时可看到大量close_wait进程,且请求方和接收方均为当前机器。
    这说明用例在执行时由主服务和selenium建立的连接在用例执行结束后未能正常结束。
    检查建立主节点的命令是否设置了timeOut和browserTimeOut,将这两个值调小或去掉即可
  5. 分布式服务如何实现上传文件功能?
    •编写用例时将自己的文件上传到文件服务器
    •执行用例时再下载到执行用例的node机上。
    •服务运行在linux服务器上,如何将文件下载到客户端?打个jar包放在node机上,在执行用例的时候调用客户端服务将文件下载到本地,再去上传即可
  6. 如何保证服务稳定性
    •建立合适重试机制
    •定时任务清理系统缓存
    •定时任务清理残余进程
    •定时重启机器(可选)
    如网络不稳定导致的运行失败问题,这种情况下重试是没有用的。那么可以先把这种原因导致的失败用例做个标记。待所有用例执行结束或检测到网络恢复正常的时候再去重试

六 总结

业务方面:已应用于多个业务场景,日常巡检用例1000+。通过自动化也扫描出了不少bug,像页面标签错误等手工测试发现不了的bug也可通过自动检测出来。迭代回归人力降低了5人力,迭代周期也从两周一发布,缩短到一周一发布。
个人方面:本平台为个人独立设计完成,整个过程中遇到了很多问题,刚开始的时候也很忐忑,毕竟是第一个项目。幸而坚持了下来,学到了很多新的技术,加深了对很多知识的理解。想到精巧的设计思路,解决了新的难点问题时的满足,使我不断前进。希望看到此处的你,也能一起努力前行。

上一篇:Mac电脑上设置环境变量


下一篇:mac npm安装全局依赖,失效找不到commad is not found