自动化测试平台(十一):UI自动化用例图形化编写及执行的实现

x33g5p2x  于2022-03-14 转载在 其他  
字(10.6k)|赞(0)|评价(0)|浏览(707)

一、前言

上一章我们完成了UI元素及元素页面的管理功能,这一章节将实现UI自动化用例的编辑管理功能和执行功能。

完整教程地址:《从0搭建自动化测试平台》

项目在线演示地址:http://121.43.43.59/ (帐号:admin 密码:123456)

本章内容实现效果如下:

编辑用例

执行用例(需要执行客户端!联系博主获取)

二、前端页面

1. 创建元素页面模块文件

  1. npx umi g page uiCases/index --typescript

2. 概览页修改

增加提示和跳转功能

我们可以使用antd的Tooltip组件对卡片内容进行包裹,然后增加点击跳转到uicase路由的事件即可,代码如下:

  1. <Tooltip placement="top" title="点我查看UI用例">
  2. <ul style={{ listStyleType: 'none', cursor: 'pointer', padding: 0 }} className={styles.card} onClick={() => history.push('/case/uiCaseOverview/UiCases')}>
  3. <Badge status="error" text="用例失败数量:" />
  4. {item.status1}
  5. <li>
  6. <Badge status="default" text="等待执行数量:" />
  7. {item.status0}
  8. </li>
  9. <li>
  10. <Badge status="warning" text="正在执行数量:" />
  11. {item.status2}
  12. </li>
  13. <li>
  14. <Badge status="processing" text="执行完成数量:" />
  15. {item.status3}
  16. </li>
  17. <li>
  18. <Badge status="success" text="测试通过数量:" />
  19. {item.status4}
  20. </li>
  21. </ul>
  22. </Tooltip>

这样点击卡片就能够调整到UI用例列表的页面了:

3. UI用例列表页

查询树和查询列表与之前的模块大同小异;直接CURD就可以了

3. 编辑UI用例弹窗

弹窗可以动态的增加步骤并根据选择操作不同显示/隐藏不同组件:

跳转新页面修改数据后,会自动更新数据

下方选择页面的名称在修改后由“百度搜索主页”自动变为“百度搜索”了:

这里和我们之前实现的页面主要不同点如下:

  1. 执行步骤在点击加减图标后会动态的增加减少;
  2. 切换了操作类型后,界面的组件(输入框、选择框)会动态的显示或隐藏;
  3. 跳转新页面修改数据后,会自动更新数据;

实现思路如下

3.1 动态增加步骤

我们可以使用antd的ProFormList组件来实现,文档地址:https://procomponents.ant.design/components/group

