京东质量社区 浅谈仓储 UI 自动化之路 | 京东物流技术团队

京东云开发者 · 2023年11月16日 · 最后由 回复于 2023年11月17日 · 4586 次阅读

1 分层测试

分层测试:就是不同的时间段,不同的团队或团队使用不同的测试用例对产品不同的关注点进行测试。一个系统/产品我们最先看到的是 UI 层,也就是外观或者说整体,这些是最上层,最上层依赖下面的服务层,也就是接口或者模块,最底层就是单元,这个单元是函数或者方法。按照这三层选择不同时间段,不同团队不同测试用例进行的测试就是分层测试。

通读上述概念,先对分层测试有个大体的印象,下面结合测试金字塔模型来具体说明:

1.1 单元 (Unit ) 测试

单元测试是针对代码单元(通常是类/方法)的测试,单元测试的价值在于能提供最快的反馈,在开发过程中就可以对逻辑单元进行验证。

1.2 接口(Service/服务/API)测试

接口测试是针对业务接口进行的测试,主要测试内部接口功能实现是否完整。如主要业务流是否能走通,异常处理是否正确,数据为空时校验等等。接口测试的主要价值在于接口定义相对稳定,不像界面或底层代码会经常发生变化,所以接口测试比较容易编写,用例的维护成本也相对较低。在接口层面准备测试的性价比相对较高。

1.3 集成(UI)测试

集成测试从用户的角度验证产品功能的正确性,测的是端到端的流程,并且加入用户场景和数据,验证整个业务流。集成测试的业务价值最高,它验证的是一个完整的流程,但因为需要验证完整流程,在环境部署、准备用例及实施等方面成本较高,实施起来并不容易。

1.4 分层测试总结

Google 的自动化分层投入占比是:单元测试(Unit):占比 70%;接口测试(Service):占比 20%;集成测试(UI):占比 10%。

测试过程中需要尽量提早介入测试,针对重点模块功能进行摸底测试,根据金字塔模型 越往上,越接近 QA、业务和最终用户,发现问题后解决问题的成本会越高。采用分层测试存在以下优势:

  • 尽量测试前移,在开发前期发现问题解决问题,开发成本会迅速下降。
  • 不同时间段关注不同,分重点测试,层层防护。
  • 容易定位问题,测的哪一层,出现问题,就是哪一层的问题,很明确。
  • 分层测试在用例设计和执行测试的时候,更具有针对性,思维更加清晰,不容易遗漏。
  • 加强测试对代码实现的理解,可以更好的进行测试技能拓展。

最后,在具体实施时,层级如何划分要设计好,设计好对应层级的测试用例,且用例执行时要持续追踪,前面的工作要为后面的工作起到实际作用。

2 UI 自动化

UI 自动化测试,即通过模拟手动操作用户 UI 界面的方式,以代码方式实现自动操作和验证的一种自动化测试手段。从测试渠道上可以分为 WebUI 测试和 App 测试,WebUI 包括 PC 和 H5两个方向。

2.1 UI 自动化作用

  • 重复性的功能测试及验证;
  • 避免疲惫操作时的人为测试遗漏;
  • 通过 UI 自动化操作获取其他测试数据的能力。

2.2 UI 自动化优点

  • 用例编写简单,降低上手门槛;
  • 节省人工测试成本,提高功能测试、回归测试的测试效率;
  • 保障软件质量的一种手段和方式。

2.3 UI 自动化缺点

  • UI 控件的频繁变更导致控件定位;
  • 用例脚本的维护成本较高,投入和产出比例低;
  • 元素定位的不稳定导致用例的效率和稳定性差。

3 常见的 UI 自动化框架分析

常用的 WebUI 自动化测试工具主要有强大且免费开源的 Selenium 家族,也有体验良好收费很贵的 QTP 工具,还有新兴崛起的 Cypress,以及其他工具。

3.1 Cypress 和 Selenium 用户量对比

