aamt工具:介绍–专注于接口自动化
正式版源码:
aamt简介
aamt是Api AutoMation Test的首字母缩写,是一款基于pytest测试框架的测试工具,集成了各种实用的第三方包和优秀的自动化测试设计思想,帮你快速实现自动化项目落地。
快速入门
安装
安装最新版本
pip install aamt
指定版本安装
pip install aamt==0.2.5
升级aamt
pip install -U aamt
# 大写U
验证安装成功:
aamt -V
# 大写V
新建项目脚手架
aamt startproject demo
带上-venv参数,可创建单个项目的Python虚拟环境,并在该项目的虚拟环境中安装tep:
aamt startproject demo -venv
外网速度慢,pandas可能安装失败,推荐用国内镜像
pip --default-timeout=6000 install -i https://pypi.tuna.tsinghua.edu.cn/simple aamt
目录结构说明
api :各模块接口文件
data:用例yaml数据
case:测试用例;
fixtures:Pytest fixture,自动导入;
common:个人封装 比如邮件、mysql、project项目各种路径、token信息读取等
report:测试报告;
conftest.py:Pytest挂载;
file:待上传的文件目录;
utils:工具包;
pytest.ini:Pytest配置;
main.py:执行入口;
requirements.txt:项目依赖;
项目结构说明.txt ;
用例集
在case目录下将测试用例按功能模块分成多个用例集:
case
offer_doctor
test_offer_doctor.py
offer_nurse
test_offer_nurse.py
brand
test_brand.py
测试用例
必须遵循用例解耦原则,每条用例都是单独可运行的。用例由3个文件组成,一个存放不同功能模块的api接口,一个存放test用例,一个文件存放纯粹的yaml数据 :
api
offer_doctor
offer_need_api.py
case
offer_doctor
test_offer_doctor.py
data
offer_doctor
offer_doctor.yaml
单独把api摘出来,是为了方便不能的功能模块使用这个api接口,直接导入使用即可
用例只需要安装流程组装不同的业务逻辑
yaml文件里是参数化数据
一条测试用例由鉴权信息、多个测试步骤和多个断言组成:
@allure.title("采购商部分入库后(入库数量3),供应商只能同步到的入库数据为3")
def test(xuefeng1_offer_doctor1_need, xuefeng2_offer_nurse1_receive):
case_vars.put("token", login["token"])
cache = TepCache(env_vars=env_vars, case_vars=case_vars)
# 供货商领取需求订单
xuefeng2_offer_nurse1_receive.receive_offer()
# pytest原生断言
assert 1==1
# 供货商发货
xuefeng2_offer_nurse1_receive.nurse_ship()
# pytest原生断言
assert 1==1
# 采购商入库
xuefeng1_offer_doctor1_need.add_inbound()
# pytest原生断言
assert 1==1
# 供货商同步入库数据
xuefeng2_offer_nurse1_receive.nurse_sync_inboundQuantity()
# pytest原生断言 下文有详细介绍
assert 1==1
xuefeng1_offer_doctor1_need、xuefeng2_offer_nurse1_receive 为 session 级别的 fixture
# conftest.py
@pytest.fixture(scope="session")
def xuefeng2_offer_nurse1_receive():
return Nurse_receive("xuefeng2_nurse1_token")
具体的存放位置,前期可以先写在不同功能模块的test*.py文件里;后期根据情况,可以统一存放在case目录下,如下