参考代码:

  1. <ProFormList
  2. name="steps"
  3. style={{
  4. border: '1px solid #f0f0f0',
  5. borderRadius: 6,
  6. marginTop: 20,
  7. marginBottom: 20,
  8. paddingLeft: 10,
  9. }}
  10. alwaysShowItemLabel
  11. initialValue={caseSteps}
  12. creatorButtonProps={false}
  13. copyIconProps={false}
  14. deleteIconProps={false}
  15. >
  16. {(f, index, { add, remove }) => {
  17. return (
  18. <div style={{ marginTop: 20 }}>
  19. <h3 style={{ fontWeight: 'bold' }}>步骤 {index + 1}</h3>
  20. <ProFormSelect
  21. name="ui_action_id"
  22. label="选择操作"
  23. width="md"
  24. allowClear={false}
  25. fieldProps={{
  26. options: actionData,
  27. showSearch: true,
  28. onChange: (v: any, record: any) => {
  29. const c_step = formRef.current.getFieldValue('steps');
  30. c_step[index].show_type = record.show_type;
  31. formRef.current.setFieldsValue({ steps: c_step });
  32. },
  33. }}
  34. placeholder="请选择操作"
  35. rules={[{ required: true, message: '请选择操作!' }]}
  36. />
  37. <ProFormDependency
  38. name={['show_type', 'cascader_pages', 'element_id']}
  39. >
  40. {({ show_type, cascader_pages, element_id }) => {
  41. var stepItems: any;
  42. if (show_type == 1 || show_type == 3) {
  43. stepItems = (
  44. <>
  45. <div style={{ display: 'flex' }}>
  46. <ProFormCascader
  47. name="cascader_pages"
  48. label="选择页面"
  49. width="md"
  50. fieldProps={{
  51. changeOnSelect: true,
  52. expandTrigger: 'hover',
  53. fieldNames: {
  54. label: 'name',
  55. value: 'id',
  56. children: 'children',
  57. },
  58. options: pageTreeData,
  59. onChange: async (page: any) => {
  60. if (page) {
  61. const page_id = page[0];
  62. const c_step =
  63. formRef.current.getFieldValue('steps');
  64. c_step[index].element_id = null; //将选择的步骤值设置为空
  65. formRef.current.setFieldsValue({
  66. steps: c_step,
  67. });
  68. await getUiPagesElements({
  69. page_ids: page_id,
  70. }).then((res) => {
  71. let pageEle = Object.assign(
  72. pageElement,
  73. res.data,
  74. );
  75. console.log('d1', pageEle)
  76. setPageElement(pageEle);
  77. const c_step =
  78. formRef.current.getFieldValue('steps');
  79. c_step[index].cascader_pages = page; //将选择的步骤值设置为空
  80. formRef.current.setFieldsValue({
  81. steps: c_step,
  82. });
  83. });
  84. }
  85. },
  86. }}
  87. rules={[
  88. { required: true, message: '请选择页面!' },
  89. ]}
  90. />
  91. <span
  92. className={styles.operationStyle}
  93. style={{ marginTop: 5 }}
  94. onClick={() =>
  95. window.open(
  96. `/case/uiCaseOverview/UiPage?project_id=${projectId}`,
  97. )
  98. }
  99. >
  100. 维护页面
  101. </span>
  102. </div>
  103. <div style={{ display: 'flex' }}>
  104. <ProFormSelect
  105. name="element_id"
  106. label="选择元素"
  107. width="md"
  108. fieldProps={{
  109. showSearch: true,
  110. options: cascader_pages
  111. ? parsePageElement(
  112. pageElement[cascader_pages.slice(-1)],
  113. )
  114. : [],
  115. }}
  116. placeholder="请选择元素"
  117. rules={[
  118. { required: true, message: '请选择元素!' },
  119. ]}
  120. />
  121. <div style={{ marginTop: 5 }}>
  122. <span
  123. className={styles.operationStyle}
  124. onClick={() => {
  125. if (cascader_pages?.length > 0) {
  126. showEleModal(
  127. REQ_CREATE,
  128. cascader_pages.slice(-1)[0],
  129. index,
  130. );
  131. } else {
  132. message.error('请先选择页面!');
  133. }
  134. }}
  135. >
  136. 创建元素
  137. </span>
  138. <span
  139. className={styles.operationStyle}
  140. style={{ marginLeft: 8 }}
  141. onClick={() => {
  142. console.log('ee', element_id);
  143. if (element_id) {
  144. showEleModal(
  145. REQ_UPDATE,
  146. cascader_pages.slice(-1)[0],
  147. index,
  148. { id: element_id },
  149. );
  150. } else {
  151. message.error('请先选择元素!');
  152. }
  153. }}
  154. >
  155. 修改元素
  156. </span>
  157. </div>
  158. </div>
  159. </>
  160. );
  161. }
  162. if (show_type == 2 || show_type == 3) {
  163. return (
  164. <div style={{ marginBottom: 20 }}>
  165. {stepItems}
  166. <ProFormText
  167. allowClear={false}
  168. width="xl"
  169. name="value"
  170. rules={[
  171. { required: true, message: '请输入值' },
  172. { type: 'string' },
  173. { max: 255, message: '最多18个字' },
  174. ]}
  175. label="操作数值"
  176. placeholder="请输入操作值"
  177. />
  178. </div>
  179. );
  180. }
  181. return <div style={{ marginBottom: 20 }}>{stepItems} </div>;
  182. }}
  183. </ProFormDependency>
  184. <PlusCircleOutlined onClick={() => add({}, index + 1)} />
  185. <MinusCircleOutlined
  186. onClick={() => {
  187. if (index > 0) {
  188. remove(index);
  189. }
  190. }}
  191. />
  192. </div>
  193. );
  194. }}
  195. </ProFormList>

3.2 界面的组件动态的显示/隐藏

在数据库中我们对每个操作需要的内容都用show_type这个字段做了区分:

show_type的值对应的含义如下:

  1. show_type = 0 # 只需要操作类型 例如:dirver.close()
  2. show_type = 1 # 需要操作元素,例如:driver.click()
  3. show_type = 2 # 需要操作值,例如:driver.get(url)
  4. show_type = 3 # 即需要元素也需要值,例如:driver.send_keys()

这样我们后续增加操作,也可以直接在数据库中进行添加而不动代码。

然后前端可以使用antd的ProFormDependency组件来收集每个步骤的show_type的值,然后根据其不同来返回不同的UI组件即可:

