订单导出的预发和线上的自动化对比工具
问题与背景
订单导出需要将交易数据通过报表的形式导出并提供下载給商家,供商家发货、对账等。由于交易的场景非常多,承接多个业务(微商城、零售单店、零售连锁版、餐饮),订单类型很多,新老报表的字段覆盖交易、支付、会员、优惠、发货、退款、特定业务等,合计超过100个。每次代码变更(尤其是比较大的改动),如果想要手工验证指定时间段内的绝大多数场景下绝大多数订单类型的所有字段都没有问题,在前端页面点击下载报表,然后手工对比,将是非常大的工作量。因此,迫切需要一个自动化的对比工具,对比变更分支与线上分支的导出报表,找出和分析差异,修复问题。
为什么选择要在预发而不在QA进行呢? 因为订单导出的准确性不仅包含导出和下载功能(20%),更重要的是数据的准确性(80%)。而QA的数据不一定准确,且涵盖面不广,不准确的数据会导致错误的对比结果,对变更的影响造成很大的干扰,延误时间。 因此,这里直接选择用线上的数据来做对比,有时也会意外发现线上数据的一点问题。
整体思路
先做出一个假定:如果master分支的线上逻辑是没有问题的,那么预发的branch分支导出的结果,应该跟线上保持一致; 如果线上的逻辑有问题,那么预发 branch 分支导出的结果,应该有部分跟线上不一致,且不一致的地方根据推断应该仅跟改动部分有关。 分两种情况:
- 系统代码优化与重构:逻辑没有改动,那么预发和线上的导出结果应该完全一致。如果有不一致的情况发生,那么需要分析不一致的原因,决定是否可以接受和取舍。
- 业务逻辑优化:比如在某个场景下,“订单类型”字段原来输出“分销买家订单”,现在需要输出“分销买家订单/拼团订单”,那么导出结果的不一致应该限于“订单类型”。当然,如果有其他报表字段的输出也依赖于“订单类型”字段,那么可能其他字段也会不一致,这时候需要进一步分析。
整体思路如下:
- 使用 Python 来完成该任务,因为 Python 非常简洁实用 ,适合做质量要求不是非常高的接口测试工具;
- 分别往预发和线上发送相同的请求,然后通过导出ID拿到预发请求的文件和线上请求的文件,然后读取并逐字段对比,打印出差异;
- 将对比结果保存在 /tmp/cmp_export.txt , 发送邮件保存。
- 不同店铺的不同业务配置的导出测试用例通过一个单独的配置文件来给出,测试用例配置与请求测试功能分离。
这里使用了闭包的技术来配置化地构造大量测试用例。参阅:Python使用闭包结合配置自动生成函数。
抽离通用部分
为了做一个尽可能通用一点的工具框架,需要将通用部分尽可能抽离出来。
从流程上看: 构造请求 - 发送请求 - 获取结果 - 比较结果并输出 。其中,具体请求会有所不同,获取结果的方式会有所不同,比较结果的方法可能不同。需要自定义请求构造函数、获取结果函数、比较函数。 不过从中也可以抽离通用部分。
- 可以根据基本请求自动生成批量请求;
- 发送 http rest 请求获取结果是通用的;
- 逐行对比字段是通用的。
这里拆分成五个文件:
- conf.py :工具的配置部分
- common.py :包含可复用的基础功能函数,基本不用动;
- cases.py :是测试用例构造部分;
- export.py :是根据具体业务的定制化部分,需要实现 getFromService, compare 函数。
- test.py :是测试入口,基本不用动。
源代码
test.py : 主测试程序。 只要运行 python test.py 即可。然后看看是否有 diff 。如果没有 diff ,那就说明预发和线上导出结果一致; 如果有 diff ,就需要仔细分析 diff ,找出原因并解决。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#-------------------------------------------------------------------------------
# Name: test.py
# Purpose: test if result from pre is consistent with result from production
# USAGE: python test.py
# When: before deploy to production
# STEP1: login in pre machine and vim test.py, cases.py in your directory ,
# enter :set paste , copy this script and save ;
# STEP2: run test.py
#
# Author: qin.shuq
#
# Created: 12/22/2017
# Copyright: (c) qin.shuq 2017
# Licence: <your licence>
#-------------------------------------------------------------------------------
from cases import *
from export import *
import sys
import codecs
import locale
sys.stdout = codecs.getwriter(locale.getpreferredencoding())(sys.stdout)
def getResult(req, env):
serviceUrl = serviceUrlMap[env]
return getFromService(serviceUrl, req, env)
if __name__ == '__main__':
savedStdout = sys.stdout
mkdir(filedir)
f_result = open(resultfile, 'w')
sys.stdout = f_result
allreqs = []
for reqBuilder in caseGenerateFuncs:
allreqs.extend(reqBuilder(startTimeParam, endTimeParam))
for req in allreqs:
resultPre = getResult(req, 'pre')
resultProd = getResult(req, 'prod')
extra = {} # for customized
compare(resultProd, resultPre, req, extra)
print '\n'
print 'success done !'
sendmail('cmp result', resultfile , senderEmail, receiverEmail, smtpServer, smtpPort, loginUser, loginPassword)
f_result.close()
sys.stdout = savedStdout
common.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import requests
import os
import json
import time
import math
import urllib2
import traceback
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
def mkdir(filedir):
isExists=os.path.exists(filedir)
if not isExists:
os.makedirs(filedir)
return True
else:
return False
def divideNParts(total, N):
'''
divide [0, total) into N parts:
return [(0, total/N), (total/N, 2*total/N), ((N-1)*total/N, total)]
'''
each = total / N
parts = []
for index in range(N):
begin = index*each
if index == N-1:
end = total
else:
end = begin + each
parts.append((begin, end))
return parts
def sendRequest(url, query):
try:
r = requests.post(url, data=query, headers={"Content-type":"application/json"})
return r.json()
except:
print '%s' % traceback.format_exc()
return {}
def getData(url, query):
try:
resp = sendRequest(url, query)
if resp['result'] and resp['data']['success']:
return resp['data']['data']
return None
except:
print '%s' % traceback.format_exc()
return None
def download(url, filename):
f = urllib2.urlopen(url)
data = f.read()
with open(filename, "w") as csvFile:
csvFile.write(data)
return filename
def getFileLines(filename):
with open(filename, 'r') as f:
lines = f.readlines()
return (filename,lines)
def cmplines(prodLines, preLines, fields, keyIndex=0):
print 'length: online=%d, pre=%d' % (len(prodLines), len(preLines))
try:
for i in range(len(prodLines)):
online = prodLines[i].strip().split(',')
preline = preLines[i].strip().split(',')
for t in range(len(online)):
try:
if online[t] != preline[t]:
print 'diff: field=%s, online=%s, pre=%s, keyValue=%s' % (fields[t], online[t].decode('gb18030'), preline[t].decode('gb18030'), online[keyIndex])
except:
print 'compare failed. field=%s keyValue=%s %s' % (fields[t], online[keyIndex], traceback.format_exc())
print 'passed.'
except:
print 'compare failed. %s' % traceback.format_exc()
def getSortedFile(originFile, index):
filename = originFile.rsplit('.',1)[0]
sortedfilename = filename + "_sorted.csv"
cmd = 'sort -k %d %s > %s' % (index+1, originFile, sortedfilename)
os.system(cmd)
return sortedfilename
def sendmail(text, resultfile='', senderEmail='', receiverEmail='', smtpServer='', smtpPort='', loginUser='', loginPassword=''):
sender = senderEmail
receivers = receiverEmail
message = MIMEMultipart()
message['From'] = Header("对比工具", 'utf-8')
message['To'] = Header("对比工具", 'utf-8')
subject = '对比结果'
message['Subject'] = Header(subject, 'utf-8')
message.attach(MIMEText('对比结果如附件所示', 'plain', 'utf-8'))
att1 = MIMEText(open(resultfile, 'rb').read(), 'base64', 'utf-8')
att1["Content-Type"] = 'application/octet-stream'
att1["Content-Disposition"] = 'attachment; filename="export_cmp_result.txt"'
message.attach(att1)
try:
smtpObj = smtplib.SMTP(smtpServer, smtpPort)
smtpObj.login(loginUser, loginPassword)
smtpObj.sendmail(sender, receivers, message.as_string())
print "Email Send success!"
except smtplib.SMTPException:
print "Email Send failed!"
cases.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#-------------------------------------------------------------------------------
# Name: cases.py
# Purpose: Provides cases of exports
#
# Author: qin.shuq
#
# Created: 12/22/2017
# Copyright: (c) qin.shuq 2017
# Licence: <your licence>
#-------------------------------------------------------------------------------
import time
import math
import json
from common import divideNParts
from conf import *
def buildReq(baseReqTemplate, startTime, endTime, bizId=, templateId=1, field=None, value=None):
requestId = str(startTime) + "_" + str(endTime) + "_" + str(bizId) + "_" + str(templateId)
baseReq = json.loads(baseReqTemplate)
# your request fields
baseReq['request_id'] = requestId
return baseReq
def commonGenerateReqByTime(startTime, endTime, bizId=63077, templateId=1):
def generateReqByTimeInner(startTime, endTime):
totalInterval = endTime-startTime
timeparts = divideNParts(totalInterval, parts)
timeparts = map(lambda t: (t[0]+startTime, t[1]+startTime), timeparts)
reqs = []
for timeRange in timeparts:
baseReq = buildReq(baseReqStr, timeRange[0], timeRange[1], bizId, templateId)
reqs.append(json.dumps(baseReq))
return reqs
return generateReqByTimeInner
def commonGenerator(startTime, endTime, bizId=63077, templateId=1, field='', values=[]):
def generateReqInner(startTime, endTime):
reqs = []
for val in values:
baseReq = buildReq(baseReqStr, startTime, endTime, bizId, templateId, field, val)
reqs.append(json.dumps(baseReq))
return reqs
return generateReqInner
def generateGenerators(startTime, endTime, configs):
gvars = globals()
for (templateId,bizId) in bizIdTemplateIdMap.iteritems():
if len(configs) == 0:
funcName = 'generateReqByTime_' + str(bizId) + "_" + str(templateId)
gvars[funcName] = commonGenerateReqByTime(startTime, endTime, bizId, templateId)
else:
for (field, values) in configs.iteritems():
funcName = 'generateReqBy_' + str(bizId) + "_" + str(templateId) + "_" + field
gvars[funcName] = commonGenerator(startTime, endTime, bizId, templateId, field, values)
def getGenerateFuncs():
gvars = globals()
caseGenerators = [ gvars[var] for var in gvars if var.startswith('generateReq') ]
print 'case generators: ', [ var for var in gvars if var.startswith('generateReq') ]
return caseGenerators
generateGenerators(startTime, endTime, detailconfigs)
caseGenerateFuncs = getGenerateFuncs()
export.py 定制化部分
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from conf import *
from common import *
def getExportId(url, query):
exportId = getData(url,query)
if not exportId:
return 0
return int(exportId)
def getExportedFile(url, query, env):
exportId = getExportId(url, query)
print 'exportId: ', exportId
return getByExportId(exportId, env)
def getByExportId(exportId, env):
# get your fileUrl
fileUrl =
filename = filedir + env + "_" + str(exportId) + ".csv"
csvFile = download(fileUrl, filename)
return csvFile
def cmpExportFile(preFile, prodFile, exportReq, templateId=0):
fields = templateIdFieldsMap[templateId]
keyIndex = 0
preSortedFile = getSortedFile(preFile, keyIndex)
prodSortedFile = getSortedFile(prodFile, keyIndex)
preSorted = getFileLines(preSortedFile)[1]
prodSorted = getFileLines(prodSortedFile)[1]
print 'exportReq=[ %s ], prodFile=%s, preFile=%s' % (exportReq, prodSortedFile, preSortedFile)
cmplines(prodSorted, preSorted, fields, keyIndex)
# define your getFromService compare
def getFromService(serviceUrl, query, env):
return getExportedFile(serviceUrl, query, env)
def compare(resultProd, resultPre, req, extra):
templateId = json.loads(req)['template_id']
cmpExportFile(resultPre, resultProd, req, templateId)
conf.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#-------------------------------------------------------------------------------
# Name: conf.py
# Purpose: Provides confs for cases and biz
#
# Author: qin.shuq
#
#-------------------------------------------------------------------------------
import math
import time
### time range for comparing ###
startTimeParam = 1527782400
endTimeParam = 1530374400
### serivceHttpUrl ###
preUrl =
prodUrl =
queryUrl =
serviceUrlMap = {'pre': preUrl, 'prod': prodUrl}
filedir = './files/'
resultfile = '/tmp/export_cmp.txt'
### email ###
senderEmail = ''
receiverEmail = ['']
smtpServer = 'smtp.exmail.qq.com'
smtpPort = 25
loginUser = ''
loginPassword = ''
bizId =
parts = 2
endTime = math.floor(time.time()) - 300
startTime = endTime - 600
baseExportReqStr =
bizIdTemplateIdMap = {}
#如果改动了搜索相关,则需要测试订单搜索,使用该配置
searchconfigs = {}
# 如果只改动了详情,不需要测试订单搜索,只需要按照时间段来导出预发线上数据进行比较即可。
detailconfigs = {}
def extractFields(fieldsStr):
return map(lambda x: x.strip(), fieldsStr.split(','))
templateIdFieldsMap = {}
小结
无论大改还是小改,通过运行这个预发和线上对比工具,很大程度上增强了成功发布的信心。可见,预发和线上的自动化对比工具,确实是发布前的最后一道防线。