背景
引用官方的说明
软件测试过程中,最重要、最核心就是测试用例的设计,也是测试童鞋、测试团队日常投入最多时间的工作内容之一。
然而,传统的测试用例设计过程有很多痛点:
使用Excel表格进行测试用例设计,虽然成本低,但版本管理麻烦,维护更新耗时,用例评审繁琐,过程报表统计难…
使用TestLink、TestCenter、Redmine等传统测试管理工具,虽然测试用例的执行、管理、统计比较方便,但依然存在编写用例效率不高、思路不够发散、在产品快速迭代过程中比较耗时等问题…
公司自研测试管理工具,这是个不错的选择,但对于大部分小公司、小团队来说,一方面研发维护成本高,另一方面对技术要有一定要求…
…
基于这些情况,现在越来越多公司选择使用思维导图这种高效的生产力工具进行用例设计,特别是敏捷开发团队。
事实上也证明,思维导图其发散性思维、图形化思维的特点,跟测试用例设计时所需的思维非常吻合,所以在实际工作中极大提升了我们测试用例设计的效率,也非常方便测试用例评审。
但是与此同时,使用思维导图进行测试用例设计的过程中也带来不少问题:
测试用例难以量化管理、执行情况难以统计;
测试用例执行结果与BUG管理系统难以打通;
团队成员用思维导图设计用例的风格各异,沟通成本巨大;
…
于是,这时候 XMind2TestCase 就应运而生了,该工具基于 Python 实现,通过制定测试用例通用模板, 然后使用 XMind 这款广为流传且开源的思维导图工具进行用例设计。 其中制定测试用例通用模板是一个非常核心的步骤(具体请看使用指南),有了通用的测试用例模板,我们就可以在 XMind 文件上解析并提取出测试用例所需的基本信息, 然后合成常见测试用例管理系统所需的用例导入文件。这样就将 XMind 设计测试用例的便利与常见测试用例系统的高效管理结合起来了!
当前 XMind2TestCase 已实现从 XMind 文件到 TestLink 和 Zentao(禅道) 两大常见用例管理系统的测试用例转换,同时也提供 XMind 文件解析后的两种数据接口 (TestSuites、TestCases两种级别的JSON数据),方便快速与其他测试用例管理系统打通。
示例展示
Web转换工具
转换后用例预览
XMind2TestCase安装
pip3 install xmind2testcase
升级
pip3 install -U xmind2testcase
思维导图用例编写规范:
用例示例:
XMind2TestCase生成pingcode测试用例模板
增加Excel支持
pip install openpyxl
修改源文件
Lib\site-packages\webtool\application.py 修改有注释地方
#!/usr/bin/env python
# _*_ coding:utf-8 _*_
import logging
import os
import re
import arrow
import sqlite3
from contextlib import closing
from os.path import join, exists
from werkzeug.utils import secure_filename
from xmind2testcase.zentao import xmind_to_zentao_csv_file
from xmind2testcase.pingcode import xmind_to_pingcode_excel_file ##导入pingcode方法
from xmind2testcase.testlink import xmind_to_testlink_xml_file
from xmind2testcase.utils import get_xmind_testsuites, get_xmind_testcase_list
from flask import Flask, request, send_from_directory, g, render_template, abort, redirect, url_for
here = os.path.abspath(os.path.dirname(__file__))
log_file = os.path.join(here, 'running.log')
# log handler
formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s [%(module)s - %(funcName)s]: %(message)s')
file_handler = logging.FileHandler(log_file, encoding='UTF-8')
file_handler.setFormatter(formatter)
file_handler.setLevel(logging.DEBUG)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(formatter)
stream_handler.setLevel(logging.INFO)
# xmind to testcase logger
root_logger = logging.getLogger()
root_logger.addHandler(file_handler)
root_logger.addHandler(stream_handler)
root_logger.setLevel(logging.DEBUG)
# flask and werkzeug logger
werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.addHandler(file_handler)
werkzeug_logger.addHandler(stream_handler)
werkzeug_logger.setLevel(logging.DEBUG)
# global variable
UPLOAD_FOLDER = os.path.join(here, 'uploads')
ALLOWED_EXTENSIONS = ['xmind']
DEBUG = True
DATABASE = os.path.join(here, 'data.db3')
HOST = '0.0.0.0'
# flask app
app = Flask(__name__)
app.config.from_object(__name__)
app.secret_key = os.urandom(32)
def connect_db():
return sqlite3.connect(app.config['DATABASE'])
def init_db():
with closing(connect_db()) as db:
with app.open_resource('schema.sql', mode='r') as f:
db.cursor().executescript(f.read())
db.commit()
def init():
app.logger.info('Start initializing the database...')
if not exists(UPLOAD_FOLDER):
os.mkdir(UPLOAD_FOLDER)
if not exists(DATABASE):
init_db()
app.logger.info('Congratulations! the xmind2testcase webtool database has initialized successfully!')
@app.before_request
def before_request():
g.db = connect_db()
@app.teardown_request
def teardown_request(exception):
db = getattr(g, 'db', None)
if db is not None:
db.close()
def insert_record(xmind_name, note=''):
c = g.db.cursor()
now = str(arrow.now())
sql = "INSERT INTO records (name,create_on,note) VALUES (?,?,?)"
c.execute(sql, (xmind_name, now, str(note)))
g.db.commit()
def delete_record(filename, record_id):
xmind_file = join(app.config['UPLOAD_FOLDER'], filename)
testlink_file = join(app.config['UPLOAD_FOLDER'], filename[:-5] + 'xml')
zentao_file = join(app.config['UPLOAD_FOLDER'], filename[:-5] + 'csv')
pingcode_file = join(app.config['UPLOAD_FOLDER'], filename[:-5] + 'xlsx') # 此处修改,增加删除excle文件
for f in [xmind_file, testlink_file, zentao_file,pingcode_file]: # 此处修改,增加删除excle文件
if exists(f):
os.remove(f)
c = g.db.cursor()
sql = 'UPDATE records SET is_deleted=1 WHERE id = ?'
c.execute(sql, (record_id,))
g.db.commit()
def delete_records(keep=20):
"""Clean up files on server and mark the record as deleted"""
sql = "SELECT * from records where is_deleted<>1 ORDER BY id desc LIMIT -1 offset {}".format(keep)
assert isinstance(g.db, sqlite3.Connection)
c = g.db.cursor()
c.execute(sql)
rows = c.fetchall()
for row in rows:
name = row[1]
xmind_file = join(app.config['UPLOAD_FOLDER'], name)
testlink_file = join(app.config['UPLOAD_FOLDER'], name[:-5] + 'xml')
zentao_file = join(app.config['UPLOAD_FOLDER'], name[:-5] + 'csv')
pingcode_file = join(app.config['UPLOAD_FOLDER'], name[:-5] + 'xlsx') # 此处修改,增加删除excle文件
for f in [xmind_file, testlink_file, zentao_file,pingcode_file]: # 此处修改,增加删除excle文件
if exists(f):
os.remove(f)
sql = 'UPDATE records SET is_deleted=1 WHERE id = ?'
c.execute(sql, (row[0],))
g.db.commit()
def get_latest_record():
found = list(get_records(1))
if found:
return found[0]
def get_records(limit=8):
short_name_length = 120
c = g.db.cursor()
sql = "select * from records where is_deleted<>1 order by id desc limit {}".format(int(limit))
c.execute(sql)
rows = c.fetchall()
for row in rows:
name, short_name, create_on, note, record_id = row[1], row[1], row[2], row[3], row[0]
# shorten the name for display
if len(name) > short_name_length:
short_name = name[:short_name_length] + '...'
# more readable time format
create_on = arrow.get(create_on).humanize()
yield short_name, name, create_on, note, record_id
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS
def check_file_name(name):
secured = secure_filename(name)
if not secured:
secured = re.sub('[^\w\d]+', '_', name) # only keep letters and digits from file name
assert secured, 'Unable to parse file name: {}!'.format(name)
return secured + '.xmind'
def save_file(file):
if file and allowed_file(file.filename):
# filename = check_file_name(file.filename[:-6])
filename = file.filename
upload_to = join(app.config['UPLOAD_FOLDER'], filename)
if exists(upload_to):
filename = '{}_{}.xmind'.format(filename[:-6], arrow.now().strftime('%Y%m%d_%H%M%S'))
upload_to = join(app.config['UPLOAD_FOLDER'], filename)
file.save(upload_to)
insert_record(filename)
g.is_success = True
return filename
elif file.filename == '':
g.is_success = False
g.error = "Please select a file!"
else:
g.is_success = False
g.invalid_files.append(file.filename)
def verify_uploaded_files(files):
# download the xml directly if only 1 file uploaded
if len(files) == 1 and getattr(g, 'is_success', False):
g.download_xml = get_latest_record()[1]
if g.invalid_files:
g.error = "Invalid file: {}".format(','.join(g.invalid_files))
@app.route('/', methods=['GET', 'POST'])
def index(download_xml=None):
g.invalid_files = []
g.error = None
g.download_xml = download_xml
g.filename = None
if request.method == 'POST':
if 'file' not in request.files:
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return redirect(request.url)
g.filename = save_file(file)
verify_uploaded_files([file])
delete_records()
else:
g.upload_form = True
if g.filename:
return redirect(url_for('preview_file', filename=g.filename))
else:
return render_template('index.html', records=list(get_records()))
@app.route('/uploads/<filename>')
def uploaded_file(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
@app.route('/<filename>/to/testlink')
def download_testlink_file(filename):
full_path = join(app.config['UPLOAD_FOLDER'], filename)
if not exists(full_path):
abort(404)
testlink_xmls_file = xmind_to_testlink_xml_file(full_path)
filename = os.path.basename(testlink_xmls_file) if testlink_xmls_file else abort(404)
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
@app.route('/<filename>/to/zentao')
def download_zentao_file(filename):
full_path = join(app.config['UPLOAD_FOLDER'], filename)
if not exists(full_path):
abort(404)
zentao_csv_file = xmind_to_zentao_csv_file(full_path)
filename = os.path.basename(zentao_csv_file) if zentao_csv_file else abort(404)
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
@app.route('/<filename>/to/pingcode') ###新增pingcode路由
def download_pingcode_file(filename):
full_path = join(app.config['UPLOAD_FOLDER'], filename)
if not exists(full_path):
abort(404)
pingcode_xlsx_file = xmind_to_pingcode_excel_file(full_path)
filename = os.path.basename(pingcode_xlsx_file) if pingcode_xlsx_file else abort(404)
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)
@app.route('/preview/<filename>')
def preview_file(filename):
full_path = join(app.config['UPLOAD_FOLDER'], filename)
if not exists(full_path):
abort(404)
testsuites = get_xmind_testsuites(full_path)
suite_count = 0
for suite in testsuites:
suite_count += len(suite.sub_suites)
testcases = get_xmind_testcase_list(full_path)
return render_template('preview.html', name=filename, suite=testcases, suite_count=suite_count)
@app.route('/delete/<filename>/<int:record_id>')
def delete_file(filename, record_id):
full_path = join(app.config['UPLOAD_FOLDER'], filename)
if not exists(full_path):
abort(404)
else:
delete_record(filename, record_id)
return redirect('/')
@app.errorhandler(Exception)
def app_error(e):
return str(e)
def launch(host=HOST, debug=True, port=5001):
init() # initializing the database
app.run(host=host, debug=debug, port=port)
if __name__ == '__main__':
init() # initializing the database
app.run(HOST, debug=DEBUG, port=5001)
Lib\site-packages\xmind2testcase\parser.py 修改有注释地方
def get_execution_type(topics): ##标注内容,原先用来定义用例类型,现改为备注内容
labels = [topic.get('label', '') for topic in topics]
# print(labels)
if None in labels:
return ''
else:
return ' '.join(labels)
# labels = filter_empty_or_ignore_element(labels)
# exe_type = 1
# for item in labels[::-1]:
# if item.lower() in ['自动', 'auto', 'automate', 'automation']:
# exe_type = 2
# break
# if item.lower() in ['手动', '手工', 'manual']:
# exe_type = 1
# break
# return exe_type
\Lib\site-packages\xmind2testcase 下新增pingcode.py文件定义xlsx格式方法
#!/usr/bin/env python
# _*_ coding:utf-8 _*_
import csv
import logging
import os
from xmind2testcase.utils import get_xmind_testcase_list, get_absolute_path
import openpyxl
from openpyxl.styles import Font, Color
"""
Convert XMind fie to pingcode testcase csv file
pingcode official document about import CSV testcase file: https://www.pingcode.net/book/pingcodepmshelp/243.mhtml
"""
def xmind_to_pingcode_excel_file(xmind_file):
"""
写入excle文件xlsx
"""
xmind_file = get_absolute_path(xmind_file)
logging.info('Start converting XMind file(%s) to pingcode file...', xmind_file)
testcases = get_xmind_testcase_list(xmind_file)
fileheader = ["模块", "编号", "*标题", "维护人", "用例类型", "重要程度", "测试类型", "预估工时", "关联工作项",
"前置条件", "步骤描述", "预期结果", "备注"]
pingcode_testcase_rows = [fileheader]
for testcase in testcases:
row = gen_a_testcase_row(testcase)
pingcode_testcase_rows.append(row)
pingcode_file = xmind_file[:-6] + ".xlsx"
if os.path.exists(pingcode_file):
os.remove(pingcode_file)
logging.info('The pingcode csv file already exists, return it directly: %s', pingcode_file)
workbook = openpyxl.workbook.Workbook()
ws = workbook.active
ws.merge_cells('A1:P1') #合并单元格
tips='''
请按照下面的规则填写上传数据:\n
1.模块:填写用例库下已有的模块名称,请从第一级模块开始完整填写(所有用例不属于模块),层级之间用“/”间隔,例如:一级模块/二级模块/三级模块,最多五级,不填写自动归入至‘无模块用例’中。\n
2.编号: 编号样式为:测试库标识-XX,XX代表数字,如“ QLD-3 ”;用例管理下存在该编号时覆盖用例,不存在该编号或不填写编号时新建用例。\n
3.标题:必填项,不可为空。\n
4.维护人:填写团队成员的姓名或用户名,若团队中有重名的成员默认随机选择其中一位成员。\n
5.用例类型:可选值:功能测试、性能测试、配置相关、安装部署、接口测试、安全相关、兼容性测试、UI测试、其他。\n
6.重要程度:可选值:P0、P1、P2、P3、P4。\n
7.测试类型:可选值:手动、自动。\n
8.预估工时:数值。\n
9.关联工作项:填写关联的需求编号,填写多个值时,请用"|"隔开。\n
10.前置条件:选填。\n
11.步骤描述:文本,步骤请加编号填写,如1.xxx、2.xxx;分组填写,子步骤前加“→”,如1.xxx、→1.xxx;每个分组或步骤单元格内换行。\n
12.预期结果:文本,保持编号与步骤对应,如1.xxx、2.xxx;分组的预期结果不用填写,子预期前加“→”,如1. 空、→1.xxx,每个预期结果单元格内换行。\n
13.关注人:填写团队成员的姓名或用户名,若团队中有重名的成员默认随机选择其中一位成员,填写多个值时,请用"|"隔开。\n
14.备注:选填。\n
15.自定义属性使用系统中创建的属性名,非必填。\n
Tips:\n
1.单次导入最多支持5000条。\n
2.“标题”为必填项,必填字段为空时,不予以导入。\n
'''
cell=ws.cell(row=1, column=1)
cell.value = tips
cell.font=Font(color=Color(rgb="348FE4")) #设置字体颜色
# workbook['Sheet'] .row_dimensions[cell.row].height=350 ###设置单元格高度
line = 2 #pingcode导入从第二行开始读
for data in pingcode_testcase_rows:
for col in range(1, len(data) + 1):
ws.cell(row=line, column=col).value = data[col - 1]
line += 1
workbook.save(pingcode_file)
workbook.close()
logging.info('Convert XMind file(%s) to a pingcode csv file(%s) successfully!', xmind_file, pingcode_file)
return pingcode_file
def gen_a_testcase_row(testcase_dict):
case_module = gen_case_module(testcase_dict['suite'])
case_title = testcase_dict['name']
case_precontion = testcase_dict['preconditions']
case_step, case_expected_result = gen_case_step_and_expected_result(testcase_dict['steps'])
# case_keyword = ''
case_priority = gen_case_priority(testcase_dict['importance'])
case_type = gen_case_type(testcase_dict['execution_type']) ##备注
case_apply_phase = '功能测试'
# row = [case_module, case_title, case_precontion, case_step, case_expected_result, case_keyword, case_priority, case_type, case_apply_phase]
row = [case_module, "", case_title, "", case_apply_phase, case_priority, '手动', "", "",
case_precontion, case_step, case_expected_result, case_type]
return row
def gen_case_module(module_name):
if module_name:
module_name = module_name.replace('(', '(')
module_name = module_name.replace(')', ')')
else:
module_name = '/'
return module_name
def gen_case_step_and_expected_result(steps):
case_step = ''
case_expected_result = ''
for step_dict in steps:
case_step += str(step_dict['step_number']) + '. ' + step_dict['actions'].replace('\n', '').strip() + '\n'
case_expected_result += str(step_dict['step_number']) + '. ' + \
step_dict['expectedresults'].replace('\n', '').strip() + '\n' \
if step_dict.get('expectedresults', '') else ''
return case_step, case_expected_result
def gen_case_priority(priority):
mapping = {1: 'P0', 2: 'P1', 3: 'P2', 4: 'P3', 5: 'P4'}
if priority in mapping.keys():
return mapping[priority]
else:
return '中'
def gen_case_type(case_type):
return case_type
# mapping = {1: '手动', 2: '自动'}
# if case_type in mapping.keys():
# return mapping[case_type]
# else:
# return '手动'
if __name__ == '__main__':
# xmind_file = '../docs/pingcode_testcase_template.xmind'
xmind_file = r'C:\Users\NINGMEI\Downloads\xmind2testcase-master\docs\pingcode_testcase_template.xmind'
pingcode_csv_file = xmind_to_pingcode_excel_file(xmind_file)
print('Conver the xmind file to a pingcode csv file succssfully: %s', pingcode_csv_file)
修改index.html
<td><a href="{{ url_for('uploaded_file',filename=record[1]) }}">XMIND</a> |
<a href="{{ url_for('download_zentao_file',filename=record[1]) }}">CSV</a> |
<a href="{{ url_for('download_testlink_file',filename=record[1]) }}">XML</a> |
<a href="{{ url_for('download_pingcode_file', filename=record[1]) }}">EXCEL</a> |{# 此处修改!!!#}
<a href="{{ url_for('preview_file',filename=record[1]) }}">PREVIEW</a> |
<a href="{{ url_for('delete_file',filename=record[1], record_id=record[4]) }}">DELETE</a>
修改preview.html
<h2>TestSuites: {{ suite_count }} / TestCases: {{ suite | length }}
/ <a href="{{ url_for("download_zentao_file",filename= name) }}">Get Zentao CSV</a>
/ <a href="{{ url_for("download_testlink_file",filename= name) }}">Get TestLink XML</a>
/ <a href="{{ url_for("download_pingcode_file",filename= name) }}">Get PingCode XLSX</a> {# 此处修改!!!#}
/ <a href="{{ url_for("index") }}">Go Back</a></h2>
运行
python application.py
命令行:
xmind2testcase webtool 5001(port)