参考代码

  1. <ProFormDependency
  2. name={['show_type', 'cascader_pages', 'element_id']}
  3. >
  4. {({ show_type, cascader_pages, element_id }) => {
  5. var stepItems: any;
  6. if (show_type == 1 || show_type == 3) {
  7. stepItems = (
  8. <>
  9. <div style={{ display: 'flex' }}>
  10. <ProFormCascader
  11. name="cascader_pages"
  12. label="选择页面"
  13. width="md"
  14. fieldProps={{
  15. changeOnSelect: true,
  16. expandTrigger: 'hover',
  17. fieldNames: {
  18. label: 'name',
  19. value: 'id',
  20. children: 'children',
  21. },
  22. options: pageTreeData,
  23. onChange: async (page: any) => {
  24. if (page) {
  25. const page_id = page[0];
  26. const c_step =
  27. formRef.current.getFieldValue('steps');
  28. c_step[index].element_id = null; //将选择的步骤值设置为空
  29. formRef.current.setFieldsValue({
  30. steps: c_step,
  31. });
  32. await getUiPagesElements({
  33. page_ids: page_id,
  34. }).then((res) => {
  35. let pageEle = Object.assign(
  36. pageElement,
  37. res.data,
  38. );
  39. console.log('d1', pageEle)
  40. setPageElement(pageEle);
  41. const c_step =
  42. formRef.current.getFieldValue('steps');
  43. c_step[index].cascader_pages = page; //将选择的步骤值设置为空
  44. formRef.current.setFieldsValue({
  45. steps: c_step,
  46. });
  47. });
  48. }
  49. },
  50. }}
  51. rules={[
  52. { required: true, message: '请选择页面!' },
  53. ]}
  54. />
  55. <span
  56. className={styles.operationStyle}
  57. style={{ marginTop: 5 }}
  58. onClick={() =>
  59. window.open(
  60. `/case/uiCaseOverview/UiPage?project_id=${projectId}`,
  61. )
  62. }
  63. >
  64. 维护页面
  65. </span>
  66. </div>
  67. <div style={{ display: 'flex' }}>
  68. <ProFormSelect
  69. name="element_id"
  70. label="选择元素"
  71. width="md"
  72. fieldProps={{
  73. showSearch: true,
  74. options: cascader_pages
  75. ? parsePageElement(
  76. pageElement[cascader_pages.slice(-1)],
  77. )
  78. : [],
  79. }}
  80. placeholder="请选择元素"
  81. rules={[
  82. { required: true, message: '请选择元素!' },
  83. ]}
  84. />
  85. <div style={{ marginTop: 5 }}>
  86. <span
  87. className={styles.operationStyle}
  88. onClick={() => {
  89. if (cascader_pages?.length > 0) {
  90. showEleModal(
  91. REQ_CREATE,
  92. cascader_pages.slice(-1)[0],
  93. index,
  94. );
  95. } else {
  96. message.error('请先选择页面!');
  97. }
  98. }}
  99. >
  100. 创建元素
  101. </span>
  102. <span
  103. className={styles.operationStyle}
  104. style={{ marginLeft: 8 }}
  105. onClick={() => {
  106. console.log('ee', element_id);
  107. if (element_id) {
  108. showEleModal(
  109. REQ_UPDATE,
  110. cascader_pages.slice(-1)[0],
  111. index,
  112. { id: element_id },
  113. );
  114. } else {
  115. message.error('请先选择元素!');
  116. }
  117. }}
  118. >
  119. 修改元素
  120. </span>
  121. </div>
  122. </div>
  123. </>
  124. );
  125. }
  126. if (show_type == 2 || show_type == 3) {
  127. return (
  128. <div style={{ marginBottom: 20 }}>
  129. {stepItems}
  130. <ProFormText
  131. allowClear={false}
  132. width="xl"
  133. name="value"
  134. rules={[
  135. { required: true, message: '请输入值' },
  136. { type: 'string' },
  137. { max: 255, message: '最多18个字' },
  138. ]}
  139. label="操作数值"
  140. placeholder="请输入操作值"
  141. />
  142. </div>
  143. );
  144. }
  145. return <div style={{ marginBottom: 20 }}>{stepItems} </div>;
  146. }}
  147. </ProFormDependency>

3.3 切换页面自动刷新数据显示

实现这个功能我们需要使用到窗口监听事件visibilitychange,在我们监听到窗口切换后,则自动请求获取元素页面数据的接口:

请求元素页面的方法

  1. const refreshPageTree = useCallback(() => {
  2. if (!window.document.hidden) {
  3. //当页面显示的时候重新请求一次页面树
  4. treeUiPage({ project_id: projectId }).then((res) =>
  5. setPageTree(res.data),
  6. );
  7. }
  8. }, []);

注册监听事件

  1. useEffect(() => {
  2. window.addEventListener('visibilitychange', refreshPageTree);
  3. }, []);

这样在我们每次切换页面后就会调用刷新页面数据的接口进行数据刷新了。

