前端测试 测试开发之路--UI 自动化常用设计模式

孙高飞 · 2018年08月23日 · 最后由 孙高飞 回复于 2021年11月08日 · 15536 次阅读
本帖已被设为精华帖!

前言

接上一次的帖子,今天讲一下我再 UI 自动化中常用的设计模式。 由于网上已经有非常多的文章详细讲解了设计模式的编码实现,所以我今天也就不讲实现细节了。 就是讲我也讲不出什么花来,只是网上的文章基本都是讲解设计模式的本身实现,很少针对某一领域的实际场景去讲具体改怎么用设计模式。 所以今天我只针对一些实际的场景来说一下如何使用这些设计模式来完善 UI 自动化。

工厂

每种语言实现设计模式的方式都不一样,这里仅以 java 为例。 一般来说,工厂模式是为了把创建一个对象的操作都集中在一起管理,其他所有需要用到这个对象的代码都调用工厂类来创建对象。 在 UI 自动化中,工厂类有一个重要的作用就是提供数据的能力。 这里直接上一个例子, 在我的项目中有这样一个场景, 我们的测试都分模块的, 不同的模块有不同的 QA。 测试模型中心模块的 QA 想要测试的话就需要依赖建模 IDE 来产出各种各样的模型。 那根据上一个帖子我讲到的一个设计原则--模块间有数据依赖的时候。每个模块自己负责提供对外接口。 模型 IDE 的 QA 需要提供一个可以生产出各种不同模型的 API 来。 如下:

上面我们我们用一个简单工厂来实现创建各种模型。 其他模块调用此工厂方法满足自己对模型的需求。 如果我们创建模型的类型更复杂的话,可以引入工厂模式和抽象工厂模式。 但实际上我最常用的还是简单工厂,偶尔用工厂模式抽象工厂基本没用过。使用设计模式的时候最容易出现的是过度设计, 把过于复杂的模式硬搬到项目中来。 这是不可取的。

那接下来说一说这个工厂存在的意义吧。 简单工厂算是设计模式里最简单的了, 简单到它几乎不是一个什么模式。 它其实只有一种思想,就是把创建一个东西的操作都统一放到一起,调用方只需要知道我要一个东西,我需要把什么参数传递进来就可以得到这个东西。 比如我们的这个例子里,调用方只需要传递我需要一个什么类型的模型的参数。 至于如何创建这个模型它不需要知道,里面包含了多复杂的 UI 操作它也不需要知道。 这样做的好处是:

  • 代码复用,我们使用工厂的来创建的东西一般都是比较复杂的,需要很多的步骤才能创建。 如果只是随便 new 一下就可以得到的对象也就犯不着专门搞个工厂方法了。 如果任由写 case 的人根据自己的想法去创建这些对象,不仅造成了很多的重复代码。 而且这些碎片的话的代码在后期的维护上也是一个难以接受的事情。
  • 封装变化,我们把创建模型的所有操作都统一放在一起。之后生产模型的操作发生变化,比如需求变动。那我们只需要改动这一处就可以了。而且调用方也完全不感知
  • 解耦,就如开始说的那个设计原则一样, 调用方不感知复杂的模型生产过程, 达到解耦的作用。 在 UI 自动化中,尤其是业务逻辑特别复杂的大型项目中。 多人协作有个比较重要的点在这里提一下。 就是解耦,不要让其他模块的人感知自己模块的任何实现细节。 他们了解的越少,操作的越少, 出错的概率就越小,学习成本就越小。 画地为界,分而治之。 其实我个人觉得整个设计模式就是在解决两件事情:解耦和代码复用

单例

我们有了上面的工厂方法来帮助我们创建模型, 但是这里有个问题。 就是我有太多的 case 依赖这些模型了。 如果每个 case 都执行一遍上面的操作重新创建一个模型的话会有两个问题:

  • UI 操作尤其耗时,尤其是生产模型这种异步操作
  • UI 本就不稳定,这些重复的操作会增加 case 失败的概率

所以我们希望除了有这种创建新模型的能力之外。 还能够复用之前已经产生的模型。 于是我们就有了使用单例模式的需求。 一般提到单例模式,基本上就是懒汉式,饿汉式什么的。 但这两种大概率都是不可用的。 因为首先我们的操作是延迟加载的,只有到了使用的时候才会去 UI 上执行创建模型的操作。 总不能直接在类加载的时候就执行吧。 至于在不加锁的情况下判断一下对象是否为 null 也是不行的。 因为现在的大规模 UI 自动化都是并发执行的。 所以可选的方案就是加锁的双重检查机制以及静态内部类了。 这里主要讲一下静态内部类吧, 双重检查机制估计大家都玩烂了。 如下:

  • 静态内部类不会再 LRModel 的类加载的时候就加载,而是有人调用 getInstance 的时候才会加载。所以保证了延迟加载
  • java 的 classloader 会保证静态内部类的线程安全,所以不用担心并发的问题

