在上一篇文章《Pytest fixture及conftest详解》中,我们介绍了fixture的一些关键特性、用法、作用域、参数等,本篇文章将结合fixture及conftest实现一键动态切换自动化测试环境。在开始前,我们可以先思考几个问题:动态切换测试环境的目的是什么(能够解决什么问题)?该如何实现(实现方案)?具体步骤是什么(实现过程)?
动态切换测试环境的目的是什么,或者说它能解决什么样的问题:
其实以上总结起来就是:一套测试脚本,能根据环境进行自动化的配置,省去手动配置参数的步骤,可以实现在多环境中运行,从而快速验证各个接口及相关服务在不同环境中的表现。
我们希望:可以有个开关,自由控制执行脚本的运行环境,而不是需要我们手动修改,比如:选择dev时,自动读取的是开发环境的配置及测试数据:url、数据库配置、账号密码、测试数据;当切换到test时,自动读取的是测试环境的配置及测试数据。
大致实现原理如下所示:
项目结构大致如下,至于目录结构和文件命名,只能说萝卜青菜各有所爱。比如有人喜欢把存放公共方法的common目录命名为utils,存放各个api模块的api目录命名为src......
上述的方案单从文字层面可能有些难以理解,下面我们结合具体的代码案例来详细讲述一下实现过程。
在conftest.py中定义一个hook函数,实现自定义命令行工具,名为pytest_addoption(固定写法),用来在命令行中传入不同的环境参数;
def pytest_addoption(parser):
"""
添加命令行参数
parser.addoption为固定写法
default 设置一个默认值,此处设置默认值为test
choices 参数范围,传入其他值无效
help 帮助信息
"""
parser.addoption(
"--env", default="test", choices=["dev", "test", "pre"], help="enviroment parameter"
)
在conftest.py中定义get_env的fixture函数,用来获取用户在命令行输入的参数值,传递给fixture.py中的各个fixture函数。pytestconfig是request.config的快捷方式,所以request.config也可以写成pytestconfig。
@pytest.fixture(scope="session")
def get_env(request):
return request.config.getoption("--env")
来测试一下命令行能否输入参数以及fixture函数get_env能否获取到。我们可以简单定义一个测试用例:
def test_env(get_env):
print(f"The current environment is: {get_env}")
然后通过命令行执行此测试用例:
pytest -s -v --env dev test_env.py::test_env
执行结果如下:
例如当前项目为jc项目,则可以在fixture目录下定义一个jc_fixture.py的文件,用于专门存放此项目相关的fixture函数。fixture.py中的各个fixture函数根据get_env提供的环境参数值,解析测试环境对应的数据文件内容:URL(get_url)、账号(get_user)、数据库配置(get_db),同时传递给api类(api_module_A...B...C)进行实例化,登录方法(login)、数据库连接方法(use_db)等,进行初始化,这部分fixture函数再传递给测试用例,用于用例前后置操作(相当于setup/teardown);
import pytest
from config.config import URLConf, PasswordConf, UsernameConf, ProductIDConf
from api.jc_common import JCCommon
from api.jc_resource import JCResource
from config.db_config import DBConfig
from common.mysql_handler import MySQL
@pytest.fixture(scope="session")
def get_url(get_env):
"""解析URL"""
global url
if get_env == "test":
print("当前环境为测试环境")
url = URLConf.RS_TEST_URL.value
elif get_env == "dev":
print("当前环境为开发环境")
url = URLConf.RS_DEV_URL.value
elif get_env == "pre":
print("当前环境为预发布环境")
url = URLConf.RS_PRE_URL.value
return url
@pytest.fixture(scope="session")
def get_user(get_env):
"""解析登录用户"""
global username_admin, username_boss
# 若get_env获取到的是test,则读取配置文件中测试环境的用户名
if get_env == "test":
username_admin = UsernameConf.RS_TEST_ADMIN.value
username_boss = UsernameConf.RS_TEST_BOSS.value
# 若get_env获取到的是dev,则读取配置文件中开发环境的用户名
elif get_env == "dev":
username_admin = UsernameConf.RS_TEST_ADMIN.value
username_boss = UsernameConf.RS_TEST_BOSS.value
# 若get_env获取到的是pre,则读取配置文件中预发布环境的用户名
elif get_env == "pre":
username_admin = UsernameConf.RS_TEST_ADMIN.value
username_boss = UsernameConf.RS_TEST_BOSS.value
@pytest.fixture(scope="session")
def get_db(get_env):
"""解析数据库配置"""
global db_host, db_pwd, db_ssh_host, db_ssh_pwd, db_name
if get_env == "test":
db_host = DBConfig.db_test.get('host')
db_pwd = DBConfig.db_test.get('pwd')
db_ssh_host = DBConfig.db_test.get('ssh_host')
db_ssh_pwd = DBConfig.db_test.get('ssh_pwd')
db_name = DBConfig.db_test.get('dbname_jc')
elif get_env == "dev":
db_host = DBConfig.db_test.get('host')
db_pwd = DBConfig.db_test.get('pwd')
db_ssh_host = DBConfig.db_test.get('ssh_host')
db_ssh_pwd = DBConfig.db_test.get('ssh_pwd')
db_name = DBConfig.db_test.get('dbname_jc')
elif get_env == "pre":
db_host = DBConfig.db_test.get('host')
db_pwd = DBConfig.db_test.get('pwd')
db_ssh_host = DBConfig.db_test.get('ssh_host')
db_ssh_pwd = DBConfig.db_test.get('ssh_pwd')
db_name = DBConfig.db_test.get('dbname_jc')
@pytest.fixture(scope="session")
def jc_common(get_env, get_url):
"""传入解析到的URL、实例化jc项目公共接口类"""
product_id = ProductIDConf.JC_PRODUCT_ID.value
jc_common = JCCommon(product_id=product_id, url=get_url)
return jc_common
@pytest.fixture(scope="session")
def jc_resource(get_env, get_url):
"""传入解析到的URL、实例化jc项目测试接口类"""
product_id = ProductIDConf.JC_PRODUCT_ID.value
jc_resource = JCResource(product_id=product_id, url=get_url)
return jc_resource
@pytest.fixture(scope="class")
def rs_admin_login(get_user, jc_common):
"""登录的fixture函数"""
password = PasswordConf.PASSWORD_MD5.value
login = jc_common.login(username=username_shipper, password=password)
shipper_user_id = login["b"]
return admin_user_id
@pytest.fixture(scope="class")
def jc_get_admin_user_info(jc_common, jc_admin_login):
"""获取用户信息的fixture函数"""
user_info = jc_common.get_user_info(user_id=rs_shipper_login)
shipper_cpy_id = user_info["d"]["b"]
return admin_cpy_id
@pytest.fixture(scope="class")
def use_db(get_db):
"""链接数据库的fixture函数"""
mysql = MySQL(host=db_host, pwd=db_pwd, ssh_host=db_ssh_host, ssh_pwd=db_ssh_pwd, dbname=db_name)
yield mysql
mysql.disconnect()
登录模块:jc_common.py
from common.http_requests import HttpRequests
class JcCommon(HttpRequests):
def __init__(self, url, product_id):
super(JcCommon, self).__init__(url)
self.product_id = product_id
def login(self, username, password):
'''用户登录'''
headers = {"product_id": str(self.product_id)}
params = {"a": int(username), "b": str(password)}
response = self.post(uri="/userlogin", headers=headers, params=params)
return response
def get_user_info(self, uid, token):
'''获取用户信息'''
headers = {"user_id": str(uid), "product_id": str(self.product_id), "token": token}
response = self.post(uri="/user/login/info", headers=headers)
return response
业务模块:jc_resource.py
from common.http_requests import HttpRequests
from faker import Faker
class RSResource(HttpRequests):
def __init__(self, url, product_id):
super(RSResource, self).__init__(url)
self.product_id = product_id
self.faker = Faker(locale="zh_CN")
def add_goods(self, cpy_id, user_id, goods_name, goos_desc='', goods_type='', goos_price=''):
"""新增商品"""
headers = {"product_id": str(self.product_id), "cpy_id": str(cpy_id), "user_id": str(user_id)}
params = {"a": goods_name, "b": goos_desc, "c": goods_type, "d": goos_price}
r = self.post(uri="/add/goods", params=params, headers=headers)
return r
def modify_goods(self, cpy_id, user_id, goods_name, goos_desc='', goods_type='', goos_price=''):
"""修改商品信息"""
headers = {"product_id": str(self.product_id), "cpy_id": str(cpy_id), "user_id": str(user_id)}
params = {"a": car_name, "ab": car_id, "b": company_id, "c": car_or_gua}
r = self.post(uri="/risun/res/car/add/blacklist?md=065&cmd=006", params=params, headers=headers)
return r
各个模块的api函数作为独立的存在,将配置与函数隔离,且不涉及任何fixture的引用。这样无论测试URL、用户名、数据库怎么变换,也无需修改待测模块的api函数,基本可以做到一劳永逸,除非接口地址和传参发生变化。
JC项目的测试用例类TestJcSmoke根据各个jc_fixture.py中各个fixture函数返回的实例对象、配置信息,调用各个业务模块的api函数,执行测试,并读写数据库实现数据校验、断言;
import os
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))))
import allure
from fixture.jc_fixture import *
from common.parse_excel import ParseExcel
logger = LogGen("JC接口Smoke测试").getLog()
@allure.feature("JC项目接口冒烟测试")
class TestJcSmoke:
def setup_class(self):
self.fake = Faker("zh_CN")
# 将fixture中的jc_resource实例、数据库实例、登录等fixture函数传递给测试用例进行调用
@pytest.mark.jc_smoke
@allure.story("商品管理")
def test_01_goods_flow(self, jc_resource, jc_admin_login, jc_get_admin_user_info, use_db):
"""测试商品增删改查接口"""
user_id = jc_admin_login
cpy_id = jc_get_admin_user_info
goods_name = "iphone 14pro max 512G"
try:
logger.info(f"新增'{goods_name}'商品")
with allure.step("调用添加商品接口"):
add_goods = jc_resource.add_goods(cpy_id, user_id, goods_name, goods_type=1)
assert add_goods["a"] == 200
self.goods_id = add_goods["d"]
select_db = use_db.execute_sql(
f"SELECT * FROM goods_info WHERE company_id = {cpy_id} AND id = {self.goods_id}") # 查询数据库是否存在新增的数据
assert goods_name in str(select_db)
logger.info(f"商品'{goods_name}'新增成功")
logger.info(f"修改'{goods_name}'的商品信息")
with allure.step("调用修改商品接口"):
modify_goods = jc_resource.modify_goods(cpy_id, user_id, goods_id=self.goods_id, goods_name=goods_name, goods_type=2)
assert modify_goods["a"] == 200
select_db = use_db.execute_sql(
f"SELECT goodsType FROM goods_info WHERE company_id = {cpy_id} AND id = {self.goods_id}")
assert str(select_db[0]) == '2'
logger.info(f"修改'{goods_name}'的商品信息成功")
logger.info(f"开始删除商品'{goods_name}'")
with allure.step("调用删除商品接口"):
del_goods = jc_resource.delete_goods(cpy_id, user_id, goods_id=self.goods_id)
assert del_goods["a"] == 200
select_db = use_db.execute_sql(
f"SELECT * FROM goods_info WHERE id = {self.goods_id}")
print(select_db)
logger.info(f"删除商品'{goods_name}'成功")
except AssertionError as e:
logger.info(f"商品流程测试失败")
raise e
在上述smoke测试用例test_01_goods_flow中,同时验证了商品的增、改、删三个接口,形成一个简短的业务流,如果接口都是畅通的话,则最后会删除商品,无需再手动维护。
注: 1、上述模块接口及测试用例仅为演示使用,非真实存在。 2、在传统的测试用例设计模式中,会把一些实例化放在setup或setup_class中,如:jc_resource = JcResource(xxx),但因为fixture函数无法在前后置方法中传递的缘故,所以要把一些实例化的操作放在fixture函数中进行,并return一个内存地址,直接传递给测试用例,从而使测试用例能够调用到实例对象中的业务api。
完成了命令行参数、解析策略、封装接口、测试用例编写后,既可以直接在编辑器中点击运行按钮执行测试,也可以在命令行驱动执行。以下演示命令行执行用例:
pytest -v -s --env online test_jc_smoke.py
此时会提示我们参数错误,online为不可用选项。
pytest -v -s --env test test_jc_smoke.py
为了方便起见,我直接运行了现有项目的测试用例,当传入test时,会在测试环境运行。
一共12条测试用例,全部运行通过:
同时,测试结果发送到企业微信群,关于自动化测试结果自动发送企业微信的实现思路,可参考前面分享过的一篇文章《利用pytest hook函数实现自动化测试结果推送企业微信 》
pytest -v -s --env dev test_jc_smoke.py # 开发环境
pytest -v -s --env pre test_jc_smoke.py # 预发布环境
dev、pre参数接收正常,不过因为开发、预发布环境有问题、没启动的缘故,所以执行失败,可以忽略。
原理说明:
当然,以上也并非最佳设计方案、实现起来也比较复杂,尤其是fixture模块的运用。如果你有更好的实现方案,欢迎讨论、交流!