晨星商城自动化测试

122 阅读8分钟

一、项目简介

本项目是基于Python语言开发的接口自动化测试框架,采用pytest作为测试执行引擎,结合yaml文件实现数据驱动测试。框架设计注重模块化、灵活性和易维护性,支持丰富的断言方式、动态参数处理及测试报告生成。

二、项目结构

pythonproject/
├── base/                  # 核心业务逻辑模块
│   ├── apiutil.py         # 核心接口请求与断言执行
│   ├── apiutil_business.py# 业务场景接口处理
│   ├── generateId.py      # ID生成工具
│   └── removefile.py      # 文件删除工具
├── common/                # 公共功能模块
│   ├── assertions.py      # 断言封装
│   ├── connection.py      # 数据库及服务连接
│   ├── debugtalk.py       # 动态参数与提取
│   ├── readyaml.py        # yaml文件读取
│   ├── recordlog.py       # 日志管理
│   ├── sendrequest.py     # 请求发送封装
├── conf/                  # 配置文件
│   ├── setting.py         # 项目配置
│   ├── operationConfig.py # 运行配置
│   └── config.ini         # ini配置
├── testcase/              # 测试用例目录
│   ├── Single interface/  # 单接口测试
│   ├── Business interface/# 业务场景测试
├── report/                # 测试报告
│   ├── allureReport/      # Allure报告
├── logs/                  # 日志文件
├── conftest.py            # fixture
├── run.py                 # 测试启动脚本
└── pytest.ini             # pytest配置

三、核心代码解析

1. 程序入口 run.py

import shutil
import pytest
import os
import webbrowser
from conf.setting import REPORT_TYPE
​
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')
allure报告
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')
参数说明:
  • -s:显示控制台输出。
  • -v:显示更详细的测试信息。
  • --alluredir=./report/temp:将 allure 报告数据文件输出到 ./report/temp 目录。
  • ./testcase:指定测试用例目录。
  • --clean-alluredir:运行前清理临时报告目录。
  • --junitxml=./report/results.xml:同时生成一个 junit xml 格式的测试结果文件。

2. 测试用例示例

获取商品列表
import allure
import pytest
​
from base.generateId import m_id, c_id
from base.apiutil import RequestBase
from common.readyaml import get_testcase_yaml
​
​
@allure.feature(next(m_id) + '商品管理(单接口)')
class TestLogin:
​
    @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)
代码说明
  • next(m_id):调用生成器获取一个新的模块id。
  • next(c_id):从用例 ID 生成器里拿一个id拼上 获取商品列表 。
  • @allure.feature():用来给当前类标记一个 功能点,在报告里是一级标题。
  • @allure.story():给用例打上 子功能标签,在报告里是二级标题。
  • @pytest.mark.run(order=1):指定执行顺序,这个用例在本类里最先执行。
  • @pytest.mark.parametrize():参数化运行,每条yaml里的一组数据会单独生成一条用例。
id生成器
def generate_module_id():
    """
    生成测试模块编号,为了保证allure报告的顺序与pytest设定的执行顺序保持一致
    """
    for i in range(1, 1000):
        module_id = 'M' + str(i).zfill(2) + '_'
        yield module_id
​
​
def generate_testcase_id():
    """
    生成测试用例编号
    """
    for i in range(1, 10000):
        case_id = 'C' + str(i).zfill(2) + '_'
        yield case_id
​
​
m_id = generate_module_id()
c_id = generate_testcase_id()

3. YAML文件读取

yaml文件读取,支持异常处理和多用例读取。

import yaml
import traceback
import os
from common.recordlog import logs
from conf.operationConfig import OperationConfig
from conf.setting import FILE_PATH
from yaml.scanner import ScannerError
​
def get_testcase_yaml(file):
    testcase_list = []
    try:
        with open(file, 'r', encoding='utf-8') as f:
            data = yaml.safe_load(f)
            if len(data) <= 1:
                yam_data = data[0]
                base_info = yam_data.get('baseInfo')
                for ts in yam_data.get('testCase'):
                    param = [base_info, ts]
                    testcase_list.append(param)
                return testcase_list
            else:
                return data
    except UnicodeDecodeError:
        logs.error(f"[{file}]文件编码格式错误,--尝试使用utf-8编码解码YAML文件时发生了错误,请确保你的yaml文件是UTF-8格式!")
    except FileNotFoundError:
        logs.error(f'[{file}]文件未找到,请检查路径是否正确')
    except Exception as e:
        logs.error(f'获取【{file}】文件数据时出现未知错误: {str(e)}')
​
class ReadYamlData:
​
    def __init__(self, yaml_file=None):
        if yaml_file is not None:
            self.yaml_file = yaml_file
        else:
            pass
        self.conf = OperationConfig()
        self.yaml_data = None
​
    @property
    def get_yaml_data(self):
        try:
            with open(self.yaml_file, 'r', encoding='utf-8') as f:
                self.yaml_data = yaml.safe_load(f)
                return self.yaml_data
        except Exception:
            logs.error(str(traceback.format_exc()))