上面是静态内部类的实现方式,优点是相较于锁的双重检查方来说实现起来简单,坑少。 比如没有那个经典的指令重排序的问题。 当然缺点也明显, 就是一旦创建对象失败, 那以后就再也没有机会重新创建对象了。 而 UI 自动化又是出了名的不稳定。 所以还是要慎重的。

模板

模板模式在 UI 自动化中比较常用的原因是在产品中有很多的操作路径是复用的。 所以我们可以使用模板模式, 把固定的路径抽象出来,由子类去实现那些独立的逻辑。 比如:

上面是我们的产品引入一份数据的逻辑。 我们的数据引入有很多种类型。 比如从本地引入, 从数据库引入,从 hdfs 引入,从 ftp 上引入等等等等。但是他们的基本步骤都是一样的 (看截图中的注释), 所以模板模式的思想是使用父类来规定到执行操作的步骤, 为了代码复用所以也会实现一些通用的步骤比如所有的引入都得点击某些 button,填写一些都行。 然后留下一些 abstract 的方法给子类实现。 这种父类规定骨架,子类实现细节的方式就是模板方法了。 在这里我们的父类定义好了所有的步骤,但是部分的具体实现细节由子类完成。 这里我们发现子类需要实现两个方法

  • 每个数据引入的关于生成 table 的操作的 setTableConfig
  • 每种数据引入的文件配置方式操作的 setFileConfig

当然模板方法也是可以有较深的结构的。 比如上面说的一些引入方式虽然都属于数据引入,但是也分为两大类, 一个是结构化数据,一个是图片数据。 而且凡是属于结构化数据的引入方式有很多步骤都是相同的。 凡是属于图片数据引入的方式的大部分步骤也是相同的。 所以我们继续有抽象类如下:

上面是结构化数据的抽象类。 他实现了父类 IDataload 的 setTableConfig 方法。 因为所有结构化数据引入的这个页面操作都是一样的。然后才是我们具体的本地文件的数据引入的类。如下。

这个具体的本地文件引入的类实现了方法 setFileConfig。 这样我们就看到了这个模板模式的全貌。

  • 基类 IDataload 负责定义执行步骤,以及个别 UI 操作的实现。 规定子类必须实现 setTableConfig 和 setFileConfig 这两个方法
  • 类 StructureDataLoad 继承基类 IDataload,并实现了 setTableConfig 方法。 因为所有的结构化数据引入在这里使用的是同样的页面
  • 具体的实现类 LocalFileDataLoad 继承 StructureDataLoad,代表着本地数据引入并实现了针对于本地文件引入所独有的页面操作 setFileConfig

所以实际上调用方要做的事情就是这样的

模板模式的优点:

  • 代码复用, UI 上很多操作路径都是重复的,甚至说不同的业务流程操作中的部分页面使用的是相同的页面。 使用模板模式可以很好的整理我们的代码结构,将业务逻辑分类并组织起来,可以服用的代码由上层的父类实现。

模板模式的缺点:

  • 如果类层级结构较多的时候,维护起来有点麻烦。

策略

策略模式也是非常常用的, 甚至很多时候它是其他模式的基础。 它的思想也特别简单。 当初它诞生的原因是为了摆脱大量的 if else, 把每个条件分支做一个策略类。 具体原理我就不介绍了,不知道的可以 google 一下,网上一堆讲设计模式的文章,我也讲不出什么花来,我就讲在 UI 自动化中我们怎么做。 举一个最简单的例子。如下:

在我们的测试中,大量的 case 都需要经过如下的操作步骤:

  • 打开浏览器
  • 登录
  • 进入模型 IDE 页面
  • 创建一个工程
  • 创建一个 DAG
  • 在 DAG 页面上 build 一个 DAG
  • 运行 DAG 并等待运行结束

既然大量的 case 都需要执行上面的操作,那我们当然就希望能做到代码复用,所以就写了一个方法来做这个事情。 但是我们发现这些步骤中有一个操作是无法预测的。 也就是如何 Build 一个 DAG, 我们的产品的 DAG 如下