Cypress 和 Selenium 的下载量对比分析:Selenium 相对稳定,Cypress 下载量在21年度正式超过了 Selenium,并且分差不断拉大。

3.2 Cypress 和 Selenium 实现架构对比

Selenium 系统的架构:由代码通过 JSON Wire 网络协议和 driver 进行通信,由 driver 和真实浏览器交互操作,最终返回操作结果到代码。

Cypress 系统的架构:使用 webpack 将测试代码中的所有模块 bundle 到一个 js 文件中。测试代码和被测程序在同一个浏览器的不同 iframe 中,无需通过网络访问。

3.3 Cypress 和 Selenium 环境框架对比

Cypress 和 Selenium 自动化环境搭建对比:Cypress 只需要安装的方式即可使用,Selenium 作为库包形式提供,需要自己选择对应的框架,断言以及额外的依赖。

3.4 Cypress 和 Selenium 环境对比汇总

4 如何做好 UI 自动化

4.1 我不想写 UI 自动化的 N 个理由

  • 自动化编写脚本成本高,selenium 需要自己搭框架,cypress 只能用 js 写,且都需要对前端有所涉略才能写好。
  • 自动化手动编写脚本案例需要很长时间,编写场景数过少,发现不了多少问题。
  • 录制、编写时候页面有变化时候每个用例都需要修改,需要专人维护,维护成本非常高。
  • 经常出现脚本问题出现的错误,页面加载异常等等,写出来的的用例非常不稳定。

4.2 我不得不写 UI 自动化的 N 个理由

  • 需要回归测试的场景太多,手工重复频繁执行太耗时。
  • 需要进行线上环境测试,无线上接口操作权限。
  • UI 自动化测试更贴近用户实际使用场景,通过接口测试无法完全保障质量。

4.3 如何做好 UI 自动化—降低代码维护成本

针对不想写 UI 自动化又不得不做的时候,我们需要选择一个好的框架来管理我们的自动化用例。不管是 selenium 还是 cypress,我们都需要将我们的自动化脚本代码尽量的复用。如果我们只是想测试流程数据的时候,需要将我们控件和操作进行封装。下面我们将主要用仓储 Cypress 自动化来举例子。

4.3.1 基础控件进行封装

一般系统的基础封装控件是有一定风格的,譬如下图中我们如何快速寻找到订单号的输入框呢?显然如果直接通过 “请输入” 字段查找会查到多个,无法定位唯一。针对仓储系统由于页面都是配置出来的,没有固定唯一的 id 管理。

通过 dom 树分析得出,普通的输入框可以通过 label 名称查找到对应的 label,然后查找到公共的父节点 el-form-item,再根据查找 el-form-item 的子节点 el-form-item__content 的子节点 el-input 来查找到要输入的输入框进行输入内容。

将此操作封装为两个基础自定义命令:

//查找label所在的el-form-item控件组合根节点,正则全词匹配更加精确
Cypress.Commands.add('getElFormItemByLabel', (label) => {
  cy.get('.el-form-item__label').contains(new RegExp("^" + label + "$", "g")).first().parent('.el-form-item')
})


// 输入框填写值,根据传入的el-form-item找到el-input-inner对象进行输入,增加去除readonly事件,enter事件,以及强制输入
Cypress.Commands.add("cTypeWidthEvent", { prevSubject: 'element' }, ($elSelect, value, event = 'enter') => {
  cy.wrap($elSelect).find('.el-input__inner').then(($el)=>{
    $el.removeAttr('readonly')
    }).type(value + `{${event}}`,{force:true})
})


//后续使用输入框时候只需要这样使用
cy.getElFormItemByLabel('订单号').cTypeWidthEvent(orderNo)
cy.getElFormItemByLabel('派车单号').cTypeWidthEvent(TJNo)

根据 dom 树分析可以得到 el-button class 样式的 span 标签内容是设置,找到此唯一节点点击即可实现点击事件。

//封装点击自定义命令
Cypress.Commands.add('btnClick', (label) => {
    cy.get('button > span').contains(new RegExp("^" + label + "$", "g")).parent('button').click()
})

