一、背景介绍
- 因在版本迭代过程中,一般都需要调用接口来实现需求业务。而前后端或各系统之间都存在强依赖性,故构思了此Mock接口;主要解决如下场景痛点:
- 1、前后端的依赖关系:如前端已开发完毕但后端还没完成,导致前端无法进行调试;
- 2、外部系统依赖关系:如外部系统未开发完或者环境的因素无法完成对接调试;
- 3、测试阶段依赖关系:测试的某些场景无法模拟下,可调用mock接口设置自定义返回值,从而达到测试场景的覆盖(主要就是这块,因咱就是干测试滴);
二、构思设计及主要功能点
2.1)、主要功能介绍:
1、 接口可自定义规则:对mock接口依据配置规则,做字段数据必填项校验、数据类型校验
2、 接口可自定义匹配:可设置数据匹配规则,且支持设置多个匹配值;
3、 接口支持多级url、动态url、多种请求方式(get/post/put等)
4、 接口状态码自定义:可自定义设置接口返回状态码
5、 响应数据-可自定义变量返回:可依据python代码设置自定义变量(如时间戳、UUID等变量)
6、 接口可自定义控制关闭和开启,关闭后将无法调用
7、 响应数据后门属性:可依据请求指定key后,返回请求中的value数据
2.2)、功能/思路导图:
三、直接先看最后的成果:
-
1、Mock/路由配置页面:定义接口路径以及相应的规则.
-
2、Mock/接口响应数据页面:定义接口匹配数据及返回的数据
-
3、实际调用结果:调用接口验证有效性.
四、上代码说明( 因琐碎点太多,只抽取主流点和关键点作为说明)
- 作为一个测试小白,比不了他们专业的开发人员。这小项目整整一个月断断续续的写完了,其中还是有很多优化空间,后续再慢慢调整;
-
各位大佬,如有优化或其他构思思路欢迎指点评论,我再修改修改;
1、从开始的路由方面:
- 1.1)、主路由:此处为入口的主路由,依据settings配置中ROOT_URLCONF字段判断主路由位置
#settings配置中ROOT_URLCONF字段判断主路由位置
ROOT_URLCONF = 'project.urls'
urlpatterns = [
path(r'admin/mockapi/index/', views.IndexClass.index, name='index-test'), # 接口测试页面
path('admin/', admin.site.urls),#django后台管理页面路由
path('api/mock/', include('mockapi.urls')),#通用接口的路由
path('api/test/', include('testapi.urls')),#自定义接口的路由
]
urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
- 1.2)、二级路由(以api/mock为例):找到二级路由后并进入对应的views(视图)中,既进入视图逻辑:
urlpatterns = [
path(r'index', IndexClass.index, name='index-test'), # 接口测试页面
path('caseid/registno', views.Caseid.as_view(), name='caseid'),
]
add_path = [
re_path(r'(\w+)/(\w+)/(\w+)', views.CurrencyRoute.as_view(), name='test3-api-viewstwo'), # 动态路由+3
re_path(r'(\w+)/(\w+)', views.CurrencyRoute.as_view(), name='test2-api-viewstwo'), # 动态路由+2
re_path(r'(\w+)', views.CurrencyRoute.as_view(), name='test1-api-viewstwo'), # 动态路由+1
]
urlpatterns.extend(add_path)
2、从views视图方面:
class CurrencyRoute(views.APIView, Currency):
'''通用模式下-API接口视图类'''
def __init__(self):
super(Currency, self).__init__()
self.code = ['code', '-1']
self.msg = ['msg']
self.data = ['data', None]
self.DataCheck = DataCheck(self.code, self.msg, self.data)
self.response_data = {self.code[0]: self.code[1], self.msg[0]: None, self.data[0]: self.data[1]}
# 根据url找路由表数据:路由id、条件匹配规则、字段要求规则
def __getroute(self, args, UrlType, replace='/api/mock/', APIOnOff=True):
# route = "/".join(list(args)) if re_path.find('-') == -1 else self.find_path("/", re_path, args)
route = self.request.path.replace(replace, '')
try:
get_data = RouteTabMock.objects.get(CustomUrl=route, UrlType=UrlType, APIOnOff=APIOnOff)
except:
# 兼容动态url接口,目前只支持两级动态,样例:test/case/{variable}/{variable}
route_url = [m.start() for m in re.finditer('/', route)]
for count_i in range(1, len(route_url)):
try:
get_data = RouteTabMock.objects.get(CustomUrl=route[:route_url[-count_i]] + r'/{}' * count_i,
UrlType=UrlType, APIOnOff=APIOnOff)
get_data.ConditionValue = [route[route_url[-count_i] + 1:]]
except RouteTabMock.DoesNotExist:
if count_i == len(route_url) - 1:
raise '动态url匹配模式下,未匹配该url=' + route
else:
break
re_data = get_data.id, get_data.ConditionValue, get_data.APIRequired
logger.info('路由id={},条件匹配={},必填及类型规则={}'.format(re_data[0], re_data[1], re_data[2]))
return re_data
# 接口请求处理,数据处理/规则处理
def __requestprocess(self, path_a):
logger.info(
'{}\n请求类型={},地址={},报文={}'.format(80 * '=', self.request.method, self.request.path, self.request.data))
# 提取及处理request数据
if self.request.method == 'GET':
request_data = dict()
for key, value in dict(self.request.GET).items():
request_data[key] = value[0]
else:
request_data = dict(self.request.data)
# 预留一个后门属性,如果有此test_data字段直接返回
if Currency.test_data(request_data) != None:
if self.request.method == 'GET':
re_data = eval(request_data['test_data'])
else:
re_data = request_data['test_data']
return re_data
# 获取路由id、条件匹配规则、必填及数据类型规则
try:
re_dataid, conditionValue, apirequired = self.__getroute(path_a, UrlType=self.request.method)
except:
msg = '路由表未配置该url:' + self.request.path
self.response_data[self.msg[0]] = msg
logger.error('{},请求返回值={}'.format(msg, self.response_data))
return self.response_data
# 对传入参数进行必填项校验和数据类型的校验,校验不通过为False,返回Response错误的响应体
re_data = self.DataCheck.check(eval(apirequired), request_data)
if re_data != True:
return re_data
# 依据条件匹配规则,找到入参对应的条件查询值
QueryValue = Querymethod().match_value(request_data, conditionValue, self.request.method)
logger.info('条件匹配规则结果:' + str(QueryValue))
# 如无条件匹配规则,取该接口下最新mock数据
if QueryValue == False:
try:
getdb_data = APIResponseMock.objects.filter(CustomUrl_id=re_dataid)
db_data = list(getdb_data.values())[-1]
ResponseData, ResponseCode = self.re_variable(db_data['ResponseData']), db_data['ResponseCode']
re_data = json.loads(ResponseData), ResponseCode
except IndexError:
msg = 'mock表未配置该url{}对应的数据'.format(self.request.path)
self.response_data[self.msg[0]] = msg
logger.error(msg)
# 如果有条件匹配规则,对应的匹配值;如果存在多个匹配值,取第一个匹配的mock值
else:
try:
con = self.Q_model(QueryValue, re_dataid)
getdb_data = APIResponseMock.objects.filter(con)
db_data = list(getdb_data.values())[-1]
ResponseData, ResponseCode = self.re_variable(db_data['ResponseData']), db_data['ResponseCode']
re_data = json.loads(ResponseData), ResponseCode
except Exception as ex:
msg = '未找到匹配值:' + str(QueryValue)
re_data = {self.code[0]: self.code[1], self.msg[0]: msg, self.data[0]: self.data[1]}
logger.error(msg)
logger.info('请求返回值=' + str(re_data))
return re_data
# __接口状态码处理
def __request(self, response_data):
if type(response_data) == type(tuple()):
if str(response_data[1]) in ('None', ''):
response_data = response_data[0], 200
re_data, status_code = response_data
else:
re_data, status_code = response_data, 200
return re_data, status_code
# post请求方式
def post(self, request, *args):
re_data, status_code = self.__request(self.__requestprocess(args))
return Response(re_data, status=status_code)
# Get请求方式
def get(self, request, *args):
re_data, status_code = self.__request(self.__requestprocess(args))
return Response(re_data, status=status_code)
# Put请求方式
def put(self, request, *args):
re_data, status_code = self.__request(self.__requestprocess(args))
return Response(re_data, status=status_code)
*主要逻辑流介绍(不具体分解,详见上述代码内注释):
1、视图第一层处理:后门属性的判断,以及提取和返回处理
2、视图第二层处理:截取url后缀,得到url后找到路由表下对应的字段规则及匹配规则;
3、视图第三层处理:得到接口字段规则后,对路由进行必传和数据类型规则校验;
4、视图第四层处理:依据接口匹配规则,取入参值到mock数据表查找匹配值并响应数据;
class DataCheck(object):
'''数据必填+类型校验'''
def __init__(self, code=['code', '-1'], msg=['msg'], data=['data', None]):
self.key_list = []
self.code = code
self.msg = msg
self.data = data
# 校验必填项以及数据类型,如果不匹配抛出异常
def __value_type(self, dict_b, type_a):
dict_data = dict_b
try:
for keys in self.key_list:
dict_data = dict_data[keys]
if type(dict_data) != type(type_a) and type_a != True:
raise ValueError('[{}]字段类型校验失败,期望传入类型为:{}'.format(keys, type(type_a)))
except KeyError:
if type_a == True:
raise ValueError('必填校验失败,缺少必要参数:' + str(keys))
def check(self, check_a, dict_b):
try:
# 为空不做校验,与页面规则一致
if check_a == {}:
return True
re_data = self.get_dict_allkeys(check_a, dict_b)
except Exception as ex:
# 校验检查不通过,返回的报文;可通过类属性修改自定义key
ex = str(ex).strip('()')
re_data = {self.code[0]: self.code[1], self.msg[0]: ex, self.data[0]: self.data[1]}
logger.error('入参不符合校验规则={},请求返回值={}'.format(check_a, re_data))
return re_data
# 无限遍历dict所有key,递归方式调用
def get_dict_allkeys(self, check_a, dict_b):
# 使用isinstance检测数据类型,dict递归
if isinstance(check_a, dict):
for x in range(len(check_a)):
temp_key = list(check_a.keys())[x]
temp_value = check_a[temp_key]
if type(temp_value) in (type({}), type([])):
self.key_list.append(temp_key)
self.get_dict_allkeys(temp_value, dict_b) # 递归遍历
# 回退dict的上一层结构
self.key_list = self.key_list[:-1]
elif isinstance(check_a, list):
for key_a in check_a:
if isinstance(key_a, dict):
# for x in range(len(key_a)):
# 递归到列表+元素位置0,即只支持列表第一个作为校验点
temp_key = list(key_a.keys())[0]
temp_value = key_a[temp_key]
self.key_list.append(0)
self.key_list.append(temp_key)
self.get_dict_allkeys(temp_value, dict_b)
# 必填校验,兼容true,True
if str(key_a) in ('true', 'True'):
self.__value_type(dict_b, True)
continue
# 数据类型校验,目前校验str/int/bool(兼容缩写及全拼)
if str(key_a) in ('str', 'string', "<class 'str'>"):
self.__value_type(dict_b, str())
elif str(key_a) in ('int', 'integer', "<class 'int'>"):
self.__value_type(dict_b, int())
elif str(key_a) in ('bool', 'boolean', "<class 'bool'>"):
self.__value_type(dict_b, bool())
# 回退dict的上一层结构
self.key_list = self.key_list[:-1]
return True
*主要逻辑处理介绍:
1、无限遍历,如为dict既递归,如为list且再找到必填和数据类型的输入枚举值既做规则校验处理
2、如果不符合校验规则,既响应对应的错误信息.
*备注:说实话,这块的逻辑太复杂了,主要场景太多了;自己测的时候改bug改到崩溃,思路进入了死循环。也体会了下开发改bug的痛苦了;
class Querymethod(object):
def match_value(self, databody, conditionValue, req_type):
'''
依据接口设置匹配规则,获取请求参数中的value;以此作为查询条件
:param databody: 接口请求参数数据-type=dict()
:param conditionValue: 接口配置的匹配规则条件-type=dict()
:return: 根据匹配规则,返回请求体中value
'''
# 递归引用列表取key,直至到value为'True', 'true'结束
def key_list(key_data, lista=list()):
for keys, values in key_data.items():
if type(values) == type({}) or str(values) in ('True', 'true'):
lista.append(keys)
True if str(values) in ('True', 'true') else key_list(values)
elif type(values) == type([]):
lista.append(keys)
lista.append(0)
key_list(values[0], lista)
else:
if lista == []:
lista.append(keys)
break
return lista
# 依据分号切割规则数据,再依次根据规则匹配databody对应值
if type(conditionValue) == type([]):
return conditionValue
elif str(conditionValue).replace(' ', '') != '{}':
re_listdata = []
for key_data in str(conditionValue).split(";"):
re_data = databody
try:
lista = key_list(eval(key_data))
for value in lista:
re_data = re_data[value]
except KeyError:
if len(str(conditionValue).split(";")) == 1:
return False
lista.clear()
else:
re_listdata.append(re_data)
lista.clear()
re_data = re_listdata
else:
logger.warning('未找到到匹配的数据值,匹配规则=' + str(conditionValue))
re_data = False
return re_data
*主要逻辑处理介绍:
1、先判断是否空字典“{}”,如是返回False,既后续其他逻辑返回最新的一条数据;
2、依据分号" ; "切割,依据接口定义的匹配规则,依次找出入参中相应的值;
2、匹配值以列表形式存储,多个匹配值情况后续逻辑将依据正序条件命中并响应对应的数据;
3、从models模型方面:
class RouteTabMock(models.Model):
"""Mock-路由URL表模型 """
re_type = [("POST", "Post"), ("GET", "Get"), ("PUT", "Put")]
APIOnOff = models.BooleanField(default=True, verbose_name='接口启用状态')
UrlType = models.CharField(max_length=6, choices=re_type, default="Post", verbose_name='请求方式')
UrlName = models.CharField(max_length=20, db_index=True, unique=True, verbose_name='接口名称')
CustomUrl = models.CharField(max_length=50, db_index=True, unique=True, verbose_name='url地址')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
ConditionValue = models.TextField(default='{}', verbose_name='条件匹配规则')
APIRequired = models.TextField(default='{}', verbose_name='字段要求规则')
Remarks = models.CharField(max_length=200, null=True, blank=True, default='-', verbose_name='备注说明')
class Meta:
verbose_name = 'Mock/路由配置表'
verbose_name_plural = 'Mock/路由配置表'
# json格式校验,避免输入错误格式:字段要求规则、条件匹配规则
def remarksdisplay(self):
re_data = models_fun().list_setting(self.Remarks, intdata=20)
return re_data
remarksdisplay.allow_tage = True
remarksdisplay.short_description = '备注说明'
def __str__(self):
return '{}:[{}]'.format(self.UrlName, self.CustomUrl)
*主要逻辑处理介绍:
- 1、本次数据存储引用django默认配置的sqlite3方式,为文件形式存储;主要是轻量级、且便捷,如后期mock数据量过大时,可替换专业的数据库(mysql或oracle这些,修改setting中DATABASES字段即可)
- 2、各字段的设计、定义,class Meta为后端显示的名称(也可自定义表名,不添加DB的表名:既为子项目名称+类型);
- 3、clean既对页面添加路由时,对字段要求规则及条件匹配规则做json格式校验;
4、apirequired是解决后台列表字段内容超长时,列表高度被拉长情况(调用后,当输入内容超出设置值时,超长部分将显示"....";详解如下图)
class APIResponseMock(models.Model):
"""Mock-接口数据响应表模型 """
CustomUrl = models.ForeignKey(RouteTabMock, on_delete=models.PROTECT, verbose_name='关联接口')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
QueryValue = models.CharField(max_length=50, db_index=True, verbose_name='查询值')
ResponseData = models.TextField(verbose_name='响应信息数据')
ResponseCode = models.PositiveIntegerField(null=True, blank=True, default=200, verbose_name='接口状态码')
Remarks = models.CharField(max_length=200, null=True, blank=True, default='-', verbose_name='备注说明')
def clean(self):
try:
json.loads(self.ResponseData)
except json.JSONDecodeError:
#兼容~{variable}变量形式写入,即转为空字典在做json检查
try:
str_data = re.findall(r"~{(.+?)}", self.ResponseData)
replace_data = self.ResponseData.replace("~{" + str_data[0] + "}", "{}")
json.loads(replace_data)
except IndexError:
raise ValidationError('输入的json数据变量不符合规则,请检查')
except json.JSONDecodeError:
raise ValidationError('输入的json数据不合法,请检查')
class Meta:
unique_together = ('CustomUrl', 'QueryValue',)
verbose_name = 'Mock/接口响应数据表'
verbose_name_plural = 'Mock/接口响应数据表'
def reprofile(self):
re_data = models_fun().list_setting(self.ResponseData)
return re_data
reprofile.allow_tage = True
reprofile.short_description = '响应信息数据'
*主要逻辑处理介绍:
- 1、如上一个节点的路由配置表同理,相同的逻辑;就不再单独说明了;
4、admin后台管理页面设置方面(以路由表举例):
class RouteTabMockAdmin(admin.ModelAdmin):
def time_seconds(self, obj):
return obj.UpdateTime.strftime("%Y-%m-%d %H:%M:%S")
# 时间格式化处理
time_seconds.admin_order_field = 'UpdateTime'
time_seconds.short_description = '更新时间'
list_display = ['id', 'UrlName', 'CustomUrl', 'UrlType', 'apirequired', 'routeprofile', 'time_seconds',
'remarksdisplay',
'APIOnOff']
list_filter = ['CustomUrl']
search_fields = ['CustomUrl', 'UrlName']
list_per_page = 15 # 分页展示
actions_on_top = False # 页面删除触发指令
actions_on_bottom = True # 页面Action触发指令
fieldsets = [
("接口信息", {"fields": ['UrlName', 'CustomUrl', 'UrlType']}),
("接口规则", {"fields": ['APIOnOff', 'APIRequired', 'ConditionValue', 'Remarks']})
]
admin.site.register(RouteTabMock, RouteTabMockAdmin)
五、日志模块方面:
class Log(object):
level_relations = { 'debug': logging.DEBUG, 'info': logging.INFO, 'warning': logging.WARNING, 'error': logging.ERROR, 'crit': logging.CRITICAL} # 日志级别关系映射
def __init__(self, filename, level='info', when='D', backCount=3,
fmt='%(asctime)s - %(filename)s[line:%(lineno)d] - %(levelname)s: %(message)s'):
self.logger = logging.getLogger(filename)
format_str = logging.Formatter(fmt) # 设置日志格式
self.logger.setLevel(self.level_relations.get(level)) # 设置日志级别
sh = logging.StreamHandler() # 往屏幕上输出
sh.setFormatter(format_str) # 设置屏幕上显示的格式
th = handlers.TimedRotatingFileHandler(filename=filename, when=when, backupCount=backCount,
encoding='utf-8') # 往文件里写入#指定间隔时间自动生成文件的处理器
# 实例化TimedRotatingFileHandler
# interval是时间间隔,backupCount是备份文件的个数,如果超过这个个数,就会自动删除,when是间隔的时间单位,单位有以下几种:
# S 秒\M 分\H 小时\D 天\W 每星期(interval==0时代表星期一)\midnight 每天凌晨
th.setFormatter(format_str) # 设置文件里写入的格式
self.logger.addHandler(sh) # 把对象加到logger里
self.logger.addHandler(th)
def create_file():
filepath = os.path.realpath(__file__)
output_dir = os.path.abspath(os.path.join(filepath, "../../logs"))
if not os.path.exists(output_dir):
os.makedirs(output_dir)
log_name = '{}.log'.format(time.strftime('%Y-%m-%d'))
filename = os.path.join(output_dir, log_name)
return filename
filename = create_file()
log = Log(filename, level='debug')
logger = log.logger
*主要逻辑处理介绍:
1、引用的话,直接logger.info('输入打印内容')或者其他等级的即可。简单实用,可收藏为敬;
2、此日志引用的是logging库,百度一下一大推;能力有限,写不出那么强大的。是直接复制过来改一改,达到我想要的效果。
**备注:其他测试接口页面逻辑流、自定义接口逻辑流、简易封装、及琐碎配置就不再单独列举了;内容太多,估计看都能看烦.
六、结束收尾语:
- 1、这个小项目,写写停停倒腾了一个月也总算勉强的完成了;目前准备在实际测试项目上应用,将能满足一些测试场景的需求以及提高测试效率;
- 2、因还有很多逻辑以及细节就不再单独拿出来说明了,具体都有对应的注释,可自行了解查看;
- 3、目前构思或者代码中应该还有很多优化,会在下个版本进行优化;
- 4、下次版本新增功能点:
--- 1)、接口支持表单形式;
--- 2)、接口请求类型支持:PUT、PATCH、DELETE;
--- 3)、接口测试页面依据postman参考重新设计页面和功能;
--- 4)、接口不满足规则时,返回的错误信息支持自定义结构
mock系统使用说明pdf链接: https://pan.baidu.com/s/1JV8O66WLJ4q8Oh8UdsC-_A?pwd=8888