接口自动化框架
总体工程结构
一、case_datas目录
该目录存放Excel格式的接口用例,文件中包含接口的请求头、请求路径、请求方式、请求体、预期结果等信息
1、case_id
根据自己项目的要求可以自定义命名
2、title
请求接口的名称
建议根据模块+接口名称来自定义命名;如:“项目文档-验证文件夹A编辑文档A文档编码长度大于200功能”
3、headers
请求接口的请求头,可根据接口的实际情况填写;
一般接口的请求头为:{“Content-Type”: “application/json;charset=UTF-8”}
上传接口的请求头为:{},PS:目前EDC的上传接口请求头为空,若填写请求头接口会报错
4、path
请求接口的URL,可根据各自项目接口的实际情况填写
5、method
请求接口的请求方式,可根据接口的实际情况填写,常见的请求方式有:get、post、delete、put
6、files
(1)若请求接口不需要上传文件,则该项默认填写“{}”
(2)若请求接口需要上传文件,则需要填写该项;填写规则如下:
{"file_name":"文件名称","file_path":"/文件名称","content_type":"文档内容类型"}
说明:
file_name:需要上传文件的名称,包含后缀
file_path:/需要上传文件的名称,包含后缀;注意必须加“/”
content_type:文档内容类型,查看方式如下所示:
上传pdf格式文件:
上传excel格式文件:
上传word格式文件:
7、json
请求接口的请求体数据格式
(1)若请求接口没有请求体,则该项默认要填写“{}”;如:get接口
(2)若请求接口存在请求体,可根据接口文档中的实际情况填写
8、expected
请求接口的预期结果
必须是key-value的格式,可以断言状态码,也可以断言响应结果中的内容;多个预期结果使用英文逗号分隔
9、extractor(参数提取)
处理接口间的依赖关系
(1)获取当前接口响应结果中的参数,可参考:【干货】接口自动化测试Jsonpath的使用
(3)接口间有依赖关系时,下游接口接收上游接口返回的参数作为下游接口的入参,格式为:#参数#,参考下图示例:
二、common目录
common目录下存放公共方法,主要包括:
1、excel_handler.py:读取/写入excel的方法
import openpyxl # 使用pip install openpyxl命令安装
class ExcelHandler:
def __init__(self, fpath):
self.fpath = fpath
def read(self, sheet_name):
"""读取数据"""
# 打开文件
wb = openpyxl.open(self.fpath)
# 获取表格
ws = wb[sheet_name]
data = list(ws.values)
# 关闭文件
wb.close()
header = data[0]
all_data = []
for row in data[1:]:
row_dict = dict(zip(header, row))
all_data.append(row_dict)
return all_data
def write(self, sheet_name, data, row, column):
"""写入excel数据"""
wb = openpyxl.load_workbook(self.fpath)
# 获取表格
ws = wb[sheet_name]
ws.cell(row=row, column=column).value = data
# 通过workbook 保存和关闭
wb.save(self.fpath)
wb.close()
2、logger_handler.py:日志记录的方法
import logging
import logging.handlers
import os
import time
from config.path import logs_path # 日志存放目录
class LoggerUtil(logging.Logger):
def __init__(self,
name='root',
logger_level='DEBUG',
stream_handler_level='DEBUG',
file_handler_level='INFO',
fmt_str="[%(asctime)s] [%(levelname)s] [%(filename)s] [line %(lineno)s] %(message)s"):
# 获取日志收集器 logger
super().__init__(name, logger_level)
# self == 收集器
# 修改log保存位置
timestamp = time.strftime("%Y-%m-%d", time.localtime())
logfilename = '%s.log' % timestamp
logfilepath = os.path.join(logs_path, logfilename)
# 根据日志文件大小拆分文件;若日志大于1024 * 1024 * 50时,则重新生成一个日志文件名称
rotatingFileHandler = logging.handlers.RotatingFileHandler(filename=logfilepath,
maxBytes=1024 * 1024 * 50,
backupCount=5,
encoding='utf-8')
fmt = logging.Formatter(fmt_str)
# 日志处理器
handler = logging.StreamHandler()
handler.setLevel(stream_handler_level)
self.addHandler(handler)
handler.setFormatter(fmt)
rotatingFileHandler.setFormatter(fmt)
# 文件处理器
rotatingFileHandler.setLevel(file_handler_level)
self.addHandler(rotatingFileHandler)
logger = LoggerUtil()
3、yaml_handler.py:操作yaml文件的方法
import yaml # 使用pip insatll PyYAML命令安装
def read_yaml(fpath):
with open(fpath, encoding='utf-8') as f:
data = yaml.load(f, Loader=yaml.SafeLoader)
return data
4、oracle_handler.py:操作oracle数据库的方法
import cx_Oracle # 使用pip install cx-Oracle命令安装
from common.logger_handler import logger # 打印日志
class OracleHandler:
def __init__(self,
host='',
port='',
user='',
password='',
database='ORCL',
):
self.conn = cx_Oracle.connect(user=user,
password=password,
dsn='{}:{}/{}'.format(host, port, database))
logger.info("连接Oracle数据库成功.")
def query_one(self, sql):
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
data = self.cursor.fetchone()
logger.info("查询Oracle一条数据.")
self.cursor.close()
return data
def query_all(self, sql):
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
data = self.cursor.fetchall()
logger.info("查询Oracle多条数据.")
self.cursor.close()
return data
def query(self, sql, one=False):
# 结果是个list
if one:
return self.query_one(sql)
return self.query_all(sql)
def delete_cursor(self, sql):
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
logger.info("成功删除Oracle数据.")
self.cursor.close()
def close(self):
self.conn.close()
logger.info("关闭Oracle数据库成功.")
# 入参格式如下
# db_sql = OracleHandler().query("SELECT * FROM EDC.QUESTION_TYPES WHERE ID = '97532617-2e02-441d-9a2e-5f2ee4e0399a'",one=True)
# print(db_sql)
5、mysql_handler.py:操作mysql数据库的方法
import pymysql # 使用pip install PyMySQL命令安装
from pymysql.cursors import DictCursor
from common.logger_handler import logger # 添加日志
class MySQLHandler:
def __init__(self,
host='',
port=3306,
user='',
password='',
charset='utf8',
# 指定数据库
database='',
cursorclass=DictCursor
):
try:
self.conn.ping()
except:
self.conn = pymysql.connect(host=host,
port=port,
user=user,
password=password,
charset=charset,
# 指定数据库
database=database,
cursorclass=cursorclass)
logger.info("连接MySQL数据库成功.")
def query_one(self, sql):
"""查询一条数据"""
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
data = self.cursor.fetchone()
logger.info("查询MySQL一条数据.")
self.cursor.close()
return data
def query_all(self, sql):
"""查询多条数据"""
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
data = self.cursor.fetchall()
logger.info("查询MySQL多条数据.")
self.cursor.close()
return data
def query(self, sql, one=False):
# 结果是个list
if one:
return self.query_one(sql)
return self.query_all(sql)
def delete_cursor(self, sql):
"""删除数据"""
self.cursor = self.conn.cursor()
self.cursor.execute(sql)
# 事务提交
self.conn.commit()
logger.info("成功删除MySQL数据.")
self.cursor.close()
def close(self):
self.conn.close()
logger.info("关闭MySQL数据库成功.")
# 入参方法如下
# db_sql = DBHandle().query("select leave_amount from member where mobile_phone='1555555555'",one=True)
# print(db_sql)
6、sftp_handler.py:操作后台服务器、上传/下载文件的方法
import paramiko # 使用pip install paramiko命令安装
from common.logger_handler import logger # 打印日志
class Sftp:
def __init__(self,
host='',
port=22,
username='',
password=''):
self.host = host
self.port = port
self.username = username
self.password = password
def sftp_exec_command(self,command):
"""在服务器执行命令"""
try:
# 1.创建SSH对象
ssh_client = paramiko.SSHClient()
# 2.允许连接不在know_hosts文件中的主机
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# 3.连接Linux服务器
ssh_client.connect(hostname=self.host,
port=self.port,
username=self.username,
password=self.password)
std_in, std_out, std_err = ssh_client.exec_command(command)
# result = stdout.read()
for line in std_out:
print(line.strip("\n"))
ssh_client.close()
except Exception as e:
logger.error(e)
def sftp_upload_file(self,server_path, local_path):
"""向服务器上传文件"""
try:
transport = paramiko.Transport((self.host, self.port))
transport.connect(username=self.username, password=self.password)
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.put(local_path, server_path)
transport.close()
except Exception as e:
logger.info(e)
def sftp_down_file(self,server_path, local_path):
"""从服务器下载文件"""
try:
transport = paramiko.Transport((self.host, self.port))
transport.connect(username='user', password='password')
sftp = paramiko.SFTPClient.from_transport(transport)
sftp.get(server_path, local_path)
transport.close()
except Exception as e:
logger.info(e)
注意:4、5、6模块根据各自项目实际情况选择使用
三、config目录
config下存放项目测试账号、环境信息文件,主要包括:
1、env_config.yaml
环境信息,可以将URL中的公共部分写入该文件
# 书格式为:
# 名称: https://xx.xx.xx.xx:port
# 如下:
env_url: https://10.10.10.10:8080
# 提示:名称可以自定义,名称的冒号后面有个空格
2、mysql_config.yaml
mysql库连接信息,可以将host、port、username、password等写入该文件
# 如下:
db:
user: 'root' # 用户名
host: '10.10.10.10' # ip
port: 3306 # 端口号
password: '123456' # 登录密码
charset: 'utf8'
database: 'abc' # 数据库子库
# 提示:名称可以自定义,名称的冒号后面有个空格
3、oracle_config.yaml
oracle库连接信息,可以将host、port、username、password等写入该文件
# 如下:
db:
user: 'root' # 用户名
host: '10.10.10.10' # ip
port: 1521 # 端口号
password: '123456' # 登录密码
database: 'ORCL'
# 提示:名称可以自定义,名称的冒号后面有个空格
4、user_config.yaml
登录环境的账号信息
# 注释, key后面的: 后必须加空格分开 key value
d_user:
username: 'xiaoming'
password: '123456'
p_user:
username: 'xiaohua'
password: '123456'
# 若单个用户时,直接写username/password也可以;若多个用户时,建议在username/password上再包一层,如:d_user进行区分
5、path.py
获取reports(测试报告)、logs(日志目录)、case_datas(接口信息)路径
import os
# 动态获取路径
config_path = os.path.dirname(os.path.abspath(__file__))
# 获取项目根目录
root_path = os.path.dirname(config_path)
# reports 路径
reports_path = os.path.join(root_path, 'reports')
# 若reports_path不存在,则新建
if not os.path.exists(reports_path):
os.mkdir(reports_path)
# log 路径
logs_path = os.path.join(root_path, 'logs')
# 若logs_path不存在,则新建
if not os.path.exists(logs_path):
os.mkdir(logs_path)
# data 路径
data_path = os.path.join(root_path, 'case_datas')
四、logs目录
该目录存放测试过程中的日志信息,文件中主要包含日志时间、日志等级、日志产生文件、日志产生行、日志内容等信息,其中日志内容用户可以自定义在需要打印日志的文件中添加。
五、middleware目录
该目录存放测试用例和用例执行入口的中间层文件,主要包含:数据库连接、配置文件内容读取、造数据(时间戳、时间、日期和其他变量等)、数据动态替换方法类等
handler.py
import datetime
import os, re
import time
from pymysql.cursors import DictCursor
from common.yaml_handler import read_yaml # 导入read_yaml
from common.excel_handler import ExcelHandler # 导入ExcelHandler
from common.mysql_handler import MySQLHandler # 导入MySQLHandler
from common.oracle_handler import OracleHandler # 导入OracleHandler
from config import path # 导入path
class MidMySQLHandler(MySQLHandler):
"""连接MySQL数据库"""
def __init__(self):
# 读取mysql_config.yaml文件内容
mysql_path = os.path.join(path.config_path, 'mysql_config.yaml')
mysql_config = read_yaml(mysql_path)
# 继承MySQLHandler
super().__init__(host=mysql_config['db']['host'],
port=mysql_config['db']['port'],
user=mysql_config['db']['user'],
password=mysql_config['db']['password'],
charset=mysql_config['db']['charset'],
# 指定数据库
database=mysql_config['db']['database'],
cursorclass=DictCursor)
class MidOracleHandler(OracleHandler):
"""连接Oracle数据库"""
def __init__(self):
# 读取oracle_config.yaml文件内容
oracle_path = os.path.join(path.config_path, 'oracle_config.yaml')
oracle_config = read_yaml(oracle_path)
# 继承OracleHandler
super().__init__(host=oracle_config['db']['host'],
port=oracle_config['db']['port'],
user=oracle_config['db']['user'],
password=oracle_config['db']['password'],
# 指定数据库
database=oracle_config['db']['database'])
class Handler():
"""任务:中间层。 common 和 调用层。
使用项目的配置数据,填充common模块
"""
# 获取环境信息
env_path = os.path.join(path.config_path, 'env_config.yaml')
env_config = read_yaml(env_path)
# 获取用户信息
user_path = os.path.join(path.config_path, 'user_config.yaml')
user_config = read_yaml(user_path)
# mysql
mysql_path = os.path.join(path.config_path, 'mysql_config.yaml')
mysql_config = read_yaml(mysql_path)
# 获取excel测试用例信息
excel_file = os.path.join(path.data_path, 'case_datas.xlsx')
excel = ExcelHandler(excel_file)
# 需要动态替换 #...# 的数据;将Excel测试用例中两个#之间的值进行替换
before_day = (datetime.datetime.now() + datetime.timedelta(days=-1)).strftime("%Y-%m-%d") # 昨天
now_day = datetime.datetime.now().strftime("%Y-%m-%d") # 今天
next_day = (datetime.datetime.now() + datetime.timedelta(days=+1)).strftime("%Y-%m-%d") # 明天
week_day = (datetime.datetime.now() + datetime.timedelta(days=-6)).strftime("%Y-%m-%d") # 近一周
month_day = (datetime.datetime.now() + datetime.timedelta(days=-28)).strftime("%Y-%m-%d") # 近一月
now_time_stamp = int(time.time()) # 获取当前时间戳,单位:秒
now_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 获取当前时间
@classmethod
def replace_data(cls, string, pattern='#(.*?)#'): # 将Excel测试用例中两个#之间的值进行替换
"""数据动态替换,解决接口依赖问题"""
# pattern = '#(.*?)#'
results = re.finditer(pattern=pattern, string=string)
for result in results:
old = result.group()
key = result.group(1)
new = str(getattr(cls, key, ''))
string = string.replace(old, new)
return string
六、reports目录
该目录存放测试报告,报告类型一般为allure或html类型,可任选其一
1、allure测试报告配置,可参考:allure报告配置
2、html测试报告需要导入三方库,安装命令为:pip install pytest-html
七、tests目录
该目录作为单个测试用例执行的入口,文件中主要包含读取Excel中的测试用例并参数化、发送接口请求、对响应结果断言、设置Handler对应的属性(与middleware目录下handler.py文件中的类方法配合使用;若接口间没有依赖,则该部分可以不需要)
tests目录下的“.py”文件要以“test”开头或结尾。
import pytest, requests, json, allure, os
from jsonpath import jsonpath
from common.logger_handler import logger
from middleware.handler import Handler
# 读取Excel测试用例中的sheet页名称,可以自定义
excel_datas = Handler.excel.read('sheet1')
@allure.title("自定义") # allure测试报告的title,一般根据模块自定义
@pytest.mark.parametrize('datas', excel_datas) # 参数化读取到的Excel测试用例
# 函数名称要以“test”开头或结尾
def test_project_doc(datas, login_cookie): # login_cookie为登录用户的Cookie信息,该值来源于conftest.py文件
datas = json.dumps(datas)
# 动态替换依赖值
datas = Handler.replace_data(datas)
# 转化成字典
datas = json.loads(datas)
allure.dynamic.title(datas["title"]) # allure测试报告的title,可以自定义,我这里读取的是Excel用例中title列的值
allure.dynamic.description("测试{}接口.".format(datas["title"])) # allure测试报告的description,可以自定义,我这里读取的是Excel用例中title列的值
headers = json.loads(datas["headers"]) # 获取Excel用例中header列的值
headers["Cookie"] = login_cookie["cookie"] # 将Cookie值传入请求头
with allure.step('请求方式'): # 测试步骤
allure.attach('{}'.format(datas["method"]), name='{}请求方式'.format(datas["title"]))
with allure.step('请求路径'): # 测试步骤
allure.attach('{}'.format(datas["path"]), name='{}请求路径'.format(datas["title"]))
with allure.step('请求体'): # 测试步骤
allure.attach('{}'.format(datas["data"]), name='{}请求体'.format(datas["title"]))
logger.info("{}请求路径为:{},请求体为:{}".format(datas["title"], datas["path"], datas["data"]))
files = json.loads(datas["files"])
# 判断Excel用例中title列的值如果包含“上传”则获取“file_name”列的值;
# 如果是需要上传文件的接口,建议在Excel用例中title列通过关键字标识下,我这里使用的是“上传”。
if "上传" in datas["title"]:
config_path = os.path.dirname(os.path.abspath(__file__))
root_path = os.path.dirname(config_path)
data_path = os.path.join(root_path, 'upload_files')
files = {"file": (files["file_name"], open(data_path + files["file_path"], "rb"), files["content_type"])}
# 发送请求
resp = requests.request(method=datas["method"],
url=Handler.env_config["env_url"] + datas["path"],
headers=headers,
files=files,
json=json.loads(datas["data"]),
verify=False)
with allure.step('响应'): # 测试步骤
allure.attach('{}'.format(resp.status_code), name='状态码')
logger.info("{}状态码:{},响应结果:{}".format(datas["title"], resp.status_code, resp.text))
# 如果所有接口的响应结果是json格式,建议使用resp.json()
# logger.info("{}状态码:{},响应结果:{}".format(datas["title"], resp.status_code, resp.json()))
expected = json.loads(datas['expected'])
# 状态码断言
with allure.step('断言'): # 测试步骤
for key, value in expected.items():
assert resp.status_code == value # 断言状态码
allure.attach('OK', name='{}断言'.format(key))
# 多值断言,一般断言响应结果中的值。
# with allure.step('断言'):
# for key, value in expected.items():
# assert str(jsonpath(resp.json(), key)[0]) == value
# allure.attach('OK', name='{}断言'.format(key))
# 设置Handler对应的属性。
if datas['extractor']:
extrators = json.loads(datas['extractor'])
for prop, jsonpath_exp in extrators.items():
value = jsonpath(resp.json(), jsonpath_exp)[0]
setattr(Handler, prop, value)
八、upload_files目录
upload_files目录用于存放上传类接口所需的文件;若没有上传类接口,则该目录可以不需要
九、conftest.py文件
conftest.py主要用作测试用例的前置或后置条件,例如:用户登录获取Cookie、连接数据库等
import pytest
import requests
from middleware.handler import Handler, MidMySQLHandler, MidOracleHandler
from jsonpath import jsonpath
def login(username):
"""登录,得到Cookie
"""
# 发送登录接口请求
resp = requests.request(method='POST',
url=Handler.env_config["env_url"] + '/UAMS/auth?username={}'.format(username),
headers={"Content-Type": "x-www-form-urlencoded"},
json={},
verify=False
)
resp_json = resp.json()
name_session = jsonpath(resp_json, '$..name')[0]
value_session = jsonpath(resp_json, '$..value')[0]
cookie = "=".join([name_session, value_session]) # 生成Cookie,EDC这边的Cookie是通过登录接口响应结果中的“name=value”拼接的
return {"cookie": cookie} # 将Cookie返回
@pytest.fixture() # 夹具必须要在函数上面使用“@pytest.fixture()”后可以在用例执行入口处直接引用
def login_cookie():
"""交付小组用户登录"""
user = {
"username": Handler.user_config['d_user']['username']
}
return login(user['username'])
@pytest.fixture() # 夹具必须要在函数上面使用“@pytest.fixture()”后可以在用例执行入口处直接引用
def a_login():
"""系统管理组组用户登录"""
user = {
"username": Handler.user_config['a_user']['username']
}
return login(user['username'])
# 若没有mysql库相关的操作,可以删除mysql_db夹具
@pytest.fixture()
def mysql_db():
"""管理MySQL数据库链接的夹具"""
mysql_db_conn = MidMySQLHandler()
# db_conn = Handler.db_class
yield mysql_db_conn
mysql_db_conn.close()
# 若没有oracle库相关的操作,可以删除oracle_db夹具
@pytest.fixture()
def oracle_db():
"""管理Oracle数据库链接的夹具"""
oracle_db_conn = MidOracleHandler()
yield oracle_db_conn
oracle_db_conn.close()
十、run.py文件
run.py是一个测试用例收集器,作用是执行工程目录下所有的“test”开头或结尾的py文件。
"""
项目入口,主程序
收集用例,运行用例,生成报告
"""
import os,pytest
from datetime import datetime
from config.path import reports_path
# pytest 收集用例
def run_case():
"""
# html测试报告
timestamp = str(datetime.now().strftime("%Y-%m-%d %H_%M_%S"))
reportfilename = 'report_%s.html' % timestamp
htmlreport = os.path.join(reports_path, reportfilename)
pytest.main(['--html={}'.format(htmlreport)])
"""
# allure测试报告
pytest.main(['--alluredir', 'reports/allure-report'])
os.system(r'allure generate reports/allure-reports -o reports/html --clean')
run_case()
# 运行本地的Allure测试报告可以使用以下命令:
# allure serve <path_to_report_directory>
# 其中,<path_to_report_directory> 指的是测试报告的目录路径。执行上述命令后,会在默认浏览器中打开 Allure 的 HTML 测试报告页面,方便查看测试结果。
十一、README.txt文件
README需要有的几个功能模块:
1、软件项目的定位,基本功能描述;
2、运行项目代码的方法,安装环境,启动方式命令等;
3、简明扼要的使用说明;
4、项目代码结构说明,更详细说明软件的原理;
5、常见问题说明,以及注意事项。
README还要包含的一些内容:
1、项目和所有子类模块和所有库的名字;
2、如何使用;
3、版权和许可信息;
4、提交的bug、功能需求、补丁等介绍;
5、历史记录;
十二、requirements.txt文件
记录项目所有依赖的第三方模块,方便迁移到不同的环境中,防止缺少模块,或因为所依赖的第三方模块不同而引起的一系列问题。
allure-pytest2.12.0
allure-python-commons2.12.0
async-generator1.10
attrs22.2.0
baostock0.8.8
bcrypt4.0.1
certifi2022.12.7
cffi1.15.1
charset-normalizer2.1.1
colorama0.4.6
cryptography38.0.4
cx-Oracle8.3.0
et-xmlfile1.1.0
exceptiongroup1.1.0
h110.14.0
idna3.4
iniconfig1.1.1
jsonpath0.82
kafka-python2.0.2
numpy1.24.1
openpyxl3.0.10
outcome1.2.0
packaging22.0
paho-mqtt1.6.1
pandas1.5.2
paramiko2.12.0
pluggy1.0.0
py1.11.0
pycparser2.21
PyMySQL1.0.2
PyNaCl1.5.0
pyOpenSSL22.1.0
PySocks1.7.1
pytest7.2.0
pytest-html3.2.0
pytest-metadata2.0.4
python-dateutil2.8.2
pytz2022.7
PyYAML6.0
requests2.28.1
selenium4.7.2
six1.16.0
sniffio1.3.0
sortedcontainers2.4.0
tomli2.0.1
trio0.22.0
trio-websocket0.9.2
urllib31.26.13
wsproto==1.2.0
将上面的三方库复制并保存为requirements.txt文件后,打开命令行执行pip install -r requirements.txt 可以导入所有三方依赖库