用例运行时 项目里的 fixture 会自动导入,要注意的是 fixture 不要重名,否则可能导入的fixture并不是你想要的
测试步骤
测试步骤采用了 @allure.step('xx') , 比如发货功能中的上传运单接口 被我封装为一个函数 它的步骤描述就是供货商上传运单。
步骤的实现函数定义在api文件中的功能模块类 Nurse_receive中:
# nurse_need_api.py
class Nurse_receive(HttpClient):
def __init__(self, token='xuefeng2_nurse1_token'):
# 超类继承
super().__init__()
# 从配置文件里 读取最新的token文件
self.token = Operate_token().read_token(section='Token', key=token)
@allure.step('采购商填运单发货')
def nurse_ship(amounts=[2, 3]):
method = 'post'
url = '/offer/shipment/create'
# url处理方法
url = self.get_full_url(url, h=self.host)
# 调用上传运单方法
trackNo = self.upload_waybill(file_path)
# 组装数据
list_= []
for amount in amounts:
dic_ = {}
dic_['trackNo'] = trackNo
dic_['amount'] = str(amount)
list_.append(dic_)
# 通过缓存池保存数据,方便测试用例其他步骤断言
self.cache.put('shipmentHistoryList', list_)
body = {"takeOfferId":takeOfferId, "tracks":list_}
ret = self.send(url=url, body=body, method=method, x_token=self.token)
assert jmespath.search('code',ret) < 400, f'供货商发货 接口报错:{ret}'
@allure.step('供货商上传运单')
def upload_waybill(file_path):
method = 'post'
url = '/offer/shipment/create'
url = self.get_full_url(url, h=self.host)
body = {"takeOfferId":takeOfferId, "trackNo":trackNos}
ret = self.send(url=url, body=body, method=method, x_token=self.token)
assert jmespath.search('code',ret) < 400, f'供货商上传运单 接口报错:{ret}'
retrun jmespath.search('result',ret)
Nurse_receive类 中有多个 不同小功能的 实现方法Nurse_receive类 通过super().__init__()继承了 HttpClient的初始化方法,有能力的朋友可以自行重写HttpClient的初始化方法里有 自动实例了一个 缓存变量池、自动读取了一个yaml配置文件里的环境变量数据(包含 host域名、账号密码、mysql数据库等信息)
HttpClient里还对 requests库 进行了二次封装,参照postman发送请求时的操作,通过入参
body_type即可控制 requests请求 json、文件以及文件流、表单数据,极大提升书写效率HttpClient里还对 url 进行了二次封装,书写接口请求时 只需要填写
接口路径, 再调用get_full_url即可实现多场景的完整url。
测试数据
测试数据有两种存在形式
对于业务链路比较长的业务场景,做参数化会比较繁琐,工作过程中这类冒烟用例的测试数据 我是直接写在
test_offer_doctor.py对于比较简单的接口,测试人员时间允许的情况下 可以讲测试数据写在yaml里,用于参数化,覆盖更多的比如边界值、异常等场景
测试标题
测试标题采用了@allure.title(""):
@allure.title('验证采购商发offer功能正常')
def test(xuefeng1_offer_doctor1_need):
变量
环境变量:在resources/env_vars下不同环境的yaml文件里预填变量,在resources/aamt.ini中激活某个环境,模块api.py的功能类Nurse_receive已经继承HttpClient类(会自动读取环境变量),可使用 self.host、self.env_vars_data 直接引用:
# nurse_need_api.py
class Nurse_receive(HttpClient):
def __init__(self, token='xuefeng2_nurse1_token'):
# 超类继承
super().__init__()
def aaa():
# 直接引用 环境变量
self.env_vars_data
# 直接引用 域名
self.host
# 默认header头
self.default_header
用例变量:在用例中引入case_vars fixture,不同步骤函数的数据通过case_vars.put()、case_vars.get()传递,
当用例中下一个步骤的断言 需要用到 上一个步骤的数据(入参或者响应),可通过各个功能类中的缓存变量池 self.cache来获取,存到case_vars中,随用随取。
@allure.title("从登录到下单支付")
def test(case_vars):
case_vars.put("token", login["token"])
cache = TepCache(env_vars=env_vars, case_vars=case_vars)
Step("搜索商品", step_search_sku, cache)
Step("添加购物车", step_add_cart, cache)
def step_search_sku(cache: TepCache):
url = cache.env_vars["domain"] + "/searchSku"
headers = {"token": cache.case_vars.get("token")}
body = data("查询SKU")
response = request("get", url=url, headers=headers, params=body)
assert response.status_code < 400
cache.case_vars.put("skuId", response.jsonpath("$.skuId"))
cache.case_vars.put("skuPrice", response.jsonpath("$.price"))
def step_add_cart(cache: TepCache):
url = cache.env_vars["domain"] + "/addCart"
headers = {"token": cache.case_vars.get("token")}
body = data("添加购物车")
body["skuId"] = cache.case_vars.get("skuId")
response = request("post", url=url, headers=headers, json=body)
assert response.status_code < 400
接口关联
如上所述,通过想HttpClient类中的缓存变量池cache实现了不同的接口间的关联,上一个接口的响应,提取后存入self.cache,下一个接口的入参,从self.cache取值。
数据提取
utils/http_client.py封装了requests.Response,添加了jsonpath方法,支持简单取值:
使用 jmespath或者jsonpath 取值
import jmespath
# 响应:ret结果
code = jmespath.search('code',ret)
断言
采用Python原生的assert断言。16种常用断言如下:
import allure
@allure.title("等于")
def test_assert_equal():
assert 1 == 1
@allure.title("不等于")
def test_assert_not_equal():
assert 1 != 2
@allure.title("大于")
def test_assert_greater_than():
assert 2 > 1
@allure.title("小于")
def test_assert_less_than():
assert 1 < 2
@allure.title("大于等于")
def test_assert_less_or_equals():
assert 2 >= 1
assert 2 >= 2
@allure.title("小于等于")
def test_assert_greater_or_equals():
assert 1 <= 2
assert 1 <= 1
@allure.title("长度相等")
def test_assert_length_equal():
assert len("abc") == len("123")
@allure.title("长度大于")
def test_assert_length_greater_than():
assert len("hello") > len("123")
@allure.title("长度小于")
def test_assert_length_less_than():
assert len("hi") < len("123")
@allure.title("长度大于等于")
def test_assert_length_greater_or_equals():
assert len("hello") >= len("123")
assert len("123") >= len("123")
@allure.title("长度小于等于")
def test_assert_length_less_or_equals():
assert len("123") <= len("hello")
assert len("123") <= len("123")
@allure.title("字符串相等")
def test_assert_string_equals():
assert "dongfanger" == "dongfanger"
@allure.title("以...开头")
def test_assert_startswith():
assert "dongfanger".startswith("don")
@allure.title("以...结尾")
def test_assert_startswith():
assert "dongfanger".endswith("er")
@allure.title("正则匹配")
def test_assert_regex_match():
import re
assert re.findall(r"don.*er", "dongfanger")
@allure.title("包含")
def test_assert_contains():
assert "fang" in "dongfanger"
assert 2 in [2, 3]
assert "x" in {"x": "y"}.keys()
@allure.title("类型匹配")
def test_assert_type_match():
assert isinstance(1, int)
assert isinstance(0.2, float)
assert isinstance(True, bool)
assert isinstance(3e+26j, complex)
assert isinstance("hi", str)
assert isinstance([1, 2], list)
assert isinstance((1, 2), tuple)
assert isinstance({"a", "b", "c"}, set)
assert isinstance({"x": 1}, dict)
测试报告
allure下载地址: https://github.com/allure-framework/allure2/releases
解压后将bin目录添加到系统环境变量Path。
可以 执行以下命令
pytest --aamt-reports
或者 运行项目根路径main.py

