有测试问题请微信联系作者,备注来意(点击此处添加)
240
一名普通的测试打工人;专注自动化测试技术研究、实践、总结、分享、交流。
用我8年+的经历,给大家带来更多实用的干货。
人若有志,就不会在半坡停止。

【接口自动化测试实战】python接口自动化(自建库)

一、自动化项目介绍

1.涉及技术栈

python
requests
pytest
allure

2.实现的功能概述

支持requests各种请求
支持用例之间依赖关系
支持切换环境
支持按脚本的形式编写测试用例
支持生成html报告

二、框架及项目结构

项目目录

api_object:接口参数及扩展参数
config:配置文件,环境、账号、路径设置等
data:测试数据
files:测试文件
log:日志文件目录
report:测试报告目录
test_case:测试用例、测试集相关目录
utils:工具类,基础数据创建及获取等操作。

自建库

  • 自建库(base-api)
    • base_case
      • 通用执行用例类
      • 判断token状态及获取token
      • 断言封装
    • base_requests
      • send_request(请求参数处理)
      • 用例参数解析
      • allure报告展示字段设置
      • send_api(请求封装)
  • 自建库(tools)
    • 测试数据构建工具
    • DataProcess(数据处理)
      • 提取参数处理
      • 文件操作

无自建库可参考目录

api: 主程序目录
comm:公共函数,包括:接口请求基类、请求及相应数据操作基类等
intf_handle:接口操作层,包含:接口初始化、断言等
business:业务实现部分
utils:工具类,包括:读取文件、发送邮件、excel操作、数据库操作、日期时间格式化等
config:配置文件目录,包含yaml配置文件、以及路径配置
data:测试数据目录,用于存放测试数据
temp:临时文件目录,用于存放临时文件
result:结果目录
report:测试报告目录,用于存放生成的html报告
details:测试结果详情目录,用于存放生成的测试用例执行结果excel文件
log:日志文件目录
test:测试用例、测试集相关目录,启动test_suite执行用例文件存放在此
test_case:测试用例存放路径
test_suite:测试模块集,按模块组装用例

三、核心方法设计

接口请求基类

# -*- coding:utf-8 -*-
import datetime
import urllib

import requests

from tools import allure_step, allure_title, logger, allure_step_no, updateDict, get_r, form_data_type_judge
from tools.data_process import DataProcess
from tools.read_file import ReadFile