//如下使用封装所有页面的点击事件
cy.btnClick('设置')

通过如上的方法首先将系统的基础操作元素都封装到 customCommands 中,作为系统基础控件管理。可以极大程度上降低维护控件变化的成本。

4.3.2 Page-Object 模式针对系统页面进行管理

将如下的订单列表页面进行页面封装,譬如我在出库单据中心只会做根据订单号搜索,然后点击订单号跳转进入详情页。

只是需要建一个订单 PO 管理的页面 class,由于仓储是用来跑流程的所以只封装一些用到的控件即可。

//创建PO管理,通过封装的基础命令来封装控件操作,此最好做到只维护控件名称数据
export default class OrderCenterListPage{
    constructor() {}
    clearReceiveOrderDate(receiveStartOrderDate,receiveEndOrderDate){
        //删除接单时间
        cy.getElFormItemByLabel('接单时间').children('.el-form-item__content').find('.el-icon-clear').first().click({force:true})
    }

    orderNoFilterInput(orderNo){
        cy.getElFormItemByLabel('订单号').cTypeWidthEvent(orderNo)
    }

    openOrderDetail(orderNo){
        cy.get('.el-table_1_column_3').contains(orderNo).click()
    }
}

//同时将此页面的操作封装成一个自定义页面操作命令
// 按订单查询明细
Cypress.Commands.add('OrderCenterListPage.openOrderDetail', (orderNo) => {
    var page = new OrderCenterListPage();
    page.clearReceiveOrderDate()
    var routeName = 'queryOrderListInfo'+ Date.now()
    cy.intercept('POST','**/order/web/queryOrderListInfo').as(routeName)
    page.orderNoFilterInput(orderNo)
    cy.wait(`@${routeName}`).then((res=>{
        page.openOrderDetail(orderNo)
    }))
})

4.3.3 针对操作流程进行再度封装

上述封装如果针对页面测试的话已经够了,如果是要针对流程测试,类似仓储的出库流程中有如下的步骤,需要再次对每个页面操作进行封装到一个流程当中,进一步提升代码复用。

基于此,我们做了生产流程的封装,见如下图:

4.3.4 测试用例组织

首先新建一个测试套件,然后将每个页面的主要操作封装成流程的一个用例。为什么要这样做呢?将测试用例拆散验证方便失败重试,如果你写的在一个 it 里面意味着失败重试需要全量触发,反之只需要重试其中一个步骤即可,大大提升成功的效率和缩短重试执行的时间。还可以快速的发现问题所在的位置。

import ExceptionInBoundFlow from '../../support/flow/exceptionInBoundFlow'
import passBackList from '../../fixtures/0_990/passback.json'