​
    def write_yaml_data(self, value):
        file = None
        file_path = FILE_PATH['EXTRACT']
        if not os.path.exists(file_path):
            os.system(file_path)
        try:
            file = open(file_path, 'a', encoding='utf-8')
            if isinstance(value, dict):
                write_data = yaml.dump(value, allow_unicode=True, sort_keys=False)
                file.write(write_data)
            else:
                logs.info('写入[extract.yaml]的数据必须为dict格式')
        except Exception:
            logs.error(str(traceback.format_exc()))
        finally:
            file.close()
​
    def clear_yaml_data(self):
        with open(FILE_PATH['EXTRACT'], 'w') as f:
            f.truncate()
​
    def get_extract_yaml(self, node_name, second_node_name=None):
        if os.path.exists(FILE_PATH['EXTRACT']):
            pass
        else:
            logs.error('extract.yaml不存在')
            file = open(FILE_PATH['EXTRACT'], 'w')
            file.close()
            logs.info('extract.yaml创建成功!')
        try:
            with open(FILE_PATH['EXTRACT'], 'r', encoding='utf-8') as rf:
                ext_data = yaml.safe_load(rf)
                if second_node_name is None:
                    return ext_data[node_name]
                else:
                    return ext_data[node_name][second_node_name]
        except Exception as e:
            logs.error(f"【extract.yaml】没有找到:{node_name},--%s" % e)
​
    def get_testCase_baseInfo(self, case_info):
        pass
​
    def get_method(self):
        yal_data = self.get_yaml_data()
        metd = yal_data[0].get('method')
        return metd
​
    def get_request_parame(self):
        data_list = []
        yaml_data = self.get_yaml_data()
        del yaml_data[0]
        for da in yaml_data:
            data_list.append(da)
        return data_list
代码说明
  • get_testcase_yaml:读取测试用例 YAML 文件
  • get_yaml_data:读取当前对象的 yaml_file 内容并返回
  • write_yaml_data:向固定路径 extract.yaml 追加写入一段 YAML 格式的内容。
  • clear_yaml_data:清空 extract.yaml 文件内容
  • get_extract_yaml:从 extract.yaml 中读取某个节点值
  • get_method(self): 获取测试方法类型
  • get_request_parame(self):获取请求参数列表

4. 核心执行方法

解析yaml测试用例,发送请求,执行断言,提取参数。

import json
import re
from json.decoder import JSONDecodeError
import allure
import jsonpath
from common.assertions import Assertions
from common.debugtalk import DebugTalk
from common.readyaml import get_testcase_yaml, ReadYamlData
from common.recordlog import logs
from common.sendrequest import SendRequest
from conf.operationConfig import OperationConfig
from conf.setting import FILE_PATH

class RequestBase:
    def __init__(self):
        self.run = SendRequest()
        self.conf = OperationConfig()
        self.read = ReadYamlData()
        self.asserts = Assertions()
    def replace_load(self, data):
        str_data = data
        if not isinstance(data, str):
            str_data = json.dumps(data, ensure_ascii=False)
        for i in range(str_data.count('${')):
            if '${' in str_data and '}' in str_data:
                start_index = str_data.index('$')
                end_index = str_data.index('}', start_index)
                ref_all_params = str_data[start_index:end_index + 1]
                func_name = ref_all_params[2:ref_all_params.index("(")]
                func_params = ref_all_params[ref_all_params.index("(") + 1:ref_all_params.index(")")]
                extract_data = getattr(DebugTalk(), func_name)(*func_params.split(',') if func_params else "")
​
                if extract_data and isinstance(extract_data, list):
                    extract_data = ','.join(e for e in extract_data)
                str_data = str_data.replace(ref_all_params, str(extract_data))
        if data and isinstance(data, dict):
            data = json.loads(str_data)
        else:
            data = str_data
        return data
​
    def specification_yaml(self, base_info, test_case):
        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 = 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) 
                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
​
    @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
​
    def extract_data(self, testcase_extarct, response):
        try:
            pattern_lst = ['(.*?)', '(.+?)', r'(\d)', r'(\d*)']
            for key, value in testcase_extarct.items():
                for pat in pattern_lst:
                    if pat in value:
                        ext_lst = re.search(value, response)
                        if pat in [r'(\d+)', r'(\d*)']:
                            extract_data = {key: int(ext_lst.group(1))}
                        else:
                            extract_data = {key: ext_lst.group(1)}
                        self.read.write_yaml_data(extract_data)
                if '$' in value:
                    ext_json = jsonpath.jsonpath(json.loads(response), value)[0]
                    if ext_json:
                        extarct_data = {key: ext_json}
                        logs.info('提取接口的返回值:', extarct_data)
                    else:
                        extarct_data = {key: '未提取到数据,请检查接口返回值是否为空!'}
                    self.read.write_yaml_data(extarct_data)
        except Exception as e:
            logs.error(e)
