本地写了登录,查询商品goods,查询form表单几个简单的接口,没有什么复杂的校验,单纯的是为了测试这套自动化框架
/login:登录接口(post,content-type:application/json)
/query/goods:登录后查询接口(post,content-type:application/json,为了测试接口关联的场景)
/form:查询form表单(post,content-type:application/x-www-x-www-form-urlencoded)
requests(用于进行发送接口请求)
pymysql(操作mysql数据库)
redis(操作redis数据库)
faker(生成数据)
yaml(操作yaml文件)
logging(用于日志的输出打印)
configparser(获取配置文件)
pytest(数据驱动和前后置操作)
allure(生成测试报告)
configs:配置文件
common:常用的工具类
log:日志文件
test_case:测试用例
test_data:测试数据
conftest.py:存放 fixture
的配置文件
pytest.ini:pytest的配置文件
run.py:程序入口
配置文件里面可以放一些固定的信息,比如mysql数据库和redis的链接信息,测试环境地址等
config.ini配置文件是以key=value键值对的方式存在,所有的键值对都是以节section为单位结合在一起的,用[]表示节。
[database]
host = 127.0.0.1
port = 3306
user = root
password = 12345678
database = mysql
[url]
test = http://127.0.0.1:5000/
以上述代码的格式将配置写入config.ini配置文件中,[database]和[url]就是section节。
common是一个文件夹,里面放置很多常用的工具类:
该文件主要是使用pymysql对数据库增删改查等操作的封装,可以用于前置数据的准备或者数据库数据校验,以及后置的数据清理。
# 导入pymysql
import logging
import pymysql
from test_api.common.get_config import GetConfig
class DbOperation:
def __init__(self):
# 链接数据库,查询的结果以字典的形式返回
self.connect = pymysql.connect(**GetConfig().get_database(), cursorclass=pymysql.cursors.DictCursor)
# 创建游标
self.cursor = self.connect.cursor()
# 查询一条符合条件的数据
def query_data(self, sql):
try:
self.cursor.execute(sql)
result = self.cursor.fetchone()
return result
except pymysql.Error as error:
logging.error(error)
# 执行sql(包含插入,删除,修改)
def exe_data(self, sql):
try:
self.cursor.execute(sql)
self.connect.commit()
except pymysql.Error as error:
logging.error(error)
# 关闭链接和数据库
def __del__(self):
self.cursor.close()
self.connect.close()
有的时候我们测试需要模拟不同用户的请求操作,需要用到动态的参数。该文件主要是通过第三方库faker生成数据,比如随机数,手机号码,身份证号码,姓名等。
from faker import Faker
class GeneratedData:
def __init__(self, faker_lang='zh_CN'):
self.faker_data = Faker(faker_lang)
# 生成随机姓名
def generatedName(self):
return self.faker_data.name()
# 生成随机手机号码
def generatedPhone(self):
return self.faker_data.phone_number()
# 生成随机的身份证号码
def generatedIdentity(self):
return self.faker_data.ssn()
# 生成随机的银行卡号
def generatedCard(self):
return self.faker_data.credit_card_number()
# 生成随机数
def generatedRandomNumber(self, n):
return str(self.faker_data.random_number(n))
设置完配置文件后,我们需要方法去获取配置文件的值,configparser用于获取config.ini的值。
获取配置的方法,主要两种:
get方法:获取指定section下指定键的值
items方法:获取指定section下所有键的值
import configparser
import os
# 需要读取配置文件的路径
config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "configs/config.ini")
class GetConfig:
def __init__(self):
# 创建对象
self.cf = configparser.ConfigParser()
# 读取该配置文件
self.cf.read(config_path)
# 获取数据库配置信息
def get_database(self):
database_dict = {}
# f.items(section="database")获取节点database下的配置信息
for key, value in self.cf.items(section="database"):
if key == "port":
database_dict[key] = int(value)
else:
database_dict[key] = value
return database_dict
# 获取测试环境地址
def get_url(self, name):
return self.cf.get("url", name)
使用yaml库来获取yaml文件里的内容,也可以说是用来获取测试用例。该文件里封装了读取,写入和清空。写入和清空是为了接口关联而存在的。
写了两种读取用例的方法:
第一种读取主流程用例,默认每个yaml文件里第一个用例为主流程用例;
第二种是读取单个接口的所有用例,针对单个接口所有场景的测试;
注:读取方法中**kwargs不定长参数,是为了接受动态参数存在的。如果你传了动态参数,该方法会使用Template替换。
Template方法是替换yaml文件中的占位符$,使用随机参数替换。yaml里的占位符名称要和调用该方法传的键相同。
import yaml
import os
from string import Template
from test_api.common.log import Log
class GetYaml:
def __init__(self, yaml_name):
yaml.warnings({'YAMLLoadWarning': False})
self.yaml_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), f"test_data/{yaml_name}")
self.getYamlLog = Log()
# 读取主流程用例
def read_main_yaml(self, link=False, **kwargs):
with open(self.yaml_path, mode="r", encoding='utf-8') as content:
load_content = yaml.safe_load(content)
# 接口关联标志,如果为true则直接返回yaml文件内容
if link:
self.getYamlLog.info_log(f"=======接口关联,获取all.yaml文件的关联参数{load_content}=======")
return load_content
else:
# 如果kwargs为true,替换成yaml文件里的占位符$
if kwargs:
self.getYamlLog.info_log(f"===========替换动态参数===========")
self.getYamlLog.info_log(f"替换参数为{kwargs}")
return yaml.safe_load(Template(str(load_content[0])).safe_substitute(kwargs))
else:
return load_content[0]
# 读取单条测试用例各场景
def read_yaml(self, testcase_name, **kwargs):
with open(self.yaml_path, mode="r", encoding='utf-8') as content:
load_content = yaml.safe_load(content)
for num in range(len(load_content)):
# 判断测试用例名称是否在yaml文件里
if testcase_name in load_content[num].keys():
if kwargs:
return yaml.safe_load(Template(str(load_content[num][testcase_name])).safe_substitute(kwargs))
else:
return load_content[num][testcase_name]
else:
return "该测试用例不存在"
# 写入
def write_yaml(self, detail):
with open(self.yaml_path, mode="a", encoding='utf-8') as content:
yaml.dump(detail, content, default_flow_style=False)
# 清空
def clear_yaml(self):
with open(self.yaml_path, mode="w", encoding='utf-8') as content:
content.truncate()
该py文件主要是封装了文件日志和控制台日志的输出打印。通过不同的颜色来区分日志的不同级别。
import logging
import os
import time
import colorlog
log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "log")
localTime = time.strftime("%Y%m%d")
class Log:
def __init__(self):
self.logger = logging.getLogger("日志输出")
self.logger.setLevel(logging.INFO)
# 设置日志格式和颜色
self.log_format = colorlog.ColoredFormatter('%(log_color)s[%(asctime)s]-%(levelname)s:%(message)s', log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'red,bg_white',
})
# 判断是否有handles,如果有则不会再次添加,防止造成重复打印日志的情况。如果没有则去添加
if not self.logger.handlers:
# 设置文件日志的路径名称
self.fileHandle = logging.FileHandler(filename=log_path + f'/{localTime}.log', mode='a', encoding='UTF-8')
# 设置文件日志的格式
self.fileHandle.setFormatter(self.log_format)
# 将文件日志添加处理器
self.logger.addHandler(self.fileHandle)
###############################################
# 设置控制台日志
self.streamHandle = logging.StreamHandler()
# 设置控制台日志的格式
self.streamHandle.setFormatter(self.log_format)
# 将控制台日志添加处理器
self.logger.addHandler(self.streamHandle)
def info_log(self, message):
self.logger.info(message)
def debug_log(self, message):
self.logger.debug(message)
def warning_log(self, message):
self.logger.warning(message)
def error_log(self, message):
self.logger.error(message)
封装了统一的接口请求和请求要素的日志打印,目前为止只封装了get和post方法。
import requests
from test_api.common.get_config import GetConfig
from test_api.common.log import Log
path = GetConfig().get_url("test")
class RequestUtil:
session = requests.session()
requestLog = Log()
def all_request(self, url, methods, headers=None, params=None, request_data=None):
if methods.upper() == "GET":
try:
response = self.session.get(url, headers, params)
self.request_log(url, methods, headers, params, response.json())
return response
except Exception as e:
self.requestLog.error_log(f"请求失败,{e}")
elif methods.upper() == "POST":
try:
if headers['Content-Type'] == "application/json":
response = self.session.post(url=url, json=request_data)
else:
response = self.session.post(url=url, data=request_data)
self.request_log(url, methods, headers, request_data, response.json())
return response
except Exception as e:
self.requestLog.error_log(f"请求失败,{e}")
def request_log(self, url, method, headers, data, response_json):
self.requestLog.info_log(f"请求接口地址==============>{url}")
self.requestLog.info_log(f"请求接口的请求方式=============>{method}")
self.requestLog.info_log(f"请求接口的请求头==============>{headers}")
self.requestLog.info_log(f"请求接口的请求参数==============>{data}")
self.requestLog.info_log(f"请求接口的响应==============>{response_json}")
根据传入的预期返回结果和实际返回结果,对双方的key和value进行对比断言。并且打印出结果
from test_api.common.log import Log
class Check_data:
def __init__(self):
self.check_data_log = Log()
self.check_list = []
# 检查接口json格式的返回key,是否与预期结果返回key相等
def key_check(self, really_key, except_key):
if len(really_key) != len(except_key):
self.check_list.append(False)
Log().error_log("========预期结果和实际结果字段数量不一致========")
else:
for key in except_key.keys():
if key in really_key:
self.check_list.append(True)
else:
self.check_list.append(False)
Log().error_log(f"=======预期结果中不存在实际结果中的{key}字段========")
if False in self.check_list:
Log().error_log(f"断言方式[json-key]======>预期结果{really_key},实际结果{except_key},断言失败")
else:
Log().info_log(f"断言方式[json-key]======>预期结果{really_key},实际结果{except_key},断言通过")
def value_check(self, really_value, except_value):
# except_value_dict = eval(str(except_value))
for key, value in really_value.items():
if value in except_value.values():
self.check_list.append(True)
else:
self.check_list.append(False)
Log().error_log(f"=======实际结果中{key}字段的{value}值和预期结果不一致========")
if False in self.check_list:
Log().error_log(f"断言方式[json-value]======>预期结果{really_value},实际结果{except_value},断言失败")
else:
Log().info_log(f"断言方式[json-value]======>预期结果{really_value},实际结果{except_value},断言通过")
我是用yaml文件来管理测试用例,每个yaml文件里存放一个接口的全部测试场景。
yaml 是一种数据序列化语言,yaml 文件使用 .yml 或 .yaml 扩展名,并遵循特定的语法规则
语法特点:
大小写敏感;
使用缩进表示层级关系;
缩进时不允许使用Tab键,只允许使用空格;
缩进的空格数目不重要,只要相同层级的元素左侧对齐即可。
也可以使用yaml转换器来进行json和yaml进行转化:Online YAML Tools - TOOLFK
以下是一个简单的登录接口,成功和失败场景的测试用例。包括:用例编号,接口地址,请求方式,请求数据,接口返回。
- test_login_01:
url: login
method: POST
headers:
Content-Type: application/json
data:
username: "$username"
password: "$password"
valid:
msg: "用户$username登录成功"
uid: "123456"
username: "$username"
- test_login_02:
url: login
method: POST
headers:
Content-Type: application/json
data:
username: ""
password: "$password"
valid: 账号或密码错误
conftest是pytest中主要存放fixture的配置文件,它可以进行前置操作,数据的共享。
比如获取登录接口token的方法,前置操作数据库数据的方法,清空yaml文件的方法等等都可以写在conftest文件里。
import pytest
from test_api.common.getYaml import GetYaml
# 请空all.yaml文件,以便之后写入参数,保持接口关联性
@pytest.fixture(scope="class")
def clear_yamlFile():
GetYaml("all.yaml").clear_yaml()
注:clear_yamlFile这个方法是清空yaml文件的,主要是接口关联的场景需要。后一个接口的参数要用到前一个接口的返回,将需要的参数放到专门的yaml文件里,第一个接口请求之前先清空。
我是写了一个从登录,登录后查询goods,还有个content-type为form表单的post接口
通过pytets.mark.parametrize()进行参数化,将yaml里的测试用例信息传给testcase_list。
from string import Template
import allure
import pytest
from test_api.common.request_operate import RequestUtil
from test_api.common.get_yaml import GetYaml
from test_api.common.get_config import GetConfig
from test_api.common.generate_data import GeneratedData
from test_api.common.assert_data import Check_data
from test_api.common.crypt_data import MD5_encode
path = GetConfig().get_url("test")
getYamlLogin = GetYaml("login.yaml")
getYamlGoods = GetYaml("goods.yaml")
getYamlForm = GetYaml("form.yaml")
getYamlAll = GetYaml("all.yaml")
request = RequestUtil()
check = Check_data()
# md5加密测试接口
# username = MD5_encode("admin")
# password = MD5_encode("admin123")
# 随机生成账号密码
username = GeneratedData().generatedName()
password = GeneratedData().generatedRandomNumber(8)
@allure.feature("登录查询form表单")
class Test_main:
@allure.story("登录")
# 将该方法打上login标签
@pytest.mark.login
# 失败重新执行2次
@pytest.mark.flaky(reruns=2)
# 执行顺序(第一个执行)
@pytest.mark.run(order=1)
# 参数化,请求参数,方法,地址从login.yaml文件里取
@pytest.mark.parametrize("testcase_list", [getYamlLogin.read_main_yaml(username=username, password=password)])
def test_login(self, clear_yamlFile, testcase_list):
testcase = testcase_list["test_login_01"]
url = path + testcase["url"]
response = request.all_request(url, testcase["method"], testcase['headers'], request_data=testcase["data"])
# 多重断言assume,前面断言失败后面也会执行
pytest.assume(response.status_code == 200)
check.key_check(response.json(), testcase["valid"])
check.value_check(response.json(), testcase["valid"])
# 将该接口返回写入到all.yaml文件中
GetYaml("all.yaml").write_yaml({"uid": response.json()["uid"]})
@allure.story("查询goods")
@pytest.mark.run(order=2)
@pytest.mark.queryGoods
@pytest.mark.parametrize("testcase", [getYamlGoods.read_main_yaml()])
def test_queryGoods(self, testcase):
url = path + testcase["url"]
# 获取上一个接口的返回参数用于该接口的请求
data = Template(str(testcase["data"])).safe_substitute(getYamlAll.read_main_yaml(link=True))
response = request.all_request(url, testcase["method"], testcase['headers'], request_data=eval(data))
pytest.assume(response.status_code == 200)
check.key_check(response.json(), testcase["valid"])
check.value_check(response.json(), testcase["valid"])
@allure.story("查询form表单")
@pytest.mark.run(order=3)
@pytest.mark.formData
@pytest.mark.parametrize("testcase", [getYamlForm.read_main_yaml(name=username)])
def test_formData(self, testcase):
url = path + testcase["url"]
request_data = testcase["data"]
response = request.all_request(url, testcase["method"], testcase['headers'], request_data=request_data)
pytest.assume(response.status_code == 200)
check.key_check(response.json(), testcase["valid"])
check.value_check(response.json(), testcase["valid"])
该配置文件可以设置pytest运行的具体文件或者方法;默认命令行;设置标签(运行指定的测试用例)
[pytest]
# 指定运行的路径
testpaths = ./test_case
# 指定运行的文件
# python_files = test_login.py
python_files = test_main.py
;指定运行的类和方法
;python_class
;python_function
# 默认命令行
addopts = -vs
# 标签
markers =
login
queryGoods
formData
para
import pytest
import os
path = os.path.dirname(__file__)
# f"{path}/test_case/test_login.py::Test_login"
if __name__ == "__main__":
pytest.main(['--alluredir=./report/xml'])
pytest.main()里面传list对象,list里有多个命令行参数。在list里面写入你要执行类或者函数的路径
'--alluredir=./report/xml'在当前项目目录下生成测试报告。