就能一键生成Allure测试报告,并且会把请求入参和响应出参,记录在测试报告中。
身份鉴权
封装好了HttpClient,有时候调试或者写爬虫,并不是也不想写test用例的情况下 进行接口调用,我特意将登录token信息 保存在了ini文件里,接口调用的时候 只需在实例功能模块类的时候 传入 不同账号的token标识Nurse_receive("xuefeng2_nurse1_token")

起初的时候 会有顾虑,每次调用都要实例化吗? 当然没必要如此,我们只需要将一个个功能模块的实例统一存放在一个conftest.py 里

借助pytest的fixture夹具,编写用例的时候就可以 重复 调用,即插即用。如下

用例执行
串行
使用pytest命令即可执行用例。
如果想要生成测试报告,使用以下命令
pytest --aamt-reports
并行
使用pytest -n auto,由pytest-xdist提供支持。
pytest -n auto --aamt-reports
特色功能
fixtures自动导入
不是必须在conftest.py里面定义fixture。只要在fixtures目录下,创建以fixture_开头的文件,fixture都会自动加载到pytest中 。
这样做的还有一个好处
不同的测试人员,维护自己的fixture_xx.py文件,在push代码时,可以高效的处理冲突,方便维护。

分布式环境下 全局只登录一次, 解决token复用问题
预置了fixtures/fixture_xf.py登录接口,且全局仅执行一次,解决token复用问题:
import pytest
from api.logintoken import Login_after
@pytest.fixture(scope="session", autouse=True)
def login_doctor1(aamt_context_manager, env_vars_data):
"""
aamt_context_manager 可兼容pytest-xdist分布式执行的上下文管理器
即便分布式执行用例,该login只会在整个运行期间执行一次
"""
def produce_expensive_data(variable):
# 这里的登录方法 ,抽离出来,1、方便换不同的账号登录 2、遇到前后台不同的方法登录,也可以有效的区分 开,统一管理。
return Login_after(variable['xuefeng_doctor1'], 'xuefeng_doctor1_token').login_()
return aamt_context_manager(produce_expensive_data, env_vars_data)
aamt的fixture.py 内置了 封装了aamt_context_manager fixture,可兼容pytest-xdist 分布式执行的上下文管理器,即便多个session 也只会执行一次登录,具体实现方法后面会详细介绍。
常用封装
project.py,项目基本信息,比如根目录路径。
import os
from loguru import logger
class Config:
# 全局项目根目录
# root_dir = os.path.dirname(os.path.dirname(os.getcwd()))
root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# logger.info(f'全局项目根目录:{root_dir}')
data_dir = os.path.join(root_dir, "data")
file_dir = os.path.join(root_dir, "file")
emailhelper.py 封装了 offer365、qq、163等不同服务商发送邮件的方法
mysqlhelper.py 封装了 增删改查的方法,可快速上手。
工具包
fastapi_mock.py,示例应用。
import uvicorn
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/login")
async def login(req: Request):
body = await req.json()
if body["username"] == "dongfanger" and body["password"] == "123456":
return {"token": "de2e3ffu29"}
return ""
@app.get("/searchSku")
def search_sku(req: Request):
if req.headers.get("token") == "de2e3ffu29" and req.query_params.get("skuName") == "电子书":
return {"skuId": "222", "price": "2.3"}
return ""
@app.post("/addCart")
async def add_cart(req: Request):
body = await req.json()
if req.headers.get("token") == "de2e3ffu29" and body["skuId"] == "222":
return {"skuId": "222", "price": "2.3", "skuNum": "3", "totalPrice": "6.9"}
return ""
@app.post("/order")
async def order(req: Request):
body = await req.json()
if req.headers.get("token") == "de2e3ffu29" and body["skuId"] == "222":
return {"orderId": "333"}
return ""
@app.post("/pay")
async def pay(req: Request):
body = await req.json()
if req.headers.get("token") == "de2e3ffu29" and body["orderId"] == "333":
return {"success": "true"}
return ""
if __name__ == '__main__':
uvicorn.run("fastapi_mock:app", host="127.0.0.1", port=5000)
mitm.py,流量录制,做的不是很好,将就看看。
#!/usr/bin/python
# encoding=utf-8
# mitmproxy录制流量自动生成用例
import os
import time
from mitmproxy import ctx
project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
tests_dir = os.path.join(project_dir, "tests")
# tests/mitm
mitm_dir = os.path.join(tests_dir, "mitm")
if not os.path.exists(mitm_dir):
os.mkdir(mitm_dir)
# 当前时间作为文件名
filename = f'test_{time.strftime("%Y%m%d_%H%M%S", time.localtime())}.py'
case_file = os.path.join(mitm_dir, filename)
# 生成用例文件
template = """import allure
from utils.http_client import request
@allure.title("")
def test(env_vars):
"""
if not os.path.exists(case_file):
with open(case_file, "w", encoding="utf8") as fw:
fw.write(template)
class Record:
def __init__(self, domains):
self.domains = domains
def response(self, flow):
if self.match(flow.request.url):
# method
method = flow.request.method.lower()
ctx.log.error(method)
# url
url = flow.request.url
ctx.log.error(url)
# headers
headers = dict(flow.request.headers)
ctx.log.error(headers)
# body
body = flow.request.text or {}
ctx.log.error(body)
with open(case_file, "a", encoding="utf8") as fa:
fa.write(self.step(method, url, headers, body))
def match(self, url):
if not self.domains:
ctx.log.error("必须配置过滤域名")
exit(-1)
for domain in self.domains:
if domain in url:
return True
return False
def step(self, method, url, headers, body):
if method == "get":
body_grammar = f"params={body}"
else:
body_grammar = f"json={body}"
return f"""
# 描述
# 数据
# 请求
response = request(
"{method}",
url="{url}",
headers={headers},
{body_grammar}
)
# 提取
# 断言
assert response.status_code < 400
"""
# ==================================配置开始==================================
addons = [
Record(
# 过滤域名
[
"http://www.httpbin.org",
"http://127.0.0.1:5000"
],
)
]
# ==================================配置结束==================================
"""
==================================命令说明开始==================================
# 正向代理(需要手动打开代理)
mitmdump -s mitm.py
# 反向代理
mitmdump -s mitm.py --mode reverse:http://127.0.0.1:5000 --listen-host 127.0.0.1 --listen-port 8000
==================================命令说明结束==================================
"""
client.py,requests库二次封装,当然有基础的朋友也可以使用原生库
#!/usr/bin/python
# encoding=utf-8
import decimal
import json
import time
import allure
import jsonpath
import requests
import urllib3
from loguru import logger
from requests import Response
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def request(method, url, **kwargs):
template = """\n
Request URL: {}
Request Method: {}
Request Headers: {}
Request Payload: {}
Status Code: {}
Response: {}
Elapsed: {}
"""
start = time.process_time()
response = requests.request(method, url, **kwargs) # requests.request原生用法
end = time.process_time()
elapsed = str(decimal.Decimal("%.3f" % float(end - start))) + "s"
headers = kwargs.get("headers", {})
kwargs.pop("headers")
payload = kwargs
log = template.format(url, method, json.dumps(headers), json.dumps(payload), response.status_code, response.text,
elapsed)
logger.info(log)
allure.attach(log, f'request & response', allure.attachment_type.TEXT)
return TepResponse(response)
class TepResponse(Response):
"""
二次封装requests.Response,添加额外方法
"""
def __init__(self, response):
super().__init__()
for k, v in response.__dict__.items():
self.__dict__[k] = v
def jsonpath(self, expr):
"""
此处强制取第一个值,便于简单取值
如果复杂取值,建议直接jsonpath原生用法
"""
return jsonpath.jsonpath(self.json(), expr)[0]
关于aamt的更多技术细节,请在源码中一探究竟吧。也可以添加微信xiaobangzhu007,随时与我联系。
后续
aamt小工具后期将会根据实际业务场景进行微调,结构上不会做大的改动。