​
    def extract_data_list(self, testcase_extract_list, response):
        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表达式是否正确!')
​
​
if __name__ == '__main__':
    case_info = get_testcase_yaml(FILE_PATH['YAML'] + '/LoginAPI/login.yaml')[0]
    req = RequestBase()
    res = req.specification_yaml(case_info)
    print(res)
流程

读取 YAML -> 替换动态变量 -> 发送请求 -> 校验断言 -> 提取数据 -> 生成 Allure 报告

5.断言

断言方法封装,支持字符串包含、相等、不等、正则、数据库断言等。

import traceback
import allure
import jsonpath
import operator
​
from common.recordlog import logs
from common.connection import ConnectMysql
​
​
class Assertions:
    def contains_assert(self, value, response, status_code):
        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
​
    def equal_assert(self, expected_results, actual_results, statuc_code=None):
        flag = 0
        if isinstance(actual_results, dict) and isinstance(expected_results, dict):
            common_keys = list(expected_results.keys() & actual_results.keys())[0]
            new_actual_results = {common_keys: actual_results[common_keys]}
            eq_assert = operator.eq(new_actual_results, expected_results)
            if eq_assert:
                logs.info(f"相等断言成功:接口实际结果:{new_actual_results},等于预期结果:" + str(expected_results))
                allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '相等断言结果:成功',
                              attachment_type=allure.attachment_type.TEXT)
            else:
                flag += 1
                logs.error(f"相等断言失败:接口实际结果{new_actual_results},不等于预期结果:" + str(expected_results))
                allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '相等断言结果:失败',
                              attachment_type=allure.attachment_type.TEXT)
        else:
            raise TypeError('相等断言--类型错误,预期结果和接口实际响应结果必须为字典类型!')
        return flag
​
    def not_equal_assert(self, expected_results, actual_results, statuc_code=None):
        flag = 0
        if isinstance(actual_results, dict) and isinstance(expected_results, dict):
            common_keys = list(expected_results.keys() & actual_results.keys())[0]
            new_actual_results = {common_keys: actual_results[common_keys]}
            eq_assert = operator.ne(new_actual_results, expected_results)
            if eq_assert:
                logs.info(f"不相等断言成功:接口实际结果:{new_actual_results},不等于预期结果:" + str(expected_results))
                allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '不相等断言结果:成功',
                              attachment_type=allure.attachment_type.TEXT)
            else:
                flag += 1
                logs.error(f"不相等断言失败:接口实际结果{new_actual_results},等于预期结果:" + str(expected_results))
                allure.attach(f"预期结果:{str(expected_results)}\n实际结果:{new_actual_results}", '不相等断言结果:失败',
                              attachment_type=allure.attachment_type.TEXT)
        else:
            raise TypeError('不相等断言--类型错误,预期结果和接口实际响应结果必须为字典类型!')
        return flag
​
    def assert_response_any(self, actual_results, expected_results):
        flag = 0
        try:
            exp_key = list(expected_results.keys())[0]
            if exp_key in actual_results:
                act_value = actual_results[exp_key]
                rv_assert = operator.eq(act_value, list(expected_results.values())[0])
                if rv_assert:
                    logs.info("响应结果任意值断言成功")
                else:
                    flag += 1
                    logs.error("响应结果任意值断言失败")
        except Exception as e:
            logs.error(e)
            raise
        return flag
​
    def assert_response_time(self, res_time, exp_time):
        try:
            assert res_time < exp_time
            return True
        except Exception as e:
            logs.error('接口响应时间[%ss]大于预期时间[%ss]' % (res_time, exp_time))
            raise
​
    def assert_mysql_data(self, expected_results):
        flag = 0
        conn = ConnectMysql()
        db_value = conn.query_all(expected_results)
        if db_value is not None:
            logs.info("数据库断言成功")
        else:
            flag += 1
            logs.error("数据库断言失败,请检查数据库是否存在该数据!")
        return flag
​
    def assert_result(self, expected, response, status_code):
        all_flag = 0
        try:
            logs.info("yaml文件预期结果:%s" % expected)
            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
代码说明
  • contains_assert:包含断言,检查接口返回值中是否“包含”某些关键字
  • equal_assert:相等断言,检查实际返回的字典中指定字段值与预期值是否相等
  • not_equal_assert:不相等断言,检查实际返回的字典中指定字段值与预期值是否不相等
  • assert_response_any:任意字段值断言,断言某个 key 在响应里存在,并且值相等于预期值
  • assert_response_time:响应耗时断言,检查接口响应时间 < 预期时间
  • assert_mysql_data:根据 SQL 查询数据库,检查是否有返回值
  • assert_result:接收yaml里定义的validation列表,循环每个断言规则,调用对应的断言方法,汇总各个断言的返回 flag,判断最终结果

四、Allure报告

image.png

image.png