轻大书城项目

83 阅读3分钟

项目概述

这是一个基于Python的接口自动化测试框架,使用了pytest作为测试执行引擎,支持多种报告格式(allure和tmreport),具备完整的测试流程管理能力。

项目结构分析

  1. 核心目录结构
  • base/ : 基础工具模块,包含API请求处理、ID生成等基础功能
  • common/ : 公共模块,包括断言、日志记录、数据库连接、邮件发送等通用工具
  • conf/ : 配置文件目录,管理环境配置和项目设置
  • data/ : 测试数据目录,支持YAML、CSV等多种数据格式
  • testcase/ : 测试用例目录,按业务模块组织测试用例
  • report/ : 测试报告目录,生成allure和tm两种格式的测试报告
  1. 核心功能模块
测试执行入口 (run.py)

项目通过run.py文件启动测试,支持两种报告类型:

  • allure报告:功能更丰富,支持实时查看
  • tmreport报告:简洁的HTML报告格式
配置管理 (conf/)
  • config.ini: 管理API环境、数据库、Redis、邮件等配置信息
  • setting.py: 项目设置文件
  • operationConfig.py: 配置文件读取工具
测试数据管理 (data/)

支持多种数据格式:

  • YAML文件:用于接口测试参数和预期结果
  • CSV文件:用于批量数据测试
  • XML文件:用于SQL语句管理
测试用例设计 (testcase/)

测试用例按业务模块分类:

  • 单接口测试 (Single interface)
  • 业务场景测试 (Business interface)
  • 产品管理测试 (ProductManager)
核心工具类
  1. API 请求处理 (base/apiutil.py)

    1. 支持参数替换解析(${}表达式)
    2. 处理文件上传接口
    3. 支持多种请求方式(GET/POST等)
    4. 集成allure报告生成
  2. 断言工具 (common/assertions.py)

    1. 字符串包含断言
    2. 相等/不相等断言
    3. 数据库断言
    4. 响应时间断言
    5. 任意值断言
  3. 数据处理

    1. YAML数据读取 (common/readyaml.py)
    2. CSV数据处理 (common/operationcsv.py)
    3. XML数据处理 (common/operxml.py)

测试流程特点

  1. 数据驱动: 使用YAML文件管理测试数据和预期结果
  2. 参数化测试: 通过pytest.mark.parametrize实现测试用例参数化
  3. 动态报告: 集成allure报告,提供详细的测试执行信息
  4. 多环境支持: 通过配置文件支持不同测试环境切换
  5. 数据库验证: 支持数据库断言验证数据一致性
  6. 日志记录: 完整的测试执行日志记录

测试用例示例

测试用例采用YAML格式定义,包含:

  • 接口基本信息(URL、方法、请求头)
  • 测试数据
  • 预期结果验证规则
  • 参数提取规则

这种设计使得测试用例编写简洁明了,非技术人员也能轻松理解和维护测试用例。

整个项目结构清晰,模块划分合理,具备良好的扩展性和维护性,适用于中大型项目的接口自动化测试需求

核心代码

run.py文件是这个自动化测试框架的主程序入口,负责执行测试并生成报告。让我逐行解释其功能:

第1-5行是导入模块部分:

import shutil
import pytest
import os
import webbrowser
from conf.setting import REPORT_TYPE

这里导入了几个关键模块:

  • shutil:用于文件操作,如复制文件
  • pytest:测试框架核心模块
  • os:操作系统接口模块,用于执行系统命令和路径操作
  • webbrowser:用于打开网页浏览器
  • REPORT_TYPE:从配置文件中导入报告类型设置

第7-19行是主程序逻辑部分:

if __name__ == '__main__':
​
    if REPORT_TYPE == 'allure':
        pytest.main(
            ['-s', '-v', '--alluredir=./report/temp', './testcase', '--clean-alluredir',
             '--junitxml=./report/results.xml'])
​
        shutil.copy('./environment.xml', './report/temp')
        os.system(f'allure serve ./report/temp')
​
    elif REPORT_TYPE == 'tm':
        pytest.main(['-vs', '--pytest-tmreport-name=testReport.html', '--pytest-tmreport-path=./report/tmreport'])
        webbrowser.open_new_tab(os.getcwd() + '/report/tmreport/testReport.html')

