自动化测试平台(十):UI自动化元素页面的管理功能实现

x33g5p2x  于2022-01-06 转载在 其他  
字(9.0k)|赞(0)|评价(0)|浏览(574)

一、前言

上一章我们完成了列表组件公共化封装和项目管理功能的实现,这一章将实现UI元素及元素页面的管理功能,换句话说就是对selenium执行定位操作的元素进行管理。

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

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

本章内容实现效果如下:

为了开发效率,很多类型都是用any来定义的,小伙伴可以自己进行完善。

二、UI元素管理相关表模型建立

我们知道执行UI自动化的每步操作需要指定元素的路径地址。根据传统的POM模型我们可以按页面来管理元素,不同的页面有不同的元素。

但传统的POM模型是一个元素只对应一个页面,但实际情况存在很多页面都存在相同的元素,这个时候需要公共页面元素的概念来做,可是如果抽离专门的公共页面来维护反而多了一个步骤了。我们可以直接在元素和页面间建立多对多的关系,在创建元素/页面的时候就维护好它们的对应关系即可。

这里我们建立了三张表元素表元素页面表以及ui定位方式表(用于存储八种定位方式),表结构模型代码如下:

class UiPage(ComModel):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=32, verbose_name="页面名称")
    project = models.ForeignKey(to=Project, on_delete=models.DO_NOTHING, default=1, verbose_name="关联的项目id")
    parent = models.ForeignKey(to='self', verbose_name="父页面", null=True, on_delete=models.DO_NOTHING)

    class Meta:
        verbose_name = 'ui元素页面'
        db_table = 'ui_page'

class UiLocationMethod(models.Model):
    id = models.CharField(max_length=255, verbose_name="定位方式名称", primary_key=True)
    position = models.IntegerField(default=1,verbose_name="排序优先级")

    class Meta:
        verbose_name = 'ui定位方式'
        db_table = 'ui_location_method'

class UiElement(ComModel):
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=32, verbose_name="元素名称")
    path = models.CharField(max_length=255, verbose_name="路径地址")
    ui_page = models.ManyToManyField(to=UiPage, verbose_name="关联的页面id")
    location_method = models.ForeignKey(
        to=UiLocationMethod, null=True, on_delete=models.DO_NOTHING, verbose_name="关联的定位方式id")
    type = models.IntegerField(default=1, verbose_name="元素类型:1.路径元素;2.图像识别元素")

    class Meta:
        verbose_name = 'ui元素'
        db_table = 'ui_element'

大家都知道路径定位方式有八种,在我们的ui_location_method也存了这八种定位的数据。这些数据是项目初始化的时候就创建了的,对于数据的初始化可以使用django 的迁移文件来进行,步骤如下。
创建一个空的迁移文件:

python manage.py makemigrations --empty cases

在其中开发初始化代码:

from django.db import migrations

loaction_method_data = [
    {'id': 'XPATH', 'position': 1},
    {'id': 'CSS_SELECTOR', 'position': 2},
    {'id': 'NAME', 'position': 3},
    {'id': 'ID', 'position': 4},
    {'id': 'PARTIAL_LINK_TEXT', 'position': 5},
    {'id': 'CLASS_NAME', 'position': 6},
    {'id': 'LINK_TEXT', 'position': 7},
    {'id': 'TAG_NAME', 'position': 8}
]

def init_location_method(apps, schema_editor):
    """ 初始化定位方式的数据 """
    # We can't import the Person model directly as it may be a newer
    # version than this migration expects. We use the historical version.

    UiLocationMethod = apps.get_model('cases', 'UiLocationMethod')
    UiLocationMethod.objects.bulk_create([UiLocationMethod(**data) for data in loaction_method_data])

class Migration(migrations.Migration):
    dependencies = [
        ('cases', '0005_uielement_uilocationmethod_uipage'),
    ]

    operations = [
        migrations.RunPython(init_location_method)
    ]

完成后,再执行python manage.py migrate迁移命令就能够创建初始数据了:

三、开发接口

1. 元素页面管理和元素管理接口

这两个模块的接口很简单,按照之前的教学进行开发即可:

1)UI页面管理视图代码:

class UIPageAllViews(ComAllAPIView):
    permission_classes = (IsAuthenticated,)
    queryset = UiPage.objects.all()
    serializer_class = UiPageSerializer # 需要自己实现Serializer 
    # 通过django-filter匹配字段进行过滤搜索
    filter_fields = ('project_id',)
    # 实现排序
    ordering_fields = ('created',)

