前言
趁着这个周末闲来无事,简单的开发了一个接口自动化测试框架。javascript
因为我本人也是接口自动化测试的新手,若有不合理或是不正确的地方请多多指教。java
流程说明图
这张图是个人一些设计思路。python
在yaml文件中管理相关的数据便可实现接口测试。web
采用的接口是智学网
网站的API。shell
支持token
认证json
框架体系介绍
目录/文件 | 说明 | 是否为python 包 |
---|---|---|
apiData | 存放测试信息和用例的yaml 文件目录 |
|
basic | 基类包,封装requests ,json 等经常使用方法 |
是 |
common | 公共类,封装读取yaml 文件,cookies 等经常使用方法 |
是 |
config | 配置目录,目录配置,allure环境变量配置 | 是 |
logs | 日志文件 | |
Test | 测试用例 | 是 |
tools | 工具类,日志等 | 是 |
pytest.ini | pytest配置文件 | |
run.bat | 执行脚本 | |
readme.md | 自述文件 |
配置用例信息
通过excel和yaml的对比,最终我选择了yaml文件管理用例信息。api
BusinessInterface.yaml
服务器
业务接口测试cookie
登陆验证: method: post route: /loginSuccess/ RequestData: data: userId: "{{data}}" expectcode: 200 regularcheck: resultcheck: '"result":"success"'
stand_alone_interface.yaml
session
单个接口测试
登陆: method: post route: /weakPwdLogin/?from=web_login RequestData: data: loginName: 18291900215 password: dd636482aca022 code: description: encrypt expectcode: 200 regularcheck: '[\d]{16}' resultcheck: '"result":"success"' extractresult: - data
配置测试信息
testInfo.yaml
测试信息配置
test_info: # 测试信息 url: https://www.zhixue.com timeout: 30.0 headers: Accept: application/json, text/javascript, */*; q=0.01 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Connection: keep-alive User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36 cookies: aliyungf_tc=AQAAANNdlkvZ2QYAIb2Q221oiyiSOfhg; tlsysSessionId=cf0a8657-4a02-4a40-8530-ca54889da838; isJump=true; deviceId=27763EA6-04F9-4269-A2D5-59BA0FB1F154; 6e12c6a9-fda1-41b3-82ec-cc496762788d=webim-visitor-69CJM3RYGHMP79F7XV2M; loginUserName=18291900215 X-Requested-With: XMLHttpRequest
读取信息
ApiData.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import os from ruamel import yaml from config.conf import DATA_DIR class ApiInfo: """接口信息""" def __init__(self): self.info_path = os.path.join(DATA_DIR, 'testinfo.yaml') self.business_path = os.path.join(DATA_DIR, 'BusinessInterface.yaml') self.stand_alone_path = os.path.join(DATA_DIR, 'stand_alone_interface.yaml') @classmethod def load(cls, path): with open(path, encoding='utf-8') as f: return yaml.safe_load(f) @property def info(self): return self.load(self.info_path) @property def business(self): return self.load(self.business_path) @property def stand_alone(self): return self.load(self.stand_alone_path) def test_info(self, value): """测试信息""" return self.info['test_info'][value] def login_info(self, value): """登陆信息""" return self.stand_alone['登陆'].get(value) def case_info(self, name): """用例信息""" return self.business[name] def stand_info(self, name): """单个接口""" return self.stand_alone[name] testinfo = ApiInfo() if __name__ == '__main__': print(testinfo.info['test_info'])
封装日志
logger.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import os import logging from config import conf from datetime import datetime class Logger: def __init__(self): self.logger = logging.getLogger() if not self.logger.handlers: self.logger.setLevel(logging.DEBUG) # 建立一个handler,用于写入日志文件 fh = logging.FileHandler(self.log_path, encoding='utf-8') fh.setLevel(logging.DEBUG) # 在控制台输出 ch = logging.StreamHandler() ch.setLevel(logging.INFO) # 定义hanler的格式 formatter = logging.Formatter(self.fmt) fh.setFormatter(formatter) ch.setFormatter(formatter) # 给log添加handles self.logger.addHandler(fh) self.logger.addHandler(ch) @property def fmt(self): return '%(asctime)s %(levelname)s %(filename)s:%(lineno)d %(message)s' @property def log_path(self): if not os.path.exists(LOG_PATH): os.makedirs(LOG_PATH) return os.path.join(LOG_PATH, '{}.log'.format(datetime_strftime())) log = Logger().logger if __name__ == '__main__': log.info("你好")
封装requests
request.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import json import allure import urllib3 import requests from utils.logger import log from requests import Response from requests.status_codes import codes from requests.exceptions import RequestException from common.ApiData import testinfo from common.RegExp import regexps from core.serialize import deserialization, serialization from core.getresult import get_result urllib3.disable_warnings() __all__ = ['req', 'codes'] class HttpRequest(object): """requests方法二次封装""" def __init__(self): self.timeout = 30.0 self.r = requests.session() self.headers = testinfo.test_info('headers') def send_request(self, method: str, route: str, extract: str, **kwargs): """发送请求 :param method: 发送方法 :param route: 发送路径 optional 可选参数 :param extract: 要提取的值 :param params: 发送参数-"GET" :param data: 发送表单-"POST" :param json: 发送json-"post" :param headers: 头文件 :param cookies: 验证字典 :param files: 上传文件,字典:相似文件的对象`` :param timeout: 等待服务器发送的时间 :param auth: 基本/摘要/自定义HTTP身份验证 :param allow_redirects: 容许重定向,默认为True :type bool :param proxies: 字典映射协议或协议和代理URL的主机名。 :param stream: 是否当即下载响应内容。默认为“False”。 :type bool :param verify: (可选)一个布尔值,在这种状况下,它控制是否验证服务器的TLS证书或字符串,在这种状况下,它必须是路径到一个CA包使用。默认为“True”。 :type bool :param cert: 若是是字符串,则为ssl客户端证书文件(.pem)的路径 :return: request响应 """ pass method = method.upper() url = testinfo.test_info('url') + route try: log.info("Request Url: {}".format(url)) log.info("Request Method: {}".format(method)) if kwargs: kwargs_str = serialization(kwargs) is_sub = regexps.findall(kwargs_str) if is_sub: new_kwargs_str = deserialization(regexps.subs(is_sub, kwargs_str)) log.info("Request Data: {}".format(new_kwargs_str)) kwargs = new_kwargs_str log.info("Request Data: {}".format(kwargs)) if method == "GET": response = self.r.get(url, **kwargs, headers=self.headers, timeout=self.timeout) elif method == "POST": response = self.r.post(url, **kwargs, headers=self.headers, timeout=self.timeout) elif method == "PUT": response = self.r.put(url, **kwargs, headers=self.headers, timeout=self.timeout) elif method == "DELETE": response = self.r.delete(url, **kwargs, headers=self.headers, timeout=self.timeout) elif method in ("OPTIONS", "HEAD", "PATCH"): response = self.r.request(method, url, **kwargs, headers=self.headers, timeout=self.timeout) else: raise AttributeError("send request method is ERROR!") with allure.step("%s请求接口" % method): allure.attach(url, name="请求地址") allure.attach(str(response.headers), "请求头") if kwargs: allure.attach(json.dumps(kwargs, ensure_ascii=False), name="请求参数") allure.attach(str(response.status_code), name="响应状态码") allure.attach(str(elapsed_time(response)), name="响应时间") allure.attach(response.text, "响应内容") log.info(response) log.info("Response Data: {}".format(response.text)) if extract: get_result(response, extract) return response except RequestException as e: log.exception(format(e)) except Exception as e: raise e def __call__(self, *args, **kwargs): return self.send_request(*args, **kwargs) def close_session(self): print("关闭会话") self.r.close() def elapsed_time(func: Response, fixed: str = 's'): """ 用时函数 :param func: response实例 :param fixed: 1或1000 秒或毫秒 :return: """ try: if fixed.lower() == 's': second = func.elapsed.total_seconds() elif fixed.lower() == 'ms': second = func.elapsed.total_seconds() * 1000 else: raise ValueError("{} not in ['s','ms']".format(fixed)) return second except RequestException as e: log.exception(e) except Exception as e: raise e req = HttpRequest() if __name__ == '__main__': pass
前置条件
conftest.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import json import pytest from core.request import req from common.ApiData import testinfo from core.checkresult import check_results @pytest.fixture(scope='session') def is_login(request): """登陆""" r = req(testinfo.login_info('method'), testinfo.login_info('route'), testinfo.login_info('extractresult'), **testinfo.login_info('RequestData')) result = json.loads(r.text) check_results(r, testinfo.stand_info('登陆')) if 'token' in result: req.headers['Authorization'] = "JWT " + result['token'] def fn(): req.close_session() request.addfinalizer(fn) if __name__ == '__main__': pass
进行测试
无需依赖的接口
test_stand_alone.py
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import pytest import allure from core.request import req from common.ApiData import testinfo from core.checkresult import check_results @allure.feature("单个API测试") class TestStandAlone: @pytest.mark.parametrize('case', testinfo.stand_alone.values(), ids=testinfo.stand_alone.keys()) def test_stand_alone_interface(self, case): r = req(case['method'], case['route'], case.get('extractresult'), **case['RequestData']) check_results(r, case) print(r.cookies) if __name__ == "__main__": pytest.main(['test_business.py'])
无需依赖的接口在测试函数的参数中不传入"is_login"
须要依赖的接口
test_business.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import pytest import allure from core.request import req from common.ApiData import testinfo from core.checkresult import check_results @allure.feature("业务流程API测试") class TestBusiness: @pytest.mark.parametrize('case', testinfo.business.values(), ids=testinfo.business.keys()) def test_business_interface(self, is_login, case): r = req(case['method'], case['route'], case.get('extractresult'), **case['RequestData']) check_results(r, case) if __name__ == "__main__": pytest.main(['test_business.py'])
须要依赖的接口在测试函数的参数中传入"is_login"参数
校验测试结果
checkresult.py
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import re import pytest import allure from requests import Response def check_results(r: Response, case_info): """检查运行结果""" with allure.step("校验返回响应码"): allure.attach(name='预期响应码', body=str(case_info['expectcode'])) allure.attach(name='实际响应码', body=str(r.status_code)) pytest.assume(case_info['expectcode'] == r.status_code) if case_info['resultcheck']: with allure.step("校验响应预期值"): allure.attach(name='预期值', body=str(case_info['resultcheck'])) allure.attach(name='实际值', body=r.text) pytest.assume(case_info['resultcheck'] in r.text) if case_info['regularcheck']: with allure.step("正则校验返回结果"): allure.attach(name='预期正则', body=case_info['regularcheck']) allure.attach(name='响应值', body=str(re.findall(case_info['regularcheck'], r.text))) pytest.assume(re.findall(case_info['regularcheck'], r.text))
接口参数关联
在接口测试中咱们须要用上一个接口返回的数据,我在思考了两天以后采起了正则提取的方式来实现此功能,原本想用jinja2模板可是不太会用。
建立正则类
RegExp.py
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import re from utils.logger import log from common.variable import is_vars class RegExp(object): """正则相关类""" def __init__(self): self.re = re def findall(self, string): keys = self.re.findall(r"\{{(.*?)}\}", string) return keys def subs(self, keys, string): result = None log.info("提取变量:{}".format(keys)) for i in keys: log.info("替换变量:{}".format(i)) result = self.re.sub(r"\{{%s}}" % i, is_vars.get(i), string) log.info("替换结果:{}".format(result)) return result def __call__(self, exp, string): return self.re.findall(r'\"%s":"(.*?)"' % exp, string)[0] regexps = RegExp() if __name__ == '__main__': pass
添加全局变量池
variable.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- class Variable(object): """全局变量池""" def __init__(self): super().__init__() def set(self, key, value): setattr(self, key, value) def get(self, key): return getattr(self, key) def has(self, key): return hasattr(self, key) is_vars = Variable() if __name__ == '__main__': is_vars.set('name', 'hoou') print(is_vars.get('name'))
获取接口的返回值
#!/usr/bin/env python3 # -*- coding: utf-8 -*- import pytest import allure from utils.logger import log from requests import Response from common.variable import is_vars from common.RegExp import regexps def get_result(r: Response, extract): """获取值""" for i in extract: value = regexps(i, r.text) log.info("正则提取结果值:{}={}:".format(i, value)) is_vars.set(i, value) pytest.assume(is_vars.has(i)) with allure.step("提取返回结果中的值"): for i in extract: allure.attach(name="提取%s" % i, body=is_vars.get(i))
配置pytest.ini
pytest.ini
[pytest] addopts = -s -q
配置allure环境变量
APIenv=TEST APIversion=1.0 TestServer=https://www.zhixue.com Tester=hoou
执行测试
run.bat
pytest --alluredir allure-results --clean-alluredir COPY config\environment.properties allure-results allure generate allure-results -c -o allure-report allure open allure-report
运行结果


这就是本周末开发的接口自动化测试框架详情。。。
因为我才疏学浅,里面不全面的地方仍是有不少的。好比:
- 接口以前参数提取和传递
- 密码MD5加密
等。。。
2020年7月17日晚更新:
已添加接口接口上下文关联参数提取传递
有木有大佬给我点建议或参考实现这些,感激涕零
最后、这个框架对于简单的跑接口应该足够使用了。