describe('5.0到6.0切仓出库全流程验证', () => {

  const flow = new ExceptionInBoundFlow()
  const ibOrderNo = 'UAT_'+Date.now()
  const oBOrderNo = 'WMSESL140760105630761'   //WMSESL140760105632249

  before(()=>{
    cy.clearCookies()
  })

  after(()=>{
    //cy.clearCookies()
  })

  beforeEach(() => {
    //登陆系统
    cy.intercept('**/*',(req) => {
      req.headers['origin'] = 'http://sunlon.wms.jdl.cn'
    }).as('headers')

    cy.visit(Cypress.env('baseUrl'))

  })

  it('1.查询生产流程和生产状态',  () => {
    flow.getOrderProductionInfo(oBOrderNo)
  })

  it('2.根据定位异常下单',  () => {
    flow.receiveIbOrder({oBOrderNo:oBOrderNo,ibOrderNo:ibOrderNo})
  })

  it('3.扫描收货',  () => {
    flow.scanReceiving({orderId:ibOrderNo,locationNo:Cypress.env('locationNo').pickLocation})
  })

  it('4.重新定位',  () => {
    flow.reLocate(oBOrderNo)
  })

  it('5.是否手工定位',  () => {
    flow.manualLocation(oBOrderNo)
  })

  it('6.任务分配',  () => {
    flow.createOutboundTask(oBOrderNo)
  })

  it('7.拣货',  () => {
    flow.pickNew(oBOrderNo,Cypress.env('locationNo').pickLocation)
  })

  it('8.前合流',  () => {
    flow.confluenceBeforeCheck()
  })

  it('9.复核',  () => {
    flow.check({platformNo:Cypress.env('review').defaultPlatformNo,containerNo:Cypress.env('review').defaultContainerNo, palletNo:null, defaultConsumableCode:Cypress.env('review').defaultConsumableCode})
  })

  it('10.后合流上架',  () => {
    flow.upToShipmentLocation(oBOrderNo,Cypress.env('locationNo').fahuoLocation)
  })

  it('11.客单生成包裹',  () => {
    flow.createPackage(oBOrderNo)
  })

  it('12.发货',  () => {
    flow.quickShip(oBOrderNo)
  })

  describe('13.校验生产单回传',  () => {
    for(const index in passBackList){
        const node = passBackList[index].node
        const whiteList = passBackList[index].whiteList
        const desc = passBackList[index].desc
        it(`校验生产单回传节点${index},订单号=${oBOrderNo},回传节点=${node},回传名称${desc},校验内容校验字段=${whiteList}`,  () => {
          cy.log(`校验生产单回传节点,订单号=${oBOrderNo},回传节点=${node},屏蔽校验字段=${whiteList}`).then(()=>{
            flow.passBackCompare(oBOrderNo, passBackList[index].node,passBackList[index].whiteList)
          })
        })
    }
  })
})

以上为 UI 自动化(实际上不限于 UI)降低代码脚本维护成本的方法。

4.4 如何做好 UI 自动化—提升脚本效率以及稳定性

4.4.1 去掉等待

我们在编写脚本时候由于一些操作需要等待后台接口的返回才能进行下一步操作,我们可能会增加 cy.wait(10000) 设置等待时长 10 秒来处理。这种如果接口没有 10 秒内返回的话,会导致用例的失败。针对此我们采用了 cy.intercept 来设置拦截接口路由,通过 wait 来等待后台接口返回后再进行下一步操作。

  //设置路由名称
   var routeName = 'queryOrderListInfo'+ Date.now()
   cy.intercept('POST','**/order/web/queryOrderListInfo').as(routeName)
//操作页面按钮触发请求
   page.orderNoFilterInput(orderNo)
   //等待页面请求结束后进行下一步操作
   cy.wait(`@${routeName}`).then((res=>{
       page.openOrderDetail(orderNo)
   }))

用 cy.intercept 除了可以解决操作问题外,还可以用来进行判断断言接口返回值,并且根据接口返回值进行重试操作,增强稳定性。

cy.intercept('**/queryWaitTaskAssignOrderInfo').as('queryWaitTaskAssignOrderInfo1')
    //点击查询
    page.search()
    cy.wait('@queryWaitTaskAssignOrderInfo1').then((res) => {
        // 针对响应进行断言
        if(res.response.body.resultValue.total != 1){
            console.log('查找待组单的订单不成功,可能是未定位完成,再等待一分钟')
            cy.wait(30000)
            cy.intercept('**/queryWaitTaskAssignOrderInfo').as('queryWaitTaskAssignOrderInfo2')

            page.search()
            cy.wait('@queryWaitTaskAssignOrderInfo2').then((res) => {
                // 针对响应进行断言
                if(res.response.body.resultValue.total != 1){
                    console.log('查找待组单的订单不成功,可能是未定位完成,再等待一分钟')
                    cy.wait(60000)
                    cy.intercept('**/queryWaitTaskAssignOrderInfo').as('queryWaitTaskAssignOrderInfo3')
                    page.search()
                    cy.wait('@queryWaitTaskAssignOrderInfo3')
                }
            })
        }
    })