class BaseRequest(object):
    session = None

    @classmethod
    def get_session(cls):
        """
        单例模式保证测试过程中使用的都是一个session对象
        :return:
        """
        if cls.session is None:
            cls.session = requests.Session()
        return cls.session

    @classmethod
    def send_request(cls, case: dict, reverse=True):
        """
        请求参数处理
        :param case: 读取出来的每一行用例内容,可进行解包
        :param env: 环境名称 默认使用config.yaml server下的 test 后面的基准地址
        return: 响应结果, 预期结果
        """

        title = case.get('title')
        header = case.get('header')
        path = case.get('path')
        method = case.get('method')
        file_obj = case.get('files')
        params = case.get('params')
        data = case.get('data')
        jsonData = case.get('json')
        extract = case.get('extract')
        expect = case.get('expect')
        step = case.get('step')
        # 关联功能用例
        relation = case.get('relation')
        # 自定义断言
        expect_custom = case.get('expect_custom')

        logger.debug(
            f"\n用例进行处理前数据: \n用例标题:{title} \n   请求头:{header} \n 接口路径:{path} \n  params:{params} \n    data:{data} \n    json:{jsonData} \n    file:{file_obj} \n  提取参数:{extract} \n  预期结果:{expect} \n")
        # 如果不传title,就不设置
        if title is not None:
            # allure报告 用例标题
            allure_title(title)

        if step is not None:
            # 报告右侧步骤中增加步骤
            allure_step_no(f'步骤: {step}')

        # 如果传method,默认为GET请求
        if method is None:
            method = 'GET'

        # 处理url、header、data、files、的前置方法
        url = DataProcess.handle_path(path)
        params = DataProcess.handle_data(params)
        data = DataProcess.handle_data(data)
        jsonData = DataProcess.handle_data(jsonData)
        # 每个请求增加_r参数
        params = updateDict(params, "_r", get_r())
        # 判断是否有resign,有的话就添加在params中
        resign = ReadFile.read_config('$.resign')
        if resign != False:
            params = updateDict(params, "resign", resign)

        # 判断是否有bazi参数,有就增加在params,这个参数广告的需要,响应解密
        bazi = ReadFile.read_config('$.bazi')
        if bazi != False:
            params = updateDict(params, "bazi", bazi)

        # 有请求数据再在报告中展示
        requestsData = {}
        if params:
            requestsData.update({'params': params})
        if data:
            # allure报告数据需要
            requestsData.update({'data': data})

            # 再判断data中有没有传"data_type": "multipart/form-data",如果有就处理为二进制方式提交数据(上传文件方式)
            data_handle = form_data_type_judge(data)
            if isinstance(data_handle, tuple):
                # 如果是元组,说明是这种类型,第一个数据是data转为二进制的数据,第二个是content-type的类型
                data = data_handle[0]
                content_type = data_handle[1]
                if header is None:
                    header = {}
                header.update({"Content-Type": content_type})
            else:
                data = data_handle

        # 请求头处理
        header = DataProcess.handle_header(header)

        if jsonData:
            requestsData.update({'json': jsonData})
        allure_step('请求数据', requestsData)
        allure_step_no(f'请求时间: {datetime.datetime.now()}')
        file = DataProcess.handler_files(file_obj)
        # 发送请求
        response = cls.send_api(url, method, header, params, data, jsonData, file)
        # 处理请求前extract在报告中展示
        if extract is not None:
            allure_step("请求前extract", extract)
            # 提取参数
            report_extract = DataProcess.handle_extra(extract, response)
            # 设置报告替换的extract
            if report_extract is not None or report_extract != {} or report_extract != "None":
                logger.info("请求后的extract" + str(report_extract))
                allure_step("请求后extract", report_extract)

        logger.info("当前可用参数池" + str(DataProcess.extra_pool))
        allure_step("当前可用参数池", DataProcess.extra_pool)

        # 如果没有填预期结果,默认断言响应中的success=True
        if expect is None and expect_custom is None:
            if reverse:
                expect = {"$.success": True}
            else:
                expect = {"$.success": False}
        if expect is not None:
            allure_step("请求前expect", expect)

        if expect_custom is not None:
            allure_step("请求前expect_custom", expect_custom)

        # 存储关联的用例id和维护人员
        if relation is not None:
            relationId = relation.get("id")
            if relationId is not None:
                if relation not in DataProcess.extra_pool.get("relations"):
                    DataProcess.extra_pool.get("relations").append(relation)

        return response, expect, expect_custom

    @classmethod
    def send_api(cls, url, method, header=None, params=None, data=None, jsonData=None, file=None, allure=True) -> dict:
        """
        封装请求
        :param url: url
        :param method: get、post...
        :param header: 请求头
        :param params: 查询参数类型,明文传输,一般在url?参数名=参数值
        :param data: 一般用于form表单类型参数
        :param jsonData: json类型参数
        :param file: 文件参数
        :return: 响应结果
        """
        session = cls.get_session()

        res = session.request(method, url, params=params, data=data, json=jsonData, files=file, headers=header)
        try:
            response = res.json()
        except Exception:
            # 这里return 二进制内容,文件下载需要接收
            response = res.content
        if allure:
            allure_step_no(f'响应耗时(s): {res.elapsed.total_seconds()}')

        if isinstance(response, bytes):
            if "html" in str(response):
                response_result = res.text
            else:
                response_result = "响应结果为二进制文件"
        else:
            response_result = response

        if allure:
            allure_step("响应结果", response_result)
        logger.info(
            f'\n最终请求地址:{urllib.parse.unquote(res.url)}\n   请求方法:{method}\n    请求头:{res.request.headers}\n   params:{params}\n     data:{data}\n     json:{jsonData}\n     file:{file}\n  响应数据:{response_result}')

        return response

执行用例类

@classmethod
    def execute(cls, data=None, reverse=True):
        """
        通用执行用例的方法:默认通用断言,断言响应中的success为True
        :param data:
        :return:
        """
        # 运行前先判断accessToken
        cls.__grant_access_token()

        # 调用方法合并path和method
        data = cls.basic_attr(data)

        # 执行用例->所有参数字典丢给send_request处理即可
        response, expect, expect_custom = BaseRequest.send_request(data, reverse)
        # 通用断言
        if expect is not None:
            DataProcess.assert_result(response, expect)
        # 自定义断言
        if expect_custom is not None:
            DataProcess.expect_keyword(response, expect_custom)

        return response, expect, expect_custom