每个 DAG 中都有不同的算子组合在一起,形成一个图形。并且每个算子有它不同的配置。 要在 UI 上 build 一个 DAG 还是需要很多的操作的。 并且 case 之间要 build 的 DAG 的图形也是不一样的。 有的 case 需要 5 个算子组成一个图形, 有的 case 可能需要 10 个算子组成一个图形。 这些是完全不一样的操作, 也就是说虽然我们想写一个方法来封装上面所有的操作。但是其中构建 DAG 这一步是我们预先控制不了也复用不了的。这怎么办? 所以我们索性把 build DAG 的操作定义为一个接口。 如下:

它只有一个方法,就是 build(), 意思是这个方法要实现 build 一个 DAG 的操作。 但具体 build 一个什么图形什么配置的 DAG, 由子类自己实现。
于是我们有了很多固定图形的 dag 的子类, 他们分别实现不同的固定图形的 build 操作。 如下:

于是我们创建这个可以用来复用的方法:

可以看到这个方法里我们执行了上面说的所有的步骤,比如打开浏览器,登录,跳转页面,创建工程等。 但是在 build 一个 dag 的时候,我们依赖一个 DagBuilder 类型的参数,也就是我们之前的定义的那个接口,当然这个 dagbuilder 使用了建造者模式,这个我们之后会讲。 现在我们在 case 中就可以很愉快的使用很少量的代码完成测试了。 如下:

当然熟悉函数式编程的同学会觉得这玩意非常眼熟。 实际上在 java8 中也完全可以使用 lamda 表达式来完成 DagBuilder 的构造

建造者

这里会涉及到建造者,策略和工厂三种模式的混合使用。可能会比较啰嗦还请大家耐心看完。

建造者模式和工厂模式都是用来创建对象的。 建造者模式适用于一个对象的内部有特别多的属性需要外部来传递的情况。 比如在上一个说策略模式的例子中。我们把 Dagbuilder 作为策略类,在 case 调用的时候动态传递一个具体的 Dagbuilder 类型决定如何 build 一个 DAG. 那么刚才我们也看到了一个 DAG 是非常复杂的,里面有不同的图形, 并且即便图形固定了, 但是里面的算子的类型和配置可能都会变化。 比如,按照上面的一个通用的模型训练的 DAG 图形, 我们就可以用下面的代码来构建。

可以看到上面每个一个 node 的 importToDag 的方法中都会有两个 int 类型的数字参数。 这个意思是将算子拖拽到 DAG 中的哪一个点上。 并且 link 方法用来连接两个算子, build 方法会执行 UI 操作配置当前算子。 通过这样一段代码就可以构建出上面讲策略模式的时候,截图中的那个 DAG 图形。 我们会发现非常多的 case 都会用到这个图形。 比如测试所有的模型训练算法的时候, 都是走这个 DAG 图形的。 所以我们理所应当的会想把这个图形封装起来给很多个 case 使用。 但是虽然 case 使用的图形一样,可是每个算子的配置可能是不一样的, 而且可能在某一个节点上使用的算子都是不一样的,这需要调用方动态的传递。 所以 builder(建造者) 模式是一个包含了很多个零件的对象, 它封装了如何操作这些组件创造出最终调用方想要的东西。但是需要调用方自由的传递这些不同的零件给 builder。 首先我们看看这个 DAG 的 builder 类中定义要使用的零件。

上面是我们构建这个模型训练双输入 DAG 所需要的零件。 可以看到由一个数据节点,一个数据拆分算子,两个特征抽取,一个模型训练,一个模型预测和一个模型预估组成。 而且这些零件都分别有 set 方法让调用方来设置。然后我们就可以在 builder 的 build 方法里使用本节里一开始贴出的代码来动态的构建图形了。

策略模式的混用

这里需要注意一点的是,这些零件大部分都是具体的实体类。 但是有些不是,比如模型训练算法,我们规定的是一个抽象类型。 如下:

为什么这么做呢,因为对于所有要测试模型训练的 case 来说。 图形是固定的, 某些算法也是固定的。 不论测试什么模型训练算法,都是一个数据下面连接数据拆分算法,再下面连接两个特征抽取算法。 也就是说对于模型训练算法来说,这些流程都是固定的,我们实现就知道该拉取什么样的算子,只是配置需要调用方动态传递。 但是测试的时候我们有各种不同的模型训练算法,这些可不是配置不同,而是连算子都变了, 所以我们把模型训练算法抽象成策略类。我不需要知道到底该拉取哪一个算子,让调用方动态传递就好了。 只要它传递的是我规定的策略类型,有规定的方法来设置这个算子就可以了。