2)UI元素管理接口代码:

class UiElementViews(ComAllAPIView):
    permission_classes = (IsAuthenticated,)
    queryset = UiElement.objects.all().order_by('-created')
    serializer_class = UiElementSerializer
    filter_backends = (DjangoFilterBackend,)
    # 通过django-filter匹配字段进行过滤搜索
    filter_fields = ('ui_page',)
    # 实现排序
    ordering_fields = ('created',)

3)获取定位方式接口:

@api_view(['GET'])
@permission_classes((IsAuthenticated,))
def ui_location_method(request):
    """ ui定位方式 """
    location_method = UiLocationMethod.objects.values('id').order_by('position')
    return Response(data=location_method)

2. 上传文件接口

我们除了传统的路径定位方式外,还增加了通过图像识别来定位的功能,所以需要写一个公共的上传文件接口,类似这样的公共接口我们可以放在之前建立的comFunc/comViews中来管理,上传文件代码如下:

@api_view(['POST'])
@permission_classes((IsAuthenticated,))
def put_file(request):
    """ 上传文件 """
    my_file = request.FILES.get('file', None)
    file_data_path = '/FileData' + ('\\' if sys.platform == 'win32' else '/')
    file_path = file_data_path + my_file.name
    full_path = os.getcwd() + file_path
    if os.path.exists('./FileData') is not True:  # 如果存放上传文件的文件夹不存在则创建
        os.mkdir('./FileData')
    with open(full_path, 'wb+') as f:
        for chunk in my_file.chunks():
            f.write(chunk)
    f.close()
    file_url = 'http://121.43.43.59:8003/' + my_file.name
    return Response(data={'file_url': file_url})

注:对于上述代码中类似存储文件夹、url前缀等应该存为静态变量,而不是直接写在代码中,小伙伴们记得自己修改下。

然后为该接口配置路由,在QNtest\urls.py的路由列表中加入下面代码:

path('put-file', put_file),

记得import:

from comFunc.comViews import put_file

3. UI页面树接口方法代码

因为我们的页面存在父子级关系,一个页面下可能还有多层子页面,为了返回下图这样的树结构,我们可以通过递归实现:

def set_tree(_list):
    """ 递归生成树 """
    _dict = dict()
    tree = list()
    for i in _list:
        _dict[i['id']] = i
        i['children'] = []
    for i in _list:
        node = i
        if node['parent_id'] is not None:
            _dict[node['parent_id']]['children'].append(node)
        else:
            tree.append(node)
    return tree

然后在页面树接口中调用上面的生成树方法:

@api_view(['GET'])
@permission_classes((IsAuthenticated,))
def tree_ui_page(request):
    """ UI元素页面树 """
    project_id = request.query_params['project_id']
    page_list = UiPage.objects.filter(project_id=project_id).values('id', 'parent_id', 'name')
    page_list = set_tree(page_list)
    return Response(data=page_list)

四、前端页面

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

npx umi g page uiPage/index --typescript

然后在.umirc.ts中配置路由即可

2. 编辑元素弹窗

因为我们需要增加通过图像匹配的方式来进行元素定位的自动化测试,所以在添加元素的时候,需要做到可以自由选择添加路径元素还是图片元素。

1)实现效果:

2)实现思路

我们可以通过useState声明一个控制元素类型的状态:

const [elemenType, setElemenType] = useState(formData.type || 1);

然后在点击切换按钮的时候进行状态的变更,以至于控制不同的展示效果。

核心代码如下:

<ProFormRadio.Group
        name="type"
        radioType="button"
        label="元素类型"
        fieldProps={{
          onChange: (e) => setElemenType(e.target.value),
          options: [
            {
              label: '路径元素',
              value: 1,
            },
            {
              label: '图片元素',
              value: 2,
            },
          ],
        }}
      />
      {elemenType === 1 ? (
        <>
          <ProFormText
            width="md"
            name="path"
            rules={[
              { required: true, message: '元素地址必填' },
              { type: 'string' },
              { max: 255, message: '最多255个字' },
            ]}
            label="元素地址"
            placeholder="请输入元素地址"
          />
          <ProFormSelect
            width="sm"
            name="location_method"
            label="定位方式"
            options={locationMethodList}
            placeholder="请选择定位方式"
            rules={[{ required: true, message: '请选择定位方式!' }]}
          />
        </>
      ) : (
        <Form.Item name="path">
          <Upload
            beforeUpload={(file: any) => {
              if (file.size / 1024000 > 0.5 || !file.name.includes('.png')) {
                message.error(
                  '上传的文件,大小不能超过500KB!只支持png格式文件上传',
                );
                //设置文件上传的status为error
                file.status = 'error';
                return false;
              }
            }}
            customRequest={(info: any) => {
              putFile(info.file).then((item) => {
                setFileList([
                  {
                    uid: '1',
                    name: 'image.png',
                    status: 'done',
                    url: item.data.file_url,
                  },
                ]);
                info.onSuccess('res', info.file); //上传成功需要通过onsuccess改状态
                formRef.current.setFieldsValue({ path: item.data.file_url });
              });
            }}
            onRemove={(e) => {
              setFileList([]);
            }}
            showUploadList={{ showPreviewIcon: false }}
            fileList={fileList}
            listType="picture-card"
            maxCount={1}
          >
            {fileList.length >= 1 ? null : uploadButton}
          </Upload>
        </Form.Item>

3. 页面树

列表左侧的页面树,因为在之前写的后端接口tree-ui-page已经返回了树结构,所以我们这里直接使用antd的Tree组件进行展示即可:

<Tree
            showLine={{ showLeafIcon: false }}
            onSelect={(e: any) => treeOnSelect(e[0])}
            treeData={setTree(treeData)}
            fieldNames={{ title: 'name', key: 'id', children: 'children' }}
          />

上图中的编辑组件实际上是自定义设置了树的标题,核心代码如下:

const setTreeTitle = (item: any) => {
    const title = (
      <span>
        {item.name}
        <span
          key={item.id}
          style={{
            zIndex: 1,
            width: '80px',
            position: 'absolute',
            display: treeEditDisplay,
          }}
        >
          <Tooltip title="增加页面">
            <PlusCircleOutlined
              style={{ marginLeft: 10 }}
              onClick={(e) => showPageModal(reqCreate, item)}
            />
          </Tooltip>
          <Tooltip title="编辑页面">
            <EditOutlined
              onClick={(e) => showPageModal(reqUpdate, item)}
              style={{ marginLeft: 10 }}
            />
          </Tooltip>
          <Popconfirm
            title="您确定要删除吗?"
            onConfirm={() =>
              uiPageAll(item.id, reqDelete).then((res) => reqTreePage())
            }
          >
            <Tooltip title="删除页面">
              <DeleteOutlined style={{ marginLeft: 10 }} />
            </Tooltip>
          </Popconfirm>
        </span>
      </span>
    );
    return title;
  };
  const setTree = (module_data: any) => {
    return module_data.map((item: any) => {
      let _json = { ...item };
      _json.name = setTreeTitle(item);
      _json.children = setTree(item.children);
      return _json;
    });
  };

4. 编辑页面的弹窗

很简单,参考之前的教程进行CURD就行,弹窗部分的代码如下:

export const UiPageForm = ({ visible, cancel, formData, onFinish, treePage }: any) => {

  return (
    <ModalForm
      title={formData.formType === reqCreate ? '创建页面' : '修改页面'}
      initialValues={{
        name: formData.name || null,
      }}
      visible={visible}
      modalProps={{
        destroyOnClose: true,
        onCancel: () => cancel(),
      }}
      onFinish={async (values) => {
        await onFinish(values).then(
          () => {
            //成功保存则返回true
            return true;
          },
          () => {
            //保存失败则返回false
            return false;
          },
        );
      }}
    >
      <ProFormText
        width="md"
        name="name"
        rules={[
          { required: true, message: '页面名称必填' },
          { type: 'string' },
          { max: 18, message: '最多18个字' },
        ]}
        label="页面名称"
        placeholder="请输入页面名称"
      />
    </ModalForm>

5. 列表数据

列表数据有一个特殊的地方是,当元素属于多个页面时,会出现这个标记:

这里在columns中增加对接口返回的is_more_ele这个字段进行判断来展示不同的效果即可,代码如下:

{
      title: '元素名称',
      dataIndex: 'name',
      key: 'name',
      width: 100,
      render: (v: any, record: any) => {
        if (record.is_more_ele) {
          return (
            <span>
              <Tag color="#108ee9">多</Tag>
              {v}
            </span>
          );
        } else {
          return <span>{v}</span>;
        }
      },
    },

6. 联调接口

联调接口没有什么特殊的地方,按照之前的教程进行对接即可。

五、总结

这一章主要是页面树生成使用了递归、以及前端页面用了新的组件Tree,其都是重复的CURD。下一章节会正式开始自动化UI用例模块相关的教学和开发。

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

相关文章