tool-字典合并

def merge_dict(dic1, dic2):
    """
    递归合并两个字典所有数据,有相同的就更新,不相同的就添加
    :param dic1: 基本数据
    :param dic2: 以dic2数据为准,dic1和dic2都有的数据,合并后以dic2为准
    :return: 合并后的字典
    """

    # 类型不同就直接赋值,返回第2个参数数据,是因为我们以第2个数据为准,来更新第1个数据的。
    if type(dic1) != type(dic2):
        return dic2

    # 两个字典深拷贝一下,避免影响之前数据
    obj1 = copy.deepcopy(dic1)
    obj2 = copy.deepcopy(dic2)

    # 都是字典时处理
    if isinstance(obj2, dict):
        for k, v in obj2.items():

            obj1_value = obj1.get(k)
            if obj1_value is None:
                obj1[k] = v
            else:
                obj1[k] = merge_dict(obj1[k], obj2[k])


    elif isinstance(obj2, list):
        for i in range(len(obj2)):
            try:
                obj1[i] = merge_dict(obj1[i], obj2[i])
            except IndexError:
                obj1.append(obj2[i])

    elif isinstance(obj2, tuple):
        for i in range(len(obj2)):
            try:
                # 元组不能修改,先转list再修改后再转回元组
                obj1 = list(obj1)
                obj1[i] = merge_dict(obj1[i], obj2[i])
                obj1 = tuple(obj1)

            except IndexError:
                obj1 += (obj2[i],)


    else:
        # 以第2个参数数据为准,返回obj2
        return obj2

    return obj1

tool-文件处理

import yaml
from tools import extractor


class ReadFile:
    """
    文件操作
    """
    config_dict = None

    @classmethod
    def get_config_dict(cls, config_path='config/config.yaml') -> dict:
        """
        读取配置文件,并且转换成字典,缓存至config_dict
        :param config_path: yaml文件地址, 默认使用当前项目目录下的config/config.yaml
        return cls.config_dict
        """
        if cls.config_dict is None:
            # 指定编码格式解决,win下跑代码抛出错误
            with open(config_path, 'r', encoding='utf-8') as file:
                cls.config_dict = yaml.load(
                    file.read(), Loader=yaml.FullLoader)
        return cls.config_dict

    @classmethod
    def read_config(cls, expr: str = '.'):
        """
        默认读取config目录下的config.yaml配置文件,根据传递的expr jsonpath表达式可任意返回任何配置项
        :param expr: 提取表达式, 使用jsonpath语法,默认值提取整个读取的对象
        return 根据表达式返回的值
        """
        return extractor(cls.get_config_dict(), expr)

    @classmethod
    def read_yaml(cls, path):
        """
        读取yaml文件
        :param path: 文件路径
        :return: 返回读取的文件数据,dict类型
        """

        # 指定编码格式解决,win下跑代码抛出错误
        with open(path, 'r', encoding='utf-8') as file:
            data = yaml.load(file.read(), Loader=yaml.FullLoader)

        return data

用例编写-conftest.py

import pytest
@pytest.fixture
def test_h5_2xxx():
    test_h5_1xxx.execute(data)  # 前置执行用例
    yield  # 以下为后置执行用例
    expect = DataProcess.extra_pool.get('expect')
    data = {
        "data": {
            "searchTimeScope": "0",
            "scope": "2",
            'pageType': 'first',
            'pageSize': '15'
        },
        'expect': expect
    }
    test_h5_3xxx.execute(data)

用例编写

import pytest
from tools import case_data_generate, merge_dict

class Test_xxx:
    @pytest.mark.parametrize("params", case_data_generate(Api_xxx.normal))  # 参数化
    def test_xxx(
            self, params, test_h5_2xxx):  # 前置执行conftest中“test_h5_2xxx用例”
        param, data, expect = params
        request_data = merge_dict(Api_xxx.params_required, {param: data})  # 提取并合并用例参数
        data = {
            "title": f"第一条用例",
            "params": request_data,
            "expect": expect  # 断言
        }
        Api_xxx.execute(data)  # 用例执行请求
posted @ 2024-01-08 10:36  三叔测试笔记  阅读(65)  评论(0编辑  收藏  举报
返回顶部 跳转底部