很久以前基于Robotframework + flask + reactjs
开发了一套Web UI自动化系统,用于网站自动化测试和日常巡检。但是随着其应用覆盖的范围越来越广…
背景
很久以前基于Robotframework + flask + reactjs
开发了一套Web UI自动化系统,用于网站自动化测试和日常巡检。但是随着其应用覆盖的范围越来越广,发现存在一些设计上的短板,如:只能单一节点部署运行上限较低、交互上有些卡顿、系统账号体系独立未与公司统一认证打通、原有的库表设计和前端组件不支持图像识别功能的扩展等等。
出于以上的种种因素,结合部门测试工具链建设的需求,于是在我们新的综合自动化管理平台的基础上,进行了WEB UI自动化系统的开发。
架构设计
系统整体采用的B/S架构,前后分离的开发模式,本章主要介绍综合自动化管理系统中的WEB UI自动化子系统,其结合python celery库进行分布式设计。
整体架构,后期功能调整的灵活性,和性能的扩展性都能很强,也是为以后结合CI系统进行大批量运行提供支撑。
实现效果
功能设计
前端框架介绍
工程结构
前端框架整体参考Ant Design Pro 2
的标准工程结构,并结合我们打造综合管理平台的可扩展需求,设计了双导航路由,代码结构上采用组件化分层设计。
基础功能组件基于全局可用性的设计封装,各个系统间业务代码独立,低耦合,便于后续功能的扩展和迁移。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| ├── assets //全局资源 │ └── logo.svg ├── components //全局组件 │ ├── Authorized │ ├── GlobalHeader │ ├── HeaderSearch │ └── PageLoading ├── layouts //基础布局 │ ├── BasicLayout.jsx │ ├── BlankLayout.jsx │ ├── SecurityLayout.jsx │ └── UserLayout.jsx ├── locales //本地化配置 │ ├── en-US │ ├── en-US.js │ ├── zh-CN │ └── zh-CN.js ├── models //公共models │ ├── global.js │ ├── login.js │ ├── setting.js │ └── user.js ├── pages //功能页面 │ ├── LinkCheck //外链检查系统 │ ├── System //系统管理功能 │ ├── UAT //UI 自动化系统 │ │ ├── Case //用例管理模块 │ │ │ ├── CaseDetail //具体功能页面 │ │ │ ├── CaseList │ │ │ ├── ModuleTree │ │ │ ├── components //模块组件 │ │ │ ├── index.jsx │ │ │ ├── index.less │ │ │ ├── model.js //模块models │ │ │ └── service.js //api管理 │ │ └── Config │ │ └── Home │ │ └── Task │ │ └── assets │ │ └── tim.svg │ ├── User │ └── document.ejs └── utils ├── Authorized.js ├── authority.js ├── localSave.js ├── request.js └── utils.js
|
基础数据流管理
由上述的工程结构可以看出,采用了umijs
作为底层前端框架,整合dva
做数据流管理。
通过自定义封装的reducers
可以方便的处理不同业务的返回数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import { queryDebugTaskInfo } from './service';
const Model = { namespace: 'uatCase', state: { debugTaskId: null, debugTaskInfo: null, }, effects: { *queryDebugTaskInfo({ payload }, { call, put }) { yield put({ type: 'updateState', payload: { debugTaskInfo: null } }); const response = yield call(queryDebugTaskInfo, payload); if (response && response.code === 0) { yield put({ type: 'updateState', payload: { debugTaskInfo: response.content } }); } }, *queryDebugCase({ payload }, { call, put }) { yield put({ type: 'updateState', payload: { debugTaskId: null } }); const response = yield call(queryDebugCase, payload); if (response && response.code === 0) { yield put({ type: 'updateState', payload: { debugTaskId: response.content.taskId } }); } }, }, reducers: { updateState(state, { payload }) { return { ...state, ...payload }; }, }, }; export default Model;
|
核心组件
页面组件的设计上,本着可服用、低耦合、高内聚的理念,结合业务本身特点,分为如下几种:
- 入口组件:页面业务组件的汇聚地,公用数据数据及方法的集合,以方便状态管理的
class
编程实现。
- 业务模块组件:具体功能的业务组件,以列表入口、页面布局、功能模块为主,看需求采用函数式编程,还是class编程。
- 公共组件:以通用类型的数据展示为主,一般是对基础组件的自定义封装,以函数编程为主,所有的数据规则交由父级业务模块组件处理。
- 特殊功能组件:这种组件在系统用的不多,但不可避免的存在,如本系统的用例详情表单、用例debug的VNC模块等,复用的需求较小,一般以其模块的功能进行代码聚合,方便问题定位与维护。
前端依赖基础组件如下:
1 2 3 4 5 6 7 8 9 10
| { "antd": "^4.15.0", "@ant-design/pro-form": "^1.3.0", "react-contextmenu": "^2.14.0", "react-data-grid": "^7.0.0-beta.11", "react-vnc": "^0.4.0", "@tinymce/tinymce-react": "^4.0.0", "@antv/l7": "^2.1.9", "react-dnd": "^15.1.1", }
|
自定义组件介绍
本次的针对操作最为集中的表格组件,采用了react-data-grid
,但其基础组件远不能满足我们的需求,因此需要进一步的封装。
做改动如下:
1.结合react-dnd
实现表单行的拖拽排序:
1 2 3
| <DndProvider backend={HTML5Backend}> <DataGrid components={{...<DraggableRowRenderer ... />...}}/> <DndProvider/>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <ContextMenuTrigger holdToDisplay={-1} id='data_grid_context_menu' ... > ... </ContextMenuTrigger> . . . <ContextMenu id='data_grid_context_menu'> <MenuItem>删除行</MenuItem> </ContextMenu>
|
3.自定义动态列头,实现表格范围的可扩展:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| caseSteps && caseSteps.stepColumns && caseSteps.stepColumns.forEach((columnKey, index) => { newColums.push( { key: columnKey, name: this.renderHeader(columnKey), formatter: (record) => this.renderDataFormatter(record, columnKey), editor: (record) => this.renderDataEditor(record, columnKey, projectId), colSpan(args) { if (args.type === 'ROW') { if (args.row.rowId === 'default') { return caseSteps.stepColumns.length; } } return undefined; }, }, ); }); if (newColums !== this.state.columns) { this.setState({ columns: newColums }); }
|
4.结合图片组件,实现图片附件的单元格操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| renderDataFormatter = (props, dataKey) => { const { rowData } = props.row; if (!rowData.hasOwnProperty(dataKey)) { return TextEditor; } switch (rowData[dataKey].dataType) { case 0: return <span><MenuOutlined style={{ cursor: 'grab', color: '#999', marginRight: 5 }} /> {rowData[dataKey].value}</span>; case 1: return <span>{rowData[dataKey].value}</span>; case 2: return ( <div className={styles.wrapperClassname}> <div className={styles.imageContainer}> <Image src={rowData[dataKey].value} /> </div> </div> ); } return <span>{rowData[dataKey].value}</span>; };
|
5.结合VNC+selenium-grid,实现的实时调试功能:
1 2 3 4 5 6 7 8 9 10 11 12
| import { VncScreen } from 'react-vnc'; ... <VncScreen url={vncUrl} loadingUI={....} scaleViewport background="#000000" style={{ width: '80vw', height: '70vh', }} />
|
插件介绍
多年前,是撸过个鼠标右键的插件的,但是本打算简单小改接着用时,发现google强制升级了协议版本,而且升级后原来的一些写法就要大改,本来内容就不多,干脆一波直接用新MV3重做吧。
本插件主要提供的向用例编辑页面回传元素定位推荐值
和元素图片截图
,这2个功能的实现,主要依赖canvas、CSS Selector
,其中的css定位值获取,依赖 optimal-select。
插件已上架google商店,但需要配合本自动化系统才能使用:web元素捕手
实现效果
插件的工作原理
插件安装后,background.js
就已经开始运行了,且只有一个,它能跨窗口、跨域名通信。content.js
只有页面加载才会运行,且每个页面都是独立的,互不相通,可以获取当前页面的DOM,但是能力受限。因此我们以background.js
为跳板,实现跨页面通信,所以其核心要点,是要理清楚页面与插件的关系,简单概括为如下流程:
1.工具页面与插件background通过插件消息通信
2.background与目标页面通过标签页面消息通信
3.标签页面消息回传background,background此时发送工具页面的content
4.工具页面content发送窗口消息给工具页面
如果不清楚插件与页面的通信方法,理解上还有些吃力的,数据流转情况如下图,三个框分别代表了:工具页面、插件、目标页面。
核心方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| function getSelector(element) { let selector; try { selector = OptimalSelect.select(element, { root: document, priority: ['id', 'class'], ignore: { class(className) { return className.length < 3; }, attribute(name, value, defaultPredicate) { return /data-*/.test(name) || defaultPredicate(name, value); }, }, }); if (selector) { const elementPos = element.getBoundingClientRect(); const { width, height } = elementPos; if (width && height) { chrome.runtime.sendMessage( { type: 'capElement', webPageTabId, elementPos, selector }, function (dataURL) { getElementImage(dataURL, elementPos, webPageTabId, selector); }, ); } } } catch (e) { console.log(e); } }
function createImage(dataURL, elementPos, webPageTabId, selector) { const devicePixelRatio = window.devicePixelRatio; let canvas = createCanvas( elementPos.width * devicePixelRatio + 10, elementPos.height * devicePixelRatio + 10, ); let context = canvas.getContext('2d'); const croppedImage = new Image(); croppedImage.src = dataURL; croppedImage.onload = function () { context.drawImage( croppedImage, elementPos.x * devicePixelRatio - 5, elementPos.y * devicePixelRatio - 5, elementPos.width * devicePixelRatio + 10, elementPos.height * devicePixelRatio + 10, 0, 0, elementPos.width * devicePixelRatio + 10, elementPos.height * devicePixelRatio + 10, ); const elementCap = canvas.toDataURL(); chrome.runtime.sendMessage({ type: 'sendElement', webPageTabId, elementCap, selector }); }; }
|
服务端介绍
服务端用的是python flask,由于是前后端分离,只需提供 api 接口。建议采用pipenv管理依赖,保持协作版本的一致性。在工程结构设计上也是考虑到扩展性,采用了蓝图管理路由,不同系统间的业务接口代码相互独立。
工程结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| ├── Pipfile // pipenv依赖清单 ├── Pipfile.lock ├── app │ ├── __init__.py │ ├── common // 工具类 │ │ ├── HTMLBuilder.py │ │ ├── Notice.py │ │ ├── __init__.py │ │ ├── aes_util.py │ │ ├── des_help.py │ │ ├── docker_task.py │ │ ├── emailCommon.py │ │ ├── sch_task.py │ │ ├── scheduler_tools.py │ │ ├── system_common.py │ │ ├── token.py │ │ └── util.py │ ├── htmlReportTemplete // html邮件模版 │ │ ├── link_check_report_templete.html │ │ └── ui_auto_report_template.html │ ├── inject.py // 全局拦截器 │ ├── router.py // 路由管理 │ ├── script // 子系统业务脚本 │ │ ├── LinkCheck │ │ ├── UAT │ │ └── __init__.py │ ├── settings.py // 系统配置设置 │ ├── src // 系统业务接口 │ │ ├── UAT │ │ ├── __init__.py │ │ ├── auth // 公用鉴权接口 │ │ ├── linkCheck │ │ └── system │ └── tables │ ├── Check // 库表模型 │ ├── System.py │ ├── UAT │ ├── User.py │ └── __init__.py ├── celery_tasks // 分布式任务入口 ├── celery_worker.py // celery实例化方法 ├── db // 数据库表sql ├── logs ├── requirements.txt ├── run.py //系统启动入口 └── run_celery_app.sh //分布节点启动脚本
|
服务端框架介绍
最早的一版简洁服务端框架,只是将配置分离,随着需求变化,已经不断优化了多次,结合Flask-Script、Flask-SocketIO、celery、schedule
目前的服务端框架支持如下:
- 路由统一管理
- 系统配置分离
- 全局拦截器配置
- 定时任务管理
- socket接口支持
- 异步脚本支持应用上下文、数据库实例调用
- 依赖库统一管理
当然就目前的框架来说,来有很多可以优化的地方,如日志管理、全局返回处理等等,这也是我们下一步要去做的。
分布式方案
从上面的工程结构不难发现,我们的分布式客户端也在服务端代码中,由于依赖库的耦合度较高,这里就没做分离。实际部署分布式执行节点时,我们只需几步就可以启用一个新的节点:
即可启用节点。此处有几个前提条件:
- 建议linux系统、x86、x64内核,不然selenium-grid的镜像运行会异常。
- python 3.8已安装,高了或者低了都会有问题哦。
- docker-ce版本 >= 20,低版本没有集成 compose执行会报错。
关于celery原理,这不做详细的讲解。它的工作流程如下图:
我们用到几个典型角色如下:
Task
任务触发者,可以立即执行、延迟执行。我们系统中已经有基于schedulers
的定时任务管理,所以这里只需要用到立即执行功能,如下:
1 2 3 4 5 6 7 8 9
| from celery_tasks.UAT.runTask import start
... @uat_task.route('/exec', methods=['POST']) def exec(): taskId = request.json.get("taskId") ... start.delay(taskId) ...
|
Broker
接收生产者发来的消息即Task,将任务存入队列。通过Redis、RabbitMQ
实现队列服务。当任务执行失败或执行过程中发生连接中断,celery 会自动尝试重新执行任务。如果任务没有消费掉会一直存在于队列中,这里我们可以通过配置合理管理我们的代理人。
1 2 3
| app.config['CELERY_TASK_RESULT_EXPIRES'] = 60 * 20 app.config['CELERYD_PREFETCH_MULTIPLIER'] = 1 app.config['CELERYD_CONCURRENCY'] = 10
|
Worker
任务的执行节点,它实时监控消息队列,如果有任务就获取任务,并调用自定义的脚本执行它。
同时通过客户端的启动参数 -O fair
启用celery的公平分配策略,让我们的任务量分配更均匀,提高执行效率。启动命令,我们封装到脚本run_celery_app.sh
,方便与python环境的结合。
1 2 3
| export PYTHONPATH="${PYTHONPATH}:${PWD}" celery -A celery_worker.celery worker --loglevel=info -P gevent -c 10 -O fair
|
核心脚本设计
有了上述的分布式结构后,我们的核心脚本主要在是节点worker上运行,关键步骤如下图:
主要脚本步骤
测试运行的关键是:
组织测试数据》创建运行环境〉执行测试》分析执行结果〉清理环境。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| def main(self, is_debug): try: self.get_task_info() self.ip, self.hub_port = self.get_hub_addr() if not self.ip or not self.hub_port: self.set_task_status(4) logger.error('Get local ip or port error!') return self.build_workspace_dir(self.hub_port) run_result = self.run_task() if not run_result: self.set_task_status(4) logger.error('Docker container create failed!') return total_count, pass_count, fail_count, fail_cases, exec_round_time = self.analysis_log() self.save_log(total_count, pass_count, fail_count, fail_cases, exec_round_time) self.upload_task_screenshot(self.workspace_dir, '.png') self.task_log_notice(total_count, pass_count, fail_count, exec_round_time) logger.info(f"Run task {self.task_id} complete") self.set_task_status(3) except Exception as e: logger.error(str(e)) self.set_task_status(4) if not is_debug: logger.info("clear project") self.clean_hub() self.clean_project()
|
动态执行容器
可以看到我们是用selenium-grid作为任务的执行容器,它本身也是支持分布式的,但我们之所以没有直接用selenium-grid作为分布式框架,一方面出去自身定义的灵活性考虑,另一方面可以看看官方issues里,实际实验过程中,其长时间运行的稳定性也很随缘。
不过它的vnc支持、容器化封装、浏览器版本丰富,是我们实时调试的必要基件。后续也会考虑,将批量运行与selenium-grid剥离,进一步提高执行效率。
目前我们结合节点机器的性能,和自深的任务执行量,优化了docker-compose
的动态容器创建方法,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| def create_docker_compose(self, hub_port): docker_image_hub = app.config['Gird_HUB'] task_browser_id = TaskTable.query.filter(TaskTable.id == self.task_id).first().browser docker_image_nod = Browsers.query.filter(Browsers.id == task_browser_id).first().docker_image self.task_browser_name = Browsers.query.filter(Browsers.id == task_browser_id).first().browser_name self.task_browser_ver = Browsers.query.filter(Browsers.id == task_browser_id).first().version node_number = self.get_node_number()
env = [ f'SE_EVENT_BUS_HOST=selenium-hub-{hub_port}', 'SE_EVENT_BUS_PUBLISH_PORT=4442', 'SE_EVENT_BUS_SUBSCRIBE_PORT=4443', 'SCREEN_WIDTH=1920', 'SCREEN_HEIGHT=1080', 'SE_NODE_MAX_SESSIONS=5', 'SE_NODE_OVERRIDE_MAX_SESSIONS=true', 'VNC_NO_PASSWORD=1', 'TZ="UT"', ] if self.task_info['proxy']: env.append(f"http_proxy={self.task_info['proxy']}") env.append(f"https_proxy={self.task_info['proxy']}") compose_data = { 'version': '3', 'services': { f'selenium-hub-{hub_port}': { 'image': f'{docker_image_hub}', 'container_name': f'selenium-hub-{hub_port}', 'ports': [f'{hub_port}:4444'] } } } for i in range(0, node_number): compose_data['services'][f'{self.task_browser_name}-{hub_port}-{i}'] = { 'image': f'{docker_image_nod}', 'shm_size': '2gb', 'platform': 'linux/amd64', 'container_name': f'node-{self.task_browser_name}-{hub_port}-{i}', 'depends_on': [f'selenium-hub-{hub_port}'], 'environment': env, } generate_yaml_doc_ruamel(f"{self.workspace_dir}/docker-compose.yml", compose_data)
|
1 2 3
| ``` python self.docker = DockerClient(compose_files=[f"{self.workspace_dir}/docker-compose.yml"]) self.docker.compose.up(detach=True)
|
图像识别库改造
前期调研时,还是乐观的,因为appium已经实现了图片元素的定位,想着RF实在不行,起个appium服务,专门处理图像识别也行啊。后来也找到了RobotEyes
,可实际应用起来发现,它一个图片对比,要2分钟。还以为是我姿势不对,分析了源码发现,它使用的是Imagemagick
的compare
,官方也说了,就是这么慢。瞬间就不香了,必须搞它。
fork了个分支自己改造:RobotEyes,对于图像对比,我换成了convert
,效率提高120倍:
1
| compare_cmd = f'convert "{self.img1}" "{self.img2}" -metric RMSE -compare -format "%[distortion]" info:'
|
结合我们断言元素图片是否存在的需求,加了个关键字Is Image In Screen
,后续也会参考 appium 增加更多图片处理关键字。
一开始只是简单的用的 opencv.matchTemplate
,效率倒是挺快,但是准确堪忧,特别元素图片来源不同,大小比例不确定,基本玩不转了。
不过经过多方实验后,采用了多例缩放对比的方法,算是解决了,详见代码。还有优化空间,有空研究下特征识别在自动化中的应用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| ...
gray = cv2.cvtColor(raw_screen, cv2.COLOR_BGR2GRAY) found = None
for scale in np.linspace(max_scale, min_scale, step)[::-1]: resized = imutils.resize(gray, width=int(gray.shape[1] * scale)) r = gray.shape[1] / float(resized.shape[1]) if resized.shape[0] < tH or resized.shape[1] < tW: continue edged = cv2.Canny(resized, 50, 200) result = cv2.matchTemplate(template, edged, cv2.TM_CCOEFF_NORMED) (_, maxVal, _, maxLoc) = cv2.minMaxLoc(result) ...
|
灰化后的元素图片:
模版图的比例是被放大后截取的,同样可以定位识别到:
定时任务执行器
第一版的定时任务执行方法,是手撸的时间字符串匹配。性能开销大,灵活性差。这次结合scheduler
进行了改造。对接了cron表达式,可以更灵活的配置我们的定时任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| def execute_uat_time_task(task_id, task_cron): cron_dict = get_cron_info(task_cron) if cron_dict: logger.info('创建定时执行任务:{}'.format(json.dumps(cron_dict))) scheduler.add_job( start_uat_time_job, 'cron', minute=cron_dict['minute'], hour=cron_dict['hour'], day=cron_dict['day'], month=cron_dict['month'], day_of_week=cron_dict['day_of_week'], id='uat'+str(task_id), args=[task_id], max_instances=4 ) else: print("run UI timing task {0} error".format(task_id))
|
结合flask框架,我们在应用启动时把scheduler
实例化,与业务接口接合,可以更灵活的管理我们的在运行任务。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ''' 定时任务管理器 ''' from apscheduler.schedulers.background import BackgroundScheduler
scheduler=BackgroundScheduler() scheduler.start() ...
from app import db, scheduler ... @uat_task.route('/stop', methods=['POST']) def stop(): taskId = request.json.get("taskId") ... scheduler.remove_job('uat'+str(taskId)) ...
|
获取debug实时画面
这里有个小技巧,本次采用的selenium-grid本身是支持vnc展示的,但是要容器的5900端口挂载出来,意味着有一个node要多占用一个端口,这个开销也不小。
分析了它的源码后,发现不需要知道具体执行node的端口和ip,可以通过sessionid,拼接成一个vnc访问地址,hub中已经帮忙我们做好转发了。
我们在创建容器时需要设置VNC_NO_PASSWORD=1
,然后根据hub的session列表获取id即可,因为我们自身的需求是只有用例调试时才需要看vnc实时画面,而且此时只会有一个session,所以直接返回第一条即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| def get_hub_sessions(hub_ip, hub_port): session_ids = [] url = f"http://{hub_ip}:{hub_port}/graphql" data = { "query": "{ sessionsInfo { sessions { id } } }" } try: res = requests.post(url, data=json.dumps(data), headers={"Content-Type": "application/json"}, timeout=5) resp = res.json() for session in resp['data']['sessionsInfo']['sessions']: session_ids.append(session['id']) except Exception as e: logger.error(str(e))
return session_ids
|
后面就是前端处理了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| queryDebugTaskSession = (taskId) => { const { dispatch } = this.props; dispatch({ type: 'uatCase/queryDebugTaskSession', payload: { taskId }, }).then(() => { const { debugSessionIds } = this.props.uatCase; if (debugSessionIds && debugSessionIds.length > 0) { const { debugTaskInfo } = this.state; this.setState({ debugSessionIds, vncUrl: `ws://${debugTaskInfo.hub_ip}:${debugTaskInfo.hub_port}/session/${debugSessionIds[0]}/se/vnc`, }); } }); };
|
结语
以上仅是综合自动化平台中,WEB UI自动化系统部分的开发介绍。道阻且长,然而坚持就是胜利,多思考、多搜索,就没有爬不出去坑。