当然,我们处于其他页面时,并不想再让它刷新数据,所以我们可以再关闭弹窗的时候,移除这个监听事件:

  1. modalProps={{
  2. destroyOnClose: true,
  3. maskClosable: false,
  4. onCancel: () => {
  5. window.removeEventListener('visibilitychange', refreshPageTree);
  6. cancel();
  7. },
  8. }}

其他地方的功能都没啥难点了,将填写的数据收集好保存到库中,然后通过执行用例的接口(需自己实现)进行任务的执行即可,小伙伴可以自己动手实现。

三、执行任务思路讲解

selenium有自己的分布式执行方案,也可以远程执行等。但这里存在一个明显的问题(主机和客户机需要再同一网络环境下),在公司还好大家都在局域网,但是想通过平台控制家里的电脑来执行任务就不行了。
为了扩展灵活性并解决这个问题,我采取了心跳机制的方式:服务暴露一个心跳接口,客户端定时轮询请求这个接口,当用户在平台点击执行任务的操作后,服务器会改变执行状态,由于状态的变更,客户端此时请求心跳接口就会拉取任务进行执行了。

拉取到任务后我们就可以根据约定的格式来执行任务了,格式约定可以根据大家的喜好来。也可以参考之前教程:《曲鸟全栈UI自动化教学(八):框架代码讲解和进一步优化》 的执行方式来实现。

至于一些配置(请求密钥、步骤失败后的重试次数、请求的服务器地址等)我采用yaml来管理:

执行UI步骤的dirver代码如下(主要通过反射的方式来选择元素定位方式和要执行的操作),可以参考之前教程:《曲鸟全栈UI自动化教学(八):框架代码讲解和进一步优化》

  1. import time
  2. from retrying import retry
  3. from selenium import webdriver
  4. from selenium.common.exceptions import WebDriverException
  5. from selenium.webdriver.common.by import By
  6. from constant import STEP_ONLY_ACTION, PATH_ELEMENT, STEP_NEED_ELEMENT, STEP_NEED_VALUE, STEP_NEED_ALL, IMAGE_ELEMENT
  7. wait_fixed = 1000
  8. stop_max_attempt_number = 6
  9. class UiDriver():
  10. """
  11. ui自动化对象
  12. """
  13. def __init__(self, case_data, settings):
  14. self.driver = None
  15. self.case_data = case_data
  16. self.step_sleep = settings['step_sleep']
  17. globals()['wait_fixed'] = settings['wait_fixed']
  18. globals()['stop_max_attempt_number'] = settings['stop_max_attempt_number']
  19. def run_case(self):
  20. """
  21. 执行UI自动化的入口方法
  22. :return:
  23. """
  24. print("开始执行UI自动化......")
  25. try:
  26. self.driver = webdriver.Chrome()
  27. except WebDriverException:
  28. input("请下载与谷歌浏览器版本对应的chromedirver!")
  29. cases = self.case_data# case_steps
  30. for step in cases:
  31. time.sleep(self.step_sleep)
  32. self.run_step(step)
  33. return True
  34. @retry(wait_fixed=wait_fixed, stop_max_attempt_number=stop_max_attempt_number)
  35. def run_step(self, step):
  36. """
  37. 执行用例步骤的具体方法
  38. """
  39. show_type = step['show_type']
  40. print('s', step['ui_action'])
  41. if show_type == STEP_ONLY_ACTION:
  42. getattr(self.driver, step['ui_action'])()
  43. elif show_type == STEP_NEED_ELEMENT:
  44. driver = choose_location_method(step['element_data'], self.driver)
  45. getattr(driver, step['ui_action'])()
  46. elif show_type == STEP_NEED_VALUE:
  47. getattr(self.driver, step['ui_action'])(step['value'])
  48. elif show_type == STEP_NEED_ALL:
  49. driver = choose_location_method(step['element_data'], self.driver)
  50. getattr(driver, step['ui_action'])(step['value'])
  51. def choose_location_method(element, driver):
  52. """
  53. 根据元素类型不同选择不同方式进行定位
  54. :return:
  55. """
  56. if element['type'] == PATH_ELEMENT:
  57. driver = driver.find_element(getattr(By, element['location_method']), element['path'])
  58. elif element['type'] == IMAGE_ELEMENT:
  59. pass
  60. return driver

四、将客户端打包

当我们完成执行客户端的代码编写后,可以通过pyinstaller第三方库将其打包为可执行文件,这样别人使用时就不需要考虑环境配置的问题了。谁有,谁就是执行客户端。

五、总结

这一章完成了UI用例编辑和执行的功能,当然还有很多需要完善的地方,比如增加执行日志,执行完成后的结果判断及变更等。小伙伴们可以按文中的思路和操作方式继续完善。

下一章节将开始接口自动化的教学了。

平台在线演示地址:http://121.43.43.59/ (帐号:admin 密码:123456)

相关文章