最后还可以通过 cy.intercept 修改请求属性,并且设置接口 mock 结果来解决外部接口依赖问题。

//设置所有请求添加请求origin
cy.intercept('**/*',(req) => {
req.headers['origin'] = 'http://sunlon.wms.jdl.cn'
}).as('headers')

//将widgets接口mock掉
cy.intercept('POST', 'http://example.com/widgets', {
  statusCode: 200,
  body: 'it worked!'
})

4.4.2 数据传递

Cypress 自动化通过上面 4.3.4 方式设计用例,提升稳定性问题,同时也带来了用例间如何传递数据的问题。在 Selenium 中同步传递数据使用一个全局变量可以解决,在 Cypress 中由于每个操作都是异步的,全局变量方法不可行。这里我们可以采用读写文件方式,读写 cookie 方式,配置文件方式读取静态变量。这里我们介绍一下 cookie 的方法(需要注意:cookie 中是不支持中文的,如果有中文会系统异常报错)。

//配置cookie全局生效:
Cypress.Cookies.defaults({
    preserve:/^testData*/
})
//获取生产流程和生产状态写入cookie:
getOrderProductionInfo(oBOrderNo){
            var json = JSON.parse(res.resultValue[0].json)
            //获取单据类型映射
            this.#testData.shipmentOrderType=json.ruleDetail[0].value[0] 
            //获取生产流程
            this.#testData.productionInfo.location.mode=json.outboundProcessDto.locatingRuleVo.operationMode
            this.#testData.productionInfo.splitOrder.mode=json.outboundProcessDto.splitOrderRuleVo.operationMode
             //单据生产流程和状态存入缓存,注意缓存不能放汉字
             cy.setCookie('testData.info.productInfo',JSON.stringify(this.#testData))
            })
 })
//cookie读取使用:
    receiveIbOrder({oBOrderNo,ibOrderNo,wait=30000}){
        //获取生产流程和上一步测试数据
        cy.getCookie('testData.info.productInfo').then(cookie=>{
             //存储sku和是否需要采购收货状态
             cy.setCookie('testData.info.productInfo',JSON.stringify(testData))
         })
    })

通过动态数据获取传递针对同一个订单不但实现了不同生产流程的通用执行操作,还实现了基于状态的重试,始得整个用例未成功的时候可以设置重新执行保证测试通过。

4.4.3 异步 Promise 获取方式

通过引入异步 Promise 的方式将代码中原先需要 then 层层递进的方法进行异步平铺返回结果。(需要注意的是不能在测试用例中将 it 改成 async 异步属性)

 export async function doTaskAsign({orderNo,orderType,pickType}){
    var batchNo = await promisify(cy['assembleFormCreat.doTaskAssign']())
    return batchNo
}

4.4.4 屏蔽系统异常提升稳定性

通过在 support 中引入如下配置解决系统错误报错,非 cypress 断言报错引起的失败现象。

Cypress.on('uncaught:exception', (err, runnable) => {
    // returning false here prevents Cypress from
    // failing the test
    return false
})

4.4.5 使用录制来辅助定位复杂的控件

作为初级使用者,可能通过录制能更好更快的学习语法,Cypress 也支持录制的方式。

只需要配置: “experimentalStudio”: true

4.4.6 漂亮的工程结构框架

5 学习 UI 自动化的途径

Selenium 本文没做介绍,比较成熟的框架网上也有一堆项目案例,各个公司也有类似开发的录制工具。Cypress 可以学习小菠萝测试笔记 101 篇总结,该位大佬基本上介绍了 Cypress 所有的 API 使用实践,测试组这边也有一本实体书可以供参考学习,遇到问题还可以去 Cypress 社区群求助。

其他就不多说了,欢迎大家一起来学习 Cypress 自动化,为我们的自动化事业添砖加瓦

作者:京东物流 徐桂贵

来源:京东云开发者社区 自猿其说 Tech 转载请注明来源

共收到 2 条回复 时间 点赞

咔咔咔咔,有交流群吗😻

仅楼主可见
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册