基于pytest和allure构建自动化测试框架与项目
代码:https://gitee.com/kunmzhao/auto_test_-project.git
框架目录结构
我们要构建一个自动化测试框架,就要以项目的概念来对项目中的所有代码文件进行划分目录和文件结构,需要设计一个合理的目录结构,以便与测试开发团队的其他人员的开发和测试,也便于项目的维护
设计的项目目录如下
根目录
├── api # 封装测试项目的api接口[用于mock测试]
│ └── __init__.py
├── config.py # 项目代码配置文件
├── data # 测试数据/测试用例存放目录
├── libs # 第三方lib库
├── main.py # 项目入口
├── pytest.ini # pytest模块的配置文件
├── reports # HTML测试报告生成目录
├── results # 测试报告生成目录
├── tests # 测试用例脚本存放目录
├── utils # 自定义工具类
├── requirments.txt # 项目依赖模块
配置文件 config.py
import pathlib # 路径操作模块,替代os.path模块,可以通过对象的方式操作路径 # 项目目录的主目录文件路径[字符串] BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix() # 项目目录的主目录路径[路径对象] BASE_DIR = pathlib.Path(BASE_DIR_STR) # 项目名字 PROJECT_NAME = "接口自动化测试框架" # 自动化测试项目的运行IP和端口 HOST = "127.0.0.1" PORT = 8088 if __name__ == '__main__': print(BASE_DIR_STR, type(BASE_DIR_STR)) print(BASE_DIR, type(BASE_DIR))
入口文件main.py
import pytest import os import sys import shutil import config if __name__ == '__main__': try: # 删除之前存在的报告文件夹 shutil.rmtree("./reports") shutil.rmtree("./results") except Exception as e: # TODO:需要添加日志 print(e) # 启动 pytest 测试框架,启动参数配置在 pytest.ini 中 pytest.main() # 生成测试报告 os.system(f"{config.BASE_DIR.joinpath('libs/allure/bin/allure')} generate ./results -o ./reports") # 打开测试报告 os.system(f"{config.BASE_DIR.joinpath('libs/allure/bin/allure')} serve ./results -h {config.HOST} -p {config.PORT}")
pytest.ini配置文件
[pytest] # 指定运行参数 addopts = -s -v -p no:warnings --alluredir=./results # 搜索测试文件的目录路径 testpaths = ./ # 搜索测试文件名格式 python_files = test_*.py # 搜索测试类格式 python_classes = Test* # 搜索测试方法名格式 python_functions = test_*
下面编写一个测试用例,来确保框架可以正常运行
test_login.py
import allure import config @allure.epic(config.PROJECT_NAME) @allure.feature("用户模块") @allure.story("用户登录") class TestUser(object): def test_username_by_empty(self): allure.dynamic.title("用户名为空,登录失败") allure.dynamic.description("测试用户名为空的描述") allure.attach("文件内容", "log") assert 1 == 2
运行main.py
新增日志功能
日志模块是项目中必不可缺的模块,便于我们查看项目运行状况和排查错误
config.py新增日志配置
import pathlib # 路径操作模块,替代os.path模块,可以通过对象的方式操作路径 # 项目目录的主目录文件路径[字符串] BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix() # 项目目录的主目录路径[路径对象] BASE_DIR = pathlib.Path(BASE_DIR_STR) # 项目名字 PROJECT_NAME = "接口自动化测试框架" # 自动化测试报告的运行IP和端口 HOST = "127.0.0.1" PORT = 8088 # 日志模块配置 LOGGING = { "name": "Zeekr", # 日志处理器名称 "filename": (BASE_DIR / "logs/zeeker.log").as_posix(), # 日志文件储存路径 "charset": "utf-8", "backup_count": 31, # 日志文件的备份数量 "when": "d" # 日志文件创建间隔时间为每天创建一个 } if __name__ == '__main__': print(BASE_DIR_STR, type(BASE_DIR_STR)) print(BASE_DIR, type(BASE_DIR))
logger.py模块
import logging import config from logging import handlers class LogHandler(object): """日志处理工具类""" def __init__(self, name=None, filename=None): """ :param name: 日志处理器的名字 :param filename: 日志文件名字 """ # 如果没有指定name和filename,则使用配置文件配置 self.name = name or config.LOGGING.get("name", "pytest") self.filename = filename or config.LOGGING.get("filename", "pytest.log") self.charset = config.LOGGING.get("charset", "utf-8") self.backup_count = config.LOGGING.get("backup_count", 31) self.when = config.LOGGING.get("when", "d") self.logger = None def get_logger(self): """ 创建 logger :return: logger 对象 """ # 避免重复创建logger TODO:使用到单例模式是否更好? if self.logger: return self.logger logger = logging.getLogger(self.name) # 设置日志初始化等级 logger.setLevel(logging.DEBUG) # 创建handler fh = handlers.TimedRotatingFileHandler( filename=self.filename, when=self.when, backupCount=self.backup_count, encoding=self.charset ) sh = logging.StreamHandler() # 为每种handler设置日志等级 # fh.setLevel(logging.INFO) # 设置输出日志格式 simple_formater = logging.Formatter(fmt="【{levelname}】 {name} {module}: {lineno} {message}", style="{") verbose_formater = logging.Formatter( fmt="【{levelname}】 {asctime} {name} {pathname}: {lineno} {message}", datefmt="%Y-%m-%d %H:%M:%S", style="{" ) # 为handler指定输出格式 fh.setFormatter(verbose_formater) sh.setFormatter(simple_formater) # 为logger添加日志处理器 logger.addHandler(fh) logger.addHandler(sh) self.logger = logger return logger if __name__ == '__main__': logger = LogHandler().get_logger() logger.debug("测试 debug") logger.info("测试 info") logger.warning("测试 warning") logger.error("测试 error")
在测试用例中简单使用日志
import allure import config from utils.logger import LogHandler logger = LogHandler().get_logger() @allure.epic(config.PROJECT_NAME) @allure.feature("用户模块") @allure.story("用户登录") class TestUser(object): def test_username_by_empty(self): allure.dynamic.title("用户名为空,登录失败") allure.dynamic.description("测试用户名为空的描述") allure.attach("文件内容", "log") logger.debug("测试 debug") logger.info("测试 info") logger.warning("测试 warning") logger.error("测试 error")
运行main.py文件
【DEBUG】 2023-09-26 10:16:33 Zeekr /home/zk4956z3/PycharmProjects/Stark/utils/logger.py: 75 测试 debug 【INFO】 2023-09-26 10:16:33 Zeekr /home/zk4956z3/PycharmProjects/Stark/utils/logger.py: 76 测试 info 【WARNING】 2023-09-26 10:16:33 Zeekr /home/zk4956z3/PycharmProjects/Stark/utils/logger.py: 77 测试 warning 【ERROR】 2023-09-26 10:16:33 Zeekr /home/zk4956z3/PycharmProjects/Stark/utils/logger.py: 78 测试 error 【DEBUG】 2023-09-26 10:18:34 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 15 测试 debug 【INFO】 2023-09-26 10:18:34 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 16 测试 info 【WARNING】 2023-09-26 10:18:34 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 17 测试 warning 【ERROR】 2023-09-26 10:18:34 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 18 测试 error 【DEBUG】 2023-09-27 09:02:13 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 16 测试 debug 【INFO】 2023-09-27 09:02:13 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 17 测试 info 【WARNING】 2023-09-27 09:02:13 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 18 测试 warning 【ERROR】 2023-09-27 09:02:13 Zeekr /home/zk4956z3/PycharmProjects/Stark/tests/users/test_login.py: 19 测试 error
封装请求工具
接口的测试一般离不开http请求,在python中常用的http请求模块有urllib, requests,httpx等,不过小编最常用的还是requests
安装
pip install requests
简单使用
import requests
####### GET请求 ########
# 发送简单的get请求 response = requests.get("https://baidu.com")
# 发送有参数的get请求
params = {
"name": "kunmzhao",
"age":18
} response = requests.get("https://httpbin.org", params=params) # 获取原生内容 print(response.content) # 获取文本内容 print(response.text)
# 接收json格式内容
print(response.json())
# 接收二进制内容
with open("1.png", "wb") as fd:
fd.write(response.content)
####### POST请求 ########
# 发送表单数据
import requests
forms = {"name":"kunmzhao","age":18}
response = requests.post("http://httpbin.org/post", data=forms)
print(response.text)
# 发送json数据
json_data = {"name":"kunmzhao","age":18}
response = requests.post("http://httpbin.org/post", json=json_data)
print(response.text)
# 文件上传 支持一张或者多张数据的上传
files = {"avata":open("1.png",'rb')}
response = requests.post("http://httpbin.org/post", files=files)
print(response.text)
##### 发送请求头 #####
headers = {
"User-Agent":"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/117.0"
}
response = requests.get("https://www/zhihu.com/explore", headers=headers)
print(response.text)
requestor.py 对常用的http请求操作进行封装
import requests from utils.logger import LogHandler class Request(object): """http请求工具类""" def __init__(self): # 实例化session管理器,维持会话 self.session = requests.session() self.logger = LogHandler().get_logger() def send(self, method, url, params=None, data=None, json=None, headers=None, **kwargs): """ 发送http请求 :param method: 请求方法 :param url: 请求URL :param params: 请求参数 :param data: 请求数据 :param json: jason传参,请求数据 :param headers: : 请求头 :param kwargs: 其它参数 :return: headers """ try: self.logger.info(f"请求方法: {method}") self.logger.info(f"请求url: {url}") self.logger.info(f"请求params: {params}") self.logger.info(f"请求data: {data}") self.logger.info(f"请求json: {json}") self.logger.info(f"请求headers: {headers}") self.logger.info(f"请求额外参数: {kwargs}") response = self.session.request(method=method, url=url, params=params, data=data, json=json, headers=headers, **kwargs) self.logger.info(f"回复状态码: {response.status_code}") self.logger.info(f"回复响应头: {response.headers}") self.logger.info(f"回复响应体[二进制]: {response.content}") self.logger.info(f"回复响应体[纯文本]: {response.text}") self.logger.info(f"回复响应体[json]: {response.json()}") return response except Exception as e: self.logger.error(f"请求错误,错误信息:{e}") def __call__(self, method, url, params=None, data=None, json=None, headers=None, **kwargs): """ 将对象当做一个函数来使用,Request(method, url, params=None, data=None, json=None, headers=None, **kwargs) """ return self.send(method=method, url=url, params=params, data=data, json=json, headers=headers, **kwargs) if __name__ == '__main__': request = Request() res = request("GET", "http://httpbin.org/get") print(res) res = request("post", "http://httpbin.org/post", json={"username": "kunmzhao"}) print(res)
基于Flask四线Mockserver
在实际的项目开发中,经常出现服务端和客户端分离的情况,我们在做测试开发的时候,有可能服务的功能还没有实现,我们可以自己先模拟出服务端返回结果,以实现联调,等服务器上线后,切换server即可
Flask是一个轻量级的python web框架,非常适合在测试中构建模拟api服务器
安装模块
pip install flask
pip install pymysql
pip install flask_sqlalchemy
api/__init__.py
import config from flask import Flask from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() app = Flask(__name__) def init_app(): # 加载配置 app.config.from_object(config) # 加载数据库配置 db.init_app(app) # db创建数据库 with app.app_context(): db.create_all() return app
config.py
import pathlib # 路径操作模块,替代os.path模块,可以通过对象的方式操作路径 # 项目目录的主目录文件路径[字符串] BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix() # 项目目录的主目录路径[路径对象] BASE_DIR = pathlib.Path(BASE_DIR_STR) # 项目名字 PROJECT_NAME = "接口自动化测试框架" # 自动化测试报告的运行IP和端口 HOST = "127.0.0.1" PORT = 8089 # 日志模块配置 LOGGING = { "name": "Zeekr", # 日志处理器名称 "filename": (BASE_DIR / "logs/zeeker.log").as_posix(), # 日志文件储存路径 "charset": "utf-8", "backup_count": 31, # 日志文件的备份数量 "when": "d" # 日志文件创建间隔时间为每天创建一个 } """mock server 的服务端配置""" # 数据库连接 SQLALCHEMY_DATABASE_URI: str = "mysql+pymysql://root:123@127.0.0.1:3306/pytest?charset=utf8mb4" # 查询时会显示原始SQL语句 SQLALCHEMY_ECHO: bool = True # 调试模式 DEBUG = True # 监听端口 API_PORT = 8000 # 监听地址 API_HOST = "0.0.0.0" if __name__ == '__main__': print(BASE_DIR_STR, type(BASE_DIR_STR)) print(BASE_DIR, type(BASE_DIR))
api/models.py
from datetime import datetime from werkzeug.security import generate_password_hash, check_password_hash from . import db class BaseModel(db.Model): """公共模型""" __abstract__ = True # 抽象模型 id = db.Column(db.Integer, primary_key=True, comment="主键ID") name = db.Column(db.String(255), default="", comment="名称/标题") is_deleted = db.Column(db.Boolean, default=False, comment="逻辑删除") orders = db.Column(db.Integer, default=0, comment="排序") status = db.Column(db.Boolean, default=True, comment="状态(是否显示,是否激活)") created_time = db.Column(db.DateTime, default=datetime.now, comment="创建时间") updated_time = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, comment="更新时间") def __repr__(self): return f"<{self.__class__.__name__}: {self.name}>" class User(BaseModel): """用户基本信息表""" __tablename__ = "py_user" name = db.Column(db.String(255), index=True, comment="用户账户") nickname = db.Column(db.String(255), comment="用户昵称") _password = db.Column(db.String(255), comment="登录密码") intro = db.Column(db.String(500), default="", comment="个性签名") avatar = db.Column(db.String(255), default="", comment="头像url地址") sex = db.Column(db.SmallInteger, default=0, comment="性别") # 0表示未设置,保密, 1表示男,2表示女 email = db.Column(db.String(32), index=True, default="", nullable=False, comment="邮箱地址") mobile = db.Column(db.String(32), index=True, nullable=False, comment="手机号码") # 存取器 @property def password(self): # user.password return self._password @password.setter def password(self, rawpwd): # user.password = '123456' """密码加密""" self._password = generate_password_hash(rawpwd) def check_password(self, rawpwd): """验证密码""" return check_password_hash(self.password, rawpwd)
api/views.py
from sqlalchemy import or_
from . import app
from .models import User,db
@app.route("/user/register", methods=["POST"])
def register():
"""
用户信息注册
:return:
"""
try:
data = request.json
# 创建用户数据
user = User(**data)
db.session.add(user)
db.session.commit()
return {"msg": "注册成功!", "data": {"id":user.id, "name": user.name}}, 200
except Exception as e:
return {"msg": "注册失败!", "data": {}}, 400
@app.route("/user/login", methods=["POST"])
def login():
"""
用户登录
:return:
"""
user = User.query.filter(
or_(
User.mobile == request.json.get("mobile"),
User.name == request.json.get("name"),
User.email == request.json.get("email")
)
).first() # 实例化模型
if not user:
return {"msg": "登录失败!用户不存在!", "data": {}}, 400
if not user.check_password(request.json.get("password")):
return {"msg": "登录失败!密码错误!", "data": {}}, 400
return {"msg": "登录成功", "data":{"id": user.id, "name": user.name}}, 200
api/run.py
import config from api import init_app # 注意,务必把模型models的内容以及 views 中的服务端接口引入当前文件,否则flask不识别。 from api import models from api import views app = init_app() if __name__ == '__main__': app.run(host=config.API_HOST, port=config.API_PORT)
test_login.py
import allure import config from utils.logger import LogHandler from utils.requestor import Request logger = LogHandler().get_logger() SERVER_URl = f"http://{config.API_HOST}:{config.API_PORT}" @allure.epic(config.PROJECT_NAME) @allure.feature("用户模块") @allure.story("用户登录") class TestUser(object): def test_username_by_empty(self): allure.dynamic.title("用户名为空,登录失败") allure.dynamic.description("测试用户名为空的描述") allure.attach("文件内容", "log") request = Request() response = request("POST", f"{SERVER_URl}/user/login", json={ "name": "", "password": "123" }) print(response) def test_password_by_empty(self): allure.dynamic.title("密码为空,登录失败") allure.dynamic.description("测试密码为空的描述") allure.attach("文件内容", "log") request = Request() response = request("POST", f"{SERVER_URl}/user/login", json={ "name": "kunmzhao", "password": "" }) print(response)
此时运行main.py文件和run.py文件,即可测该框架是否成功
基于数据驱动生成用例代码
在实际测试开发中,我们一般使用参数化来自动生成测试用例,参数化用例一般采用json,yaml或者excle文件储存,如果用例非常多,也可以改用数据库
下面介绍通过excel来承载测试用例,python中操作excel文件的模块有xlrd+xlwt,pyexcle+openpyxl
安装模块
pip install xlrd
pip install xlwt
封装excel工具类
excel.py
""" 不识别xlsx的文件后缀名,文件格式必须设置为xls """ import xlrd import json class Excel(object): """Excel文件写工具类, TODO:关于excel写操作后续补充""" def __init__(self, filename): self.workbook = xlrd.open_workbook(filename, formatting_info=True) def get_sheet_names(self): """ 获取当前excle中所有sheet的名字 """ return self.workbook.sheet_names() def __get_sheet(self, sheet_index_or_name): """ 根据sheet的索引或者名字获取对应sheet对象 """ if isinstance(sheet_index_or_name, int): if len(self.get_sheet_names()) > sheet_index_or_name: return self.workbook.sheet_by_index(sheet_index_or_name) raise Exception( "无效的的sheet下标数值, excel文档最大值为{}, 请求为数值为{}".format( len(self.get_sheet_names()), sheet_index_or_name), ) elif isinstance(sheet_index_or_name, str): if sheet_index_or_name in self.get_sheet_names(): return self.workbook.sheet_by_name(sheet_index_or_name) raise Exception("无效的sheet名字,名字为{}的sheet不存在".format(sheet_index_or_name)) raise Exception("sheet_index_or_name只能为int或者str,但是入参为{}".format(type(sheet_index_or_name))) def get_sheet_rows_num(self, sheet_index_or_name): """ 获取指定sheet的的数据总行数 """ return self.__get_sheet(sheet_index_or_name).nrows def get_sheet_cols_num(self, sheet_index_or_name): """ 获取指定sheet的数据总列数 """ return self.__get_sheet(sheet_index_or_name).ncols def get_cell_value(self, sheet_index_or_name, row_index, col_index): """ 获取指定sheet中的指定单元格数据 """ sheet = self.__get_sheet(sheet_index_or_name) try: return sheet.cell_value(row_index, col_index) except Exception as e: raise Exception(str(e)) def get_sheet_data(self, sheet_index_or_name, fields, first_line_is_header=True): """ 获取工作表的所有数据 """ rows = self.get_sheet_rows_num(sheet_index_or_name) cols = self.get_sheet_cols_num(sheet_index_or_name) data = [] for row in range(int(first_line_is_header), rows): row_data = {} for col in range(cols): cell_data = self.get_cell_value(sheet_index_or_name, row, col) if type(cell_data) is str and ("{" in cell_data and "}" in cell_data) or ( "[" in cell_data and "]" in cell_data): cell_data = json.loads(cell_data) print(col, fields[col]) row_data[fields[col]] = cell_data data.append(row_data) return data if __name__ == '__main__': xls = Excel("../data/外来人员.xls") fields = ["姓名", "身份证号", "手机号", "人员在胶状态", "目前详细住址", "随访异常", "随访异常备注", "是否已做核酸", "核酸结果", "核酸采样日期", "核酸采样地点", "人员类别", "重点地区", "到达重点地区时间", "离开重点地区时间", "返回胶州日期", "请输入行程详情概述"] print(xls.get_sheet_names()) print(xls.get_sheet_cols_num("Sheet0")) print(xls.get_sheet_rows_num(0)) print(xls.get_cell_value(0, 1, 1)) print(xls.get_sheet_data(0, fields))
config.py
import pathlib # 路径操作模块,替代os.path模块,可以通过对象的方式操作路径 # 项目目录的主目录文件路径[字符串] BASE_DIR_STR = pathlib.Path(__file__).parent.resolve().as_posix() # 项目目录的主目录路径[路径对象] BASE_DIR = pathlib.Path(BASE_DIR_STR) # 项目名字 PROJECT_NAME = "接口自动化测试框架" # 自动化测试报告的运行IP和端口 HOST = "127.0.0.1" PORT = 8089 # 日志模块配置 LOGGING = { "name": "Zeekr", # 日志处理器名称 "filename": (BASE_DIR / "logs/zeeker.log").as_posix(), # 日志文件储存路径 "charset": "utf-8", "backup_count": 31, # 日志文件的备份数量 "when": "d" # 日志文件创建间隔时间为每天创建一个 } # excel测试用例字段格式 FIELD_LIST = [ "case_id", # 用例编号 "module_name", # 模块名称 "case_name", # 用例名称 "method", # 请求方式 "url", # 接口地址 "headers", # 请求头 "params_desc", # 参数说明 "params", # 请求参数 "assert_result", # 预期结果 "real_result", # 实际结果 "remark", # 备注 ] """mock server 的服务端配置""" # 数据库连接 SQLALCHEMY_DATABASE_URI: str = "mysql+pymysql://root:ZKMzkm36337@127.0.0.1:3306/pytest?charset=utf8mb4" # 查询时会显示原始SQL语句 SQLALCHEMY_ECHO: bool = True # 调试模式 DEBUG = True # 监听端口 API_PORT = 8000 # 监听地址 API_HOST = "0.0.0.0" if __name__ == '__main__': print(BASE_DIR_STR, type(BASE_DIR_STR)) print(BASE_DIR, type(BASE_DIR))
utils/assertor.py
from utils.logger import LogHandler logger = LogHandler().get_logger() def assertor(assert_list, response): """断言函数""" if type(assert_list) is not list: assert_list = [assert_list] for expr in assert_list: logger.info(f"开始断言:assert {expr}") if expr: # exec 内置解释器,可以把符合python语法的字符串当成代码来运行 exec(f"assert {expr}", { "code": response.status_code, "json": response.json(), "text": response.text, "content": response.content, "headers": response.headers, }) logger.info(f"断言通过:assert {expr}") if __name__ == '__main__': # Response就是模拟requests HTTP请求工具的返回结果对象 class Response(object): status_code = 400 text = "对不起,登陆失败!" content = "对不起,登陆失败!" headers = [] @classmethod def json(cls): return {"id": 1}, assert_list = [ "code == 400", "'失败'in text", ] assertor(assert_list, Response())
test_login.py
import allure import pytest import config from utils.logger import LogHandler from utils.requestor import Request from utils.excle import Excel from utils.assertor import assertor logger = LogHandler().get_logger() SERVER_URl = f"http://{config.API_HOST}:{config.API_PORT}" @allure.epic(config.PROJECT_NAME) @allure.feature("用户模块") @allure.story("用户登录") class TestUser(object): @pytest.mark.parametrize("kwargs", Excel(config.BASE_DIR / "data/case_user.xls").get_sheet_data(0, config.FIELD_LIST)) def test_login(self, kwargs): request = Request() allure.dynamic.title(kwargs.get("case_name")) request.logger.info(f"开始请求测试接口:{kwargs.get('case_name')}") if kwargs.get("method").lower() in ["get", "delete"]: """发送get或delete""" response = request(kwargs.get("method"), f'{kwargs.get("url")}', params=kwargs.get("params")) else: """发送post,put,patch""" response = request(kwargs.get("method"), f'{kwargs.get("url")}', json=kwargs.get("params")) assertor(kwargs.get("assert_result"), response)