这段代码的工作流程如下:

  1. 首先检查是否是直接运行该脚本(if __name__ == '__main__'
  2. 根据配置文件中设置的 REPORT_TYPE 值来决定生成哪种类型的测试报告

REPORT_TYPE 设置为 'allure' 时:

  • 使用 pytest.main() 执行测试,参数含义:

    • -s:输出用例打印信息,即不捕获print输出
    • -v:显示详细测试过程
    • --alluredir=./report/temp:指定allure报告数据的存储目录
    • './testcase':指定测试用例所在的目录
    • --clean-alluredir:在生成新报告前清空allure报告目录
    • --junitxml=./report/results.xml:生成JUnit XML格式的测试结果文件
  • 使用 shutil.copy() 将环境配置文件复制到报告目录

  • 使用 os.system() 启动allure服务来展示报告

REPORT_TYPE 设置为 'tm' 时:

  • 使用 pytest.main() 执行测试,参数含义:

    • -vs:输出详细信息和print内容
    • --pytest-tmreport-name=testReport.html:指定生成的tm报告文件名
    • --pytest-tmreport-path=./report/tmreport:指定tm报告存储路径
  • 使用 webbrowser.open_new_tab() 在新浏览器标签页中打开生成的测试报告

这种设计使得框架可以灵活地支持多种报告格式,用户只需在配置文件中更改 REPORT_TYPE 的值即可切换报告类型。

现在我来详细讲解一个测试用例,我选择 testcase/ProductManager/test_productList.py 中的"获取商品列表"测试用例。

测试用例结构分析

具体是 test_get_product_list 方法:

@allure.story(next(c_id) + "获取商品列表")
@pytest.mark.run(order=1)
@pytest.mark.parametrize('base_info,testcase', get_testcase_yaml('./testcase/ProductManager/getProductList.yaml'))
​
def test_get_product_list(self, base_info, testcase):
    allure.dynamic.title(testcase['case_name'])
    RequestBase().specification_yaml(base_info, testcase)

测试数据文件分析

测试数据定义在getProductList.yaml文件中:

- baseInfo:
    api_name: 商品列表
    url: /coupApply/cms/goodsList
    method: Get
    header:
      Content-Type: application/x-www-form-urlencoded;charset=UTF-8
      token: ${get_extract_data(cookie)}
  testCase:
    - case_name: 获取商品列表
      params:
        msgType: getHandsetListOfCust
        page: 1
        size: 20
      validation:
        - contains: { 'error_code': '0000' }
      extract_list:
        goodsId: $.goodsList[*].goodsId

测试执行流程

1. 测试装饰器说明

  • @allure.story:用于在Allure报告中标识测试场景
  • @pytest.mark.run(order=1):指定测试执行顺序为第1个
  • @pytest.mark.parametrize:参数化测试,从YAML文件中读取测试数据

2. 测试执行过程

当执行这个测试用例时,会发生以下步骤:

  1. 数据加载:通过 get_testcase_yaml() 函数读取 getProductList.yaml 文件中的测试数据
  2. 动态标题设置:使用 allure.dynamic.title() 设置测试用例标题为"获取商品列表"
  3. 接口请求执行:调用 RequestBase().specification_yaml(base_info, testcase) 方法处理接口请求

3. 接口请求处理详解

在pythonproject\base\apiutils.py 中

def specification_yaml(self, base_info, test_case):
        """
        接口请求处理基本方法
        :param base_info: yaml文件里面的baseInfo
        :param test_case: yaml文件里面的testCase
        :return:
        """
        try:
            params_type = ['data', 'json', 'params']
            url_host = self.conf.get_section_for_data('api_envi', 'host')
            api_name = base_info['api_name']
            allure.attach(api_name, f'接口名称:{api_name}', allure.attachment_type.TEXT)
            url = url_host + base_info['url']
            allure.attach(api_name, f'接口地址:{url}', allure.attachment_type.TEXT)
            method = base_info['method']
            allure.attach(api_name, f'请求方法:{method}', allure.attachment_type.TEXT)
            header = self.replace_load(base_info['header'])
            allure.attach(api_name, f'请求头:{header}', allure.attachment_type.TEXT)
            
            # 处理cookie
            cookie = None
            if base_info.get('cookies') is not None:
                cookie = eval(self.replace_load(base_info['cookies']))
            case_name = test_case.pop('case_name')
            allure.attach(api_name, f'测试用例名称:{case_name}', allure.attachment_type.TEXT)
            
            # 处理断言
            val = self.replace_load(test_case.get('validation'))
            test_case['validation'] = val
            validation = eval(test_case.pop('validation'))
            
            # 处理参数提取
            extract = test_case.pop('extract', None)
            extract_list = test_case.pop('extract_list', None)
            
            # 处理接口的请求参数
            for key, value in test_case.items():
                if key in params_type:
                    test_case[key] = self.replace_load(value)
​
            # 处理文件上传接口
            file, files = test_case.pop('files', None), None
            if file is not None:
                for fk, fv in file.items():
                    allure.attach(json.dumps(file), '导入文件')
                    files = {fk: open(fv, mode='rb')}
​
            res = self.run.run_main(name=api_name, url=url, case_name=case_name, header=header, method=method,
                                    file=files, cookies=cookie, **test_case)
            status_code = res.status_code
            allure.attach(self.allure_attach_response(res.json()), '接口响应信息', allure.attachment_type.TEXT)
​
            try:
                res_json = json.loads(res.text)  # 把json格式转换成字典字典
                if extract is not None:
                    self.extract_data(extract, res.text)
                if extract_list is not None:
                    self.extract_data_list(extract_list, res.text)
                # 处理断言
                self.asserts.assert_result(validation, res_json, status_code)
            except JSONDecodeError as js:
                logs.error('系统异常或接口未请求!')
                raise js
            except Exception as e:
                logs.error(e)
                raise e
​
        except Exception as e:
            raise e

0. 参数替换

def get_extract_data(self, node_name, randoms=None) -> str:
        """
        获取extract.yaml数据,首先判断randoms是否为数字类型,如果不是就获取下一个node节点的数据
        :param node_name: extract.yaml文件中的key值
        :param randoms: int类型,
        0:随机读取;-1:读取全部,返回字符串形式;-2:读取全部,返回列表形式;其他根据列表索引取值,取第一个值为1,第二个为2,以此类推;
        :return:
        """
        data = self.read.get_extract_yaml(node_name)
        if randoms is not None and bool(re.compile(r'^[-+]?[0-9]*.?[0-9]+([eE][-+]?[0-9]+)?$').match(randoms)):
            randoms = int(randoms)
            data_value = {
                randoms: self.get_extract_order_data(data, randoms),
                0: random.choice(data),
                -1: ','.join(data),
                -2: ','.join(data).split(','),
            }
            data = data_value[randoms]
        else:
            data = self.read.get_extract_yaml(node_name, randoms)
        return data

处理动态参数 ${get_extract_data(cookie)} ,通过get_extract_data方法从 extract.yaml 文件中获取之前接口保存的cookie值

  1. 请求构建 :

拼接完整URL:从配置中获取host + YAML中定义的url路径

设置请求方法:GET

设置请求头:包含Content-Type和token

设置请求参数:msgType, page, size

  1. 发送请求 :

调用 SendRequest().run_main() 发送HTTP请求

  1. 响应处理 :
@classmethod
    def allure_attach_response(cls, response):
        if isinstance(response, dict):
            allure_response = json.dumps(response, ensure_ascii=False, indent=4)
        else:
            allure_response = response
        return allure_response

使用allure_attach_response方法将响应信息附加到Allure报告中

  1. 参数提取 :
def extract_data_list(self, testcase_extract_list, response):
        """
        提取多个参数,支持正则表达式和json提取,提取结果以列表形式返回
        :param testcase_extract_list: yaml文件中的extract_list信息
        :param response: 接口的实际返回值,str类型
        :return:
        """
        try:
            for key, value in testcase_extract_list.items():
                if "(.+?)" in value or "(.*?)" in value:
                    ext_list = re.findall(value, response, re.S)
                    if ext_list:
                        extract_date = {key: ext_list}
                        logs.info('正则提取到的参数:%s' % extract_date)
                        self.read.write_yaml_data(extract_date)
                if "$" in value:
                    # 增加提取判断,有些返回结果为空提取不到,给一个默认值
                    ext_json = jsonpath.jsonpath(json.loads(response), value)
                    if ext_json:
                        extract_date = {key: ext_json}
                    else:
                        extract_date = {key: "未提取到数据,该接口返回结果可能为空"}
                    logs.info('json提取到参数:%s' % extract_date)
                    self.read.write_yaml_data(extract_date)
        except:
            logs.error('接口返回值提取异常,请检查yaml文件extract_list表达式是否正确!')
  • extract_data_list方法提取响应中的 goodsId 字段,保存到 extract.yaml 文件中供后续接口使用
  • 使用JSONPath表达式 $.goodsList[*].goodsId 提取商品列表中的所有商品ID
  1. 断言验证
def assert_result(self, expected, response, status_code):
        """
        断言,通过断言all_flag标记,all_flag==0表示测试通过,否则为失败
        :param expected: 预期结果
        :param response: 实际响应结果
        :param status_code: 响应code码
        :return:
        """
        all_flag = 0
        try:
            logs.info("yaml文件预期结果:%s" % expected)
            # logs.info("实际结果:%s" % response)
            # all_flag = 0
            for yq in expected:
                for key, value in yq.items():
                    if key == "contains":
                        flag = self.contains_assert(value, response, status_code)
                        all_flag = all_flag + flag
                    elif key == "eq":
                        flag = self.equal_assert(value, response)
                        all_flag = all_flag + flag
                    elif key == 'ne':
                        flag = self.not_equal_assert(value, response)
                        all_flag = all_flag + flag
                    elif key == 'rv':
                        flag = self.assert_response_any(actual_results=response, expected_results=value)
                        all_flag = all_flag + flag
                    elif key == 'db':
                        flag = self.assert_mysql_data(value)
                        all_flag = all_flag + flag
                    else:
                        logs.error("不支持此种断言方式")

        except Exception as exceptions:
            logs.error('接口断言异常,请检查yaml预期结果值是否正确填写!')
            raise exceptions

        if all_flag == 0:
            logs.info("测试成功")
            assert True
        else:
            logs.error("测试失败")
            assert False
  • 调用 assert_result 方法进行断言
  • 使用 contains 断言模式验证响应中包含 'error_code': '0000' ,表示请求成功

4. 断言机制讲解

 def contains_assert(self, value, response, status_code):
        """
        字符串包含断言模式,断言预期结果的字符串是否包含在接口的响应信息中
        :param value: 预期结果,yaml文件的预期结果值
        :param response: 接口实际响应结果
        :param status_code: 响应状态码
        :return: 返回结果的状态标识
        """
        # 断言状态标识,0成功,其他失败
        flag = 0
        for assert_key, assert_value in value.items():
            if assert_key == "status_code":
                if assert_value != status_code:
                    flag += 1
                    allure.attach(f"预期结果:{assert_value}\n实际结果:{status_code}", '响应代码断言结果:失败',
                                  attachment_type=allure.attachment_type.TEXT)
                    logs.error("contains断言失败:接口返回码【%s】不等于【%s】" % (status_code, assert_value))
            else:
                resp_list = jsonpath.jsonpath(response, "$..%s" % assert_key)
                if isinstance(resp_list[0], str):
                    resp_list = ''.join(resp_list)
                if resp_list:
                    assert_value = None if assert_value.upper() == 'NONE' else assert_value
                    if assert_value in resp_list:
                        logs.info("字符串包含断言成功:预期结果【%s】,实际结果【%s】" % (assert_value, resp_list))
                    else:
                        flag = flag + 1
                        allure.attach(f"预期结果:{assert_value}\n实际结果:{resp_list}", '响应文本断言结果:失败',
                                      attachment_type=allure.attachment_type.TEXT)
                        logs.error("响应文本断言失败:预期结果为【%s】,实际结果为【%s】" % (assert_value, resp_list))
        return flag

contains_assert方法中

  1. 遍历预期结果中的每个键值对
  2. 如果键是 status_code ,则与响应状态码进行比较
  3. 其他键通过JSONPath在响应中查找对应值
  4. 使用 in 操作符检查预期值是否包含在实际值中
  5. 记录断言结果到日志和Allure报告中

测试用例特点

  1. 数据驱动:测试数据与测试逻辑分离,便于维护和扩展
  2. 接口依赖:通过 ${get_extract_data()} 实现接口间参数传递
  3. 参数提取:自动提取响应数据供后续接口使用
  4. 多种断言:支持包含断言、相等断言、数据库断言等多种方式
  5. 报告集成:与Allure报告深度集成,提供详细的测试信息
  6. 顺序执行:通过 @pytest.mark.run(order=) 控制测试执行顺序

这个测试用例完整地展示了该自动化测试框架的核心功能:数据驱动测试、接口依赖处理、参数提取、断言验证和报告生成。

测试结果截图