工厂模式的混用

根据上面的策略模式和建造者模式的混用我们就可以比较方便的构建 DAG 图形给 case 使用了。 但是还是有一点麻烦。那就是一个 builder 需要传递的零件太多了。这个体验有点不友好。 而且我们发现在大多数的模型训练测试场景下,我们只关心模型训练算法的配置参数,而不是很在意其他算法的配置是什么样子的。 这种场景下让我一个一个的去传递这些零件还是有点麻烦。 或者说在有些情况下,我们是可以动态的推导出其他算子的配置的。 比如我这次要测试的是逻辑回归这个算子。 那么逻辑回归是一种二分类算子,那么其实它只能使用二分类的数据,特征抽取算法中只能使用二分类的 label 处理, 相应的下面也只能连接二分类算子的预测和评估算子。 这些都是我们可以动态推导出来的。 没有必要让使用者一个一个的去传递。所以我们在 builder 外面再包一层工厂, 一个创建 builder 的工厂。如下:

如上图,根据传递的模型训练算子的类型找到预先导入的数据,配置好特征抽取,推导出所有依赖的算子配置后。 配置好这个 builder 并返回给调用方。这样我们通过之前讲的 fastCreateDag 的策略模式的例子。 就可以在 case 中只写入非常少量的代码就完成了测试用例的编写:

@Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("GBDT双输入")
    @Test
    public void doubleInputGBDT(){
        fastCreateDag(Common.randomString("GBDT2Input"), DagBuilderFactory.getDoubleInputBuilder(new GBDTNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);
    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("SVM双输入")
    @Test
    public void doubleInputSVM(){
        fastCreateDag(Common.randomString("SVM2Input"), DagBuilderFactory.getDoubleInputBuilder(new SVMNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }


    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("hetreenet双输入")
    @Test
    public void doubleInputHeTreeNet(){
        fastCreateDag(Common.randomString("he2Input"), DagBuilderFactory.getDoubleInputBuilder(new HETreeNetNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("gbrt双输入")
    @Test
    public void doubleInputGbrt(){
        fastCreateDag(Common.randomString("GBRT2Input"), DagBuilderFactory.getDoubleInputBuilder(new GBRTNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("线性回归双输入")
    @Test
    public void doubleInputLinearRegression(){
        fastCreateDag(Common.randomString("linearR"), DagBuilderFactory.getDoubleInputBuilder(new LinearRegressionNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("线性分型回归双输入")
    @Test
    public void doubleInputLFCRegression(){
        fastCreateDag(Common.randomString("LFCRe"), DagBuilderFactory.getDoubleInputBuilder(new LFCRegressionNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

    @Features(Feature.ModelIde)
    @Stories(Story.LR)
    @Description("逻辑回归多分类双输入")
    @Test
    public void doubleInputLRMultiClass(){
        fastCreateDag(Common.randomString("LRMulti"), DagBuilderFactory.getDoubleInputBuilder(new LRMultiClassNode()))
                .run()
                .waitUntil(DagStatus.SUCCESS, 60*20);

    }

可以看到上面的每一个模型训练的 case 的代码量都非常的少。

结尾

又 12 点多了今天就写到这吧。 以后有时间再继续更新设计模式相关的东西。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 20 条回复 时间 点赞
恒温 将本帖设为了精华贴 08月23日 08:24

高级啊。
感觉一般作自动化测试的想不到这么深的东西。

还是得努力学习!!!

http://zhangfei.link 这里面也做了些总结,互相学习一下!😀

再见理想 回复

张飞...我买过你的材料😂

白纸 回复

希望对你有帮助!

看飞哥写的东西,就两个字,高级

学习了,正对项目代码进行重构,

学习了,确实高级

从零开始学设计模式,感谢飞哥的分享。 准备用 python 实现

debugtalk [该话题已被删除] 中提及了此贴 08月26日 12:11

感谢分享

[该话题已被删除] 中提及了此贴 08月27日 19:06

感谢分享,学习的脚步不停。

用的是 MAC 吧,这字体看着舒服

红客联盟 python 常用 UI 自动化设计模式总结 中提及了此贴 11月15日 15:54
simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 14:44
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 12:08

楼主,我看了2017年03月13日写的 “作为一个面试官我想说点什么”,感觉你说的那几种情况比较切合我自己的情况,顿时感觉自己犹如井底之蛙,太年轻了,然后我想咨询一下你,UI 自动化,接口自动化,持续集成怎样才会比那个高级一点呢,作为一个做了一年测试的人来说,看完你的文章感觉自己好迷茫啊,有一种要被淘汰了的感觉,望解答。

牛逼。
飞哥的这文章我重新看了不止 56 遍了,每遍都多理解一些。
这里有个小问题哈:
您最后一张图,每个模型训练的 case 就 build + run + waitUntil。批跑起来了自动化,是每个 case 都只执行一遍吗?我并没有看到参数化,那是不是说,批跑一次,只会对每个 case 跑一遍(给一个 randomString 的输入,但是没有其他特殊情况的输入了呀)。这些 case 都只能算是冒烟测试吗?
其实不是很明白 UI 自动化到底该怎么执行参数化与 ddt,希望飞哥解惑。。

julystone 回复

我们的 case 大多数都没加入数据驱动, 也就是你说的参数化。 因为现在业内的单元测试框架中对于并发的支持,最细粒度也是方法级别的,同一个方法中的不同参数是串行的,没办法并行。 一般情况下这是没问题的,并发模式不需要细粒度这种程度, 但是我们的 case 比较特殊,都是大数据和机器学习的训练算子, 一个 case 跑上几十分钟都是有可能的。 所以要是用数据驱动的话,那一组参数都是串行跑就没办法接受了。 所以我只能是放弃数据驱动, 拆成多个方法来提升并发能力了。 这个是我们项目的特殊情况。 对于你说的参数化,我们其实是直接用了 testng 的 dataprovider 来写的小工具完成的。

其实如果你用的是 pytest 那么 pytest 的 parameters 也是可以完成你说的参数化的, java 的 junit 和 testng 也都有对应的数据驱动功能, 你直接用就好了。

飞哥,我又有向您请教的问题:
1、我观察到您的 PO 封装完,需要 return 一个 page 回去,我采用的是 return this,而您是 return new page。。请问您是故意不用 this 吗,是否有特殊的原因呢?
2、看了您介绍很多的设计模式,但是我发现前期初搭项目时,为了更快生产、交付给老板,往往不能考虑到这么多设计。您是后续重构的时候,才开始将建造者、策略模式等等引入项目的吗?
3、我观察了您开源的一个微信公众号的 UI 自动化测试框架,您的 driver 是定义了 static 变量,供后续的 findElement 等等操作,但是这样在多线程下安全吗?线程 1 设置了 driver,被线程 2 修改,,请问这里是故意这么设计的吗?

julystone 回复

我现在快 1 年没碰 UI 自动化了, 好多我自己都忘了~~
第一点那个如果是返回的当前 page 类的话, 返回 this 就行, 我 new 了一个 page 可能是返回的别的 page 吧。 或者是我当时忘了返回 this 了。
第二点是我其实真的是一开始写 UI 自动化的时候就设计了这些设计模式了, 是因为我在以前做项目的时候有了经验了, 所以做新的项目就上来就按以前的经验搞了。
第三点我是真的忘了咋回事了。。。我都不知道我开源过一个 UI 自动化框架。。。我甚至不知道我自己有微信公众号。。。。 你确定是我开源的?

孙高飞 回复

哇,你们俩头像太像了,全白色,我混淆了,抱歉抱歉。。。

未免有些生搬硬套

xinxi 最近面试题分享 中提及了此贴 10月21日 22:24
孙高飞 专栏文章:UI 自动化中的分层设计 中提及了此贴 11月07日 20:02

飞总的产品,应该是类似于大数据任务调度平台或者机器学习平台相关的吧,看到这个 dag,以及各种配置就特别亲切。。。我们有个任务调度平台,所有 flink,spark,python,pysparksql,spakrsql 等各种 ETL 任务都在这个平台运行,然后 dag 内容呢,也是类似这种拖拽幕布,里面内容基本都是 sparksql 具体内容,目标表信息,源表信息,sparksql 或者其他算子的高级配置等。。感觉用 UI 自动化这个工程量特别大,我是用接口自动化做的,但是工作量也很大,算子之间 dag 可以随意拖拽组合,算子甚至还能依赖其他算子,算子本身还带自己的任务周期属性,任务周期本身也是个大坑。。就定时任务那些,大周期依赖,小周期依赖等。。运行结果的 assert 也是天坑。。目前跟 dqc 质量平台也不好做交互验证,感觉这种异步方案验证,中间件验证是个比较困难的地方

握个抓~ 当初封装这块 UI 自动化的时候, 着实很费劲

ABEE ycwdaaaa (孙高飞) 在 TesterHome 的发帖整理 中提及了此贴 01月12日 13:47
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册