Add update testrail fields functionality for testrail reporter
and small refactoring of description parsing
Add update_case() method to testrail client
Pull parsing custom cases fields into _get_custom_cases_fields() method
Add _get_fields_to_update() method to test cases uploader
Change log message for up-to-date cases
Add new file: datetime_util.py
Add method that converts duration to testrail estimate format
Register new file in the doc/testrail.rst
Change duration regexp and pull it into compiled instance
Pull tests discovering and test plan creation into
_create_test_plan_from_registry() function
Pull various case checks into _is_case_processable() function
Pull test case name getter actions into _get_test_case_name() function
Pull case 'not included' verification into _is_not_included() function
Pull case 'excluded' verification into _is_excluded() function
Pull docstring getter actions into _get_docstring() function
Pull docstring parsing actions into _parse_docstring() function
Add support for multiline title and test case steps
Change-Id: I2245b939e91bab9d4f48b072e89d86163a0dd6b0
Closes-Bug: #1558008
(cherry picked from commit 4ac47dad9b
)
This commit is contained in:
parent
fd21d14664
commit
21584924af
|
@ -57,3 +57,8 @@ Generate bugs statistics for TestPlan
|
|||
-------------------------------------
|
||||
.. automodule:: fuelweb_test.testrail.generate_statistics
|
||||
:members:
|
||||
|
||||
Datetime utils for Testrail
|
||||
---------------------------
|
||||
.. automodule:: fuelweb_test.testrail.datetime_util
|
||||
:members:
|
|
@ -0,0 +1,36 @@
|
|||
# Copyright 2016 Mirantis, Inc.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from __future__ import division
|
||||
|
||||
|
||||
MINUTE = 60
|
||||
HOUR = MINUTE ** 2
|
||||
DAY = HOUR * 8
|
||||
WEEK = DAY * 5
|
||||
|
||||
|
||||
def duration_to_testrail_estimate(duration):
|
||||
"""Converts duration in minutes to testrail estimate format
|
||||
"""
|
||||
seconds = duration * MINUTE
|
||||
week = seconds // WEEK
|
||||
days = seconds % WEEK // DAY
|
||||
hours = seconds % DAY // HOUR
|
||||
minutes = seconds % HOUR // MINUTE
|
||||
estimate = ''
|
||||
for val, char in ((week, 'w'), (days, 'd'), (hours, 'h'), (minutes, 'm')):
|
||||
if val:
|
||||
estimate = ' '.join([estimate, '{0}{1}'.format(val, char)])
|
||||
return estimate.lstrip()
|
|
@ -173,6 +173,9 @@ class TestRailProject(object):
|
|||
add_case_uri = 'add_case/{section_id}'.format(section_id=section_id)
|
||||
return self.client.send_post(add_case_uri, case)
|
||||
|
||||
def update_case(self, case_id, fields):
|
||||
return self.client.send_post('update_case/{0}'.format(case_id), fields)
|
||||
|
||||
def delete_case(self, case_id):
|
||||
return self.client.send_post('delete_case/' + str(case_id), None)
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import string
|
||||
|
||||
from logging import DEBUG
|
||||
from optparse import OptionParser
|
||||
|
@ -26,6 +27,7 @@ from fuelweb_test.testrail.settings import GROUPS_TO_EXPAND
|
|||
from fuelweb_test.testrail.settings import logger
|
||||
from fuelweb_test.testrail.settings import TestRailSettings
|
||||
from fuelweb_test.testrail.testrail_client import TestRailProject
|
||||
from fuelweb_test.testrail import datetime_util
|
||||
from system_test import define_custom_groups
|
||||
from system_test import discover_import_tests
|
||||
from system_test import register_system_test_cases
|
||||
|
@ -34,13 +36,15 @@ from system_test import get_basepath
|
|||
from system_test.tests.base import ActionTest
|
||||
|
||||
|
||||
GROUP_FIELD = 'custom_test_group'
|
||||
|
||||
STEP_NUM_PATTERN = re.compile(r'^(\d{1,3})[.].+')
|
||||
DURATION_PATTERN = re.compile(r'Duration:?\s+(\d+(?:[sm]|\s?m))(?:in)?\b')
|
||||
|
||||
|
||||
def get_tests_descriptions(milestone_id, tests_include, tests_exclude, groups,
|
||||
default_test_priority):
|
||||
discover_import_tests(get_basepath(), tests_directory)
|
||||
define_custom_groups()
|
||||
for one in groups:
|
||||
register_system_test_cases(one)
|
||||
plan = TestPlan.create_from_registry(DEFAULT_REGISTRY)
|
||||
plan = _create_test_plan_from_registry(groups=groups)
|
||||
all_plan_tests = plan.tests[:]
|
||||
|
||||
tests = []
|
||||
|
@ -49,53 +53,20 @@ def get_tests_descriptions(milestone_id, tests_include, tests_exclude, groups,
|
|||
group = groups[jenkins_suffix]
|
||||
plan.filter(group_names=[group])
|
||||
for case in plan.tests:
|
||||
if not case.entry.info.enabled:
|
||||
if not _is_case_processable(case=case, tests=tests):
|
||||
continue
|
||||
home = case.entry.home
|
||||
if not hasattr(case.entry, 'parent'):
|
||||
# Not a real case, some stuff needed by template based tests
|
||||
|
||||
case_name = test_group = _get_test_case_name(case)
|
||||
|
||||
if _is_not_included(case_name, tests_include) or \
|
||||
_is_excluded(case_name, tests_exclude):
|
||||
continue
|
||||
parent_home = case.entry.parent.home
|
||||
case_state = case.state
|
||||
if issubclass(parent_home, ActionTest):
|
||||
case_name = parent_home.__name__
|
||||
test_group = parent_home.__name__
|
||||
if any([x['custom_test_group'] == test_group for x in tests]):
|
||||
continue
|
||||
else:
|
||||
case_name = home.func_name
|
||||
test_group = case.entry.home.func_name
|
||||
if tests_include:
|
||||
if tests_include not in case_name:
|
||||
logger.debug("Skipping '{0}' test because it doesn't "
|
||||
"contain '{1}' in method name"
|
||||
.format(case_name,
|
||||
tests_include))
|
||||
continue
|
||||
if tests_exclude:
|
||||
if tests_exclude in case_name:
|
||||
logger.debug("Skipping '{0}' test because it contains"
|
||||
" '{1}' in method name"
|
||||
.format(case_name,
|
||||
tests_exclude))
|
||||
continue
|
||||
|
||||
if issubclass(parent_home, ActionTest):
|
||||
docstring = parent_home.__doc__.split('\n')
|
||||
case_state.instance._load_config()
|
||||
configuration = case_state.instance.config_name
|
||||
docstring[0] = "{0} on {1}".format(docstring[0], configuration)
|
||||
docstring = '\n'.join(docstring)
|
||||
else:
|
||||
docstring = home.func_doc or ''
|
||||
configuration = None
|
||||
docstring = '\n'.join([s.strip() for s in docstring.split('\n')])
|
||||
docstring = _get_docstring(parent_home=case.entry.parent.home,
|
||||
case_state=case.state,
|
||||
home=case.entry.home)
|
||||
|
||||
steps = [{"content": s, "expected": "pass"} for s in
|
||||
docstring.split('\n') if s and s[0].isdigit()]
|
||||
|
||||
test_duration = re.search(r'Duration\s+(\d+[s,m])\b', docstring)
|
||||
title = docstring.split('\n')[0] or case.entry.home.func_name
|
||||
title, steps, duration = _parse_docstring(docstring, case)
|
||||
|
||||
if case.entry.home.func_name in GROUPS_TO_EXPAND:
|
||||
"""Expand specified test names with the group names that are
|
||||
|
@ -110,14 +81,14 @@ def get_tests_descriptions(milestone_id, tests_include, tests_exclude, groups,
|
|||
"type_id": 1,
|
||||
"milestone_id": milestone_id,
|
||||
"priority_id": default_test_priority,
|
||||
"estimate": test_duration.group(1) if test_duration else "3m",
|
||||
"estimate": duration,
|
||||
"refs": "",
|
||||
"custom_test_group": test_group,
|
||||
"custom_test_case_description": docstring or " ",
|
||||
"custom_test_case_steps": steps
|
||||
}
|
||||
|
||||
if not any([x['custom_test_group'] == test_group for x in tests]):
|
||||
if not any([x[GROUP_FIELD] == test_group for x in tests]):
|
||||
tests.append(test_case)
|
||||
else:
|
||||
logger.warning("Testcase '{0}' run in multiple Jenkins jobs!"
|
||||
|
@ -133,44 +104,49 @@ def upload_tests_descriptions(testrail_project, section_id,
|
|||
tests_suite = testrail_project.get_suite_by_name(
|
||||
TestRailSettings.tests_suite)
|
||||
check_section = None if check_all_sections else section_id
|
||||
existing_cases = [case['custom_test_group'] for case in
|
||||
testrail_project.get_cases(suite_id=tests_suite['id'],
|
||||
section_id=check_section)]
|
||||
custom_cases_fields = {}
|
||||
for field in testrail_project.get_case_fields():
|
||||
for config in field['configs']:
|
||||
if ((testrail_project.project['id'] in
|
||||
config['context']['project_ids'] or
|
||||
not config['context']['project_ids']) and
|
||||
config['options']['is_required']):
|
||||
try:
|
||||
custom_cases_fields[field['system_name']] = \
|
||||
int(config['options']['items'].split(',')[0])
|
||||
except:
|
||||
logger.error("Couldn't find default value for required "
|
||||
"field '{0}', setting '1' (index)!".format(
|
||||
field['system_name']))
|
||||
custom_cases_fields[field['system_name']] = 1
|
||||
cases = testrail_project.get_cases(suite_id=tests_suite['id'],
|
||||
section_id=check_section)
|
||||
existing_cases = [case[GROUP_FIELD] for case in cases]
|
||||
custom_cases_fields = _get_custom_cases_fields(
|
||||
case_fields=testrail_project.get_case_fields(),
|
||||
project_id=testrail_project.project['id'])
|
||||
|
||||
for test_case in tests:
|
||||
if test_case['custom_test_group'] in existing_cases:
|
||||
logger.debug('Skipping uploading "{0}" test case because it '
|
||||
'already exists in "{1}" tests section.'.format(
|
||||
test_case['custom_test_group'],
|
||||
TestRailSettings.tests_suite))
|
||||
continue
|
||||
if test_case[GROUP_FIELD] in existing_cases:
|
||||
testrail_case = _get_testrail_case(testrail_cases=cases,
|
||||
test_case=test_case,
|
||||
group_field=GROUP_FIELD)
|
||||
fields_to_update = _get_fields_to_update(test_case, testrail_case)
|
||||
|
||||
for case_field, default_value in custom_cases_fields.items():
|
||||
if case_field not in test_case:
|
||||
test_case[case_field] = default_value
|
||||
if fields_to_update:
|
||||
logger.debug('Updating test "{0}" in TestRail project "{1}", '
|
||||
'suite "{2}", section "{3}". Updated fields: {4}'
|
||||
.format(
|
||||
test_case[GROUP_FIELD],
|
||||
TestRailSettings.project,
|
||||
TestRailSettings.tests_suite,
|
||||
TestRailSettings.tests_section,
|
||||
', '.join(fields_to_update.keys())))
|
||||
testrail_project.update_case(case_id=testrail_case['id'],
|
||||
fields=fields_to_update)
|
||||
else:
|
||||
logger.debug('Skipping "{0}" test case uploading because '
|
||||
'it is up-to-date in "{1}" suite'
|
||||
.format(test_case[GROUP_FIELD],
|
||||
TestRailSettings.tests_suite))
|
||||
|
||||
logger.debug('Uploading test "{0}" to TestRail project "{1}", '
|
||||
'suite "{2}", section "{3}"'.format(
|
||||
test_case["custom_test_group"],
|
||||
TestRailSettings.project,
|
||||
TestRailSettings.tests_suite,
|
||||
TestRailSettings.tests_section))
|
||||
testrail_project.add_case(section_id=section_id, case=test_case)
|
||||
else:
|
||||
for case_field, default_value in custom_cases_fields.items():
|
||||
if case_field not in test_case:
|
||||
test_case[case_field] = default_value
|
||||
|
||||
logger.debug('Uploading test "{0}" to TestRail project "{1}", '
|
||||
'suite "{2}", section "{3}"'.format(
|
||||
test_case[GROUP_FIELD],
|
||||
TestRailSettings.project,
|
||||
TestRailSettings.tests_suite,
|
||||
TestRailSettings.tests_section))
|
||||
testrail_project.add_case(section_id=section_id, case=test_case)
|
||||
|
||||
|
||||
def get_tests_groups_from_jenkins(runner_name, build_number, distros):
|
||||
|
@ -210,6 +186,165 @@ def get_tests_groups_from_jenkins(runner_name, build_number, distros):
|
|||
return res
|
||||
|
||||
|
||||
def _create_test_plan_from_registry(groups):
|
||||
discover_import_tests(get_basepath(), tests_directory)
|
||||
define_custom_groups()
|
||||
for one in groups:
|
||||
register_system_test_cases(one)
|
||||
return TestPlan.create_from_registry(DEFAULT_REGISTRY)
|
||||
|
||||
|
||||
def _is_case_processable(case, tests):
|
||||
if not case.entry.info.enabled or not hasattr(case.entry, 'parent'):
|
||||
return False
|
||||
|
||||
parent_home = case.entry.parent.home
|
||||
if issubclass(parent_home, ActionTest) and \
|
||||
any([test[GROUP_FIELD] == parent_home.__name__ for test in tests]):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_test_case_name(case):
|
||||
"""Returns test case name
|
||||
"""
|
||||
parent_home = case.entry.parent.home
|
||||
return parent_home.__name__ if issubclass(parent_home, ActionTest) \
|
||||
else case.entry.home.func_name
|
||||
|
||||
|
||||
def _is_not_included(case_name, include):
|
||||
if include and include not in case_name:
|
||||
logger.debug("Skipping '{0}' test because it doesn't "
|
||||
"contain '{1}' in method name".format(case_name, include))
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def _is_excluded(case_name, exclude):
|
||||
if exclude and exclude in case_name:
|
||||
logger.debug("Skipping '{0}' test because it contains"
|
||||
" '{1}' in method name".format(case_name, exclude))
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def _get_docstring(parent_home, case_state, home):
|
||||
if issubclass(parent_home, ActionTest):
|
||||
docstring = parent_home.__doc__.split('\n')
|
||||
case_state.instance._load_config()
|
||||
configuration = case_state.instance.config_name
|
||||
docstring[0] = '{0} on {1}'.format(docstring[0], configuration)
|
||||
docstring = '\n'.join(docstring)
|
||||
else:
|
||||
docstring = home.func_doc or ''
|
||||
return docstring
|
||||
|
||||
|
||||
def _parse_docstring(s, case):
|
||||
split_s = s.strip().split('\n\n')
|
||||
title_r, steps_r, duration_r = _unpack_docstring(split_s)
|
||||
title = _parse_title(title_r, case) if title_r else ''
|
||||
steps = _parse_steps(steps_r) if steps_r else ''
|
||||
duration = _parse_duration(duration_r)
|
||||
return title, steps, duration
|
||||
|
||||
|
||||
def _unpack_docstring(items):
|
||||
count = len(items)
|
||||
title = steps = duration = ''
|
||||
if count > 3:
|
||||
title, steps, duration, _ = _unpack_list(*items)
|
||||
elif count == 3:
|
||||
title, steps, duration = items
|
||||
elif count == 2:
|
||||
title, steps = items
|
||||
elif count == 1:
|
||||
title = items
|
||||
return title, steps, duration
|
||||
|
||||
|
||||
def _unpack_list(title, steps, duration, *other):
|
||||
return title, steps, duration, other
|
||||
|
||||
|
||||
def _parse_title(s, case):
|
||||
title = ' '.join(map(string.strip, s.split('\n')))
|
||||
return title if title else case.entry.home.func_name
|
||||
|
||||
|
||||
def _parse_steps(strings):
|
||||
steps = []
|
||||
index = -1
|
||||
for s_raw in strings.strip().split('\n'):
|
||||
s = s_raw.strip()
|
||||
_match = STEP_NUM_PATTERN.search(s)
|
||||
if _match:
|
||||
steps.append({'content': _match.group(), 'expected': 'pass'})
|
||||
index += 1
|
||||
else:
|
||||
if index > -1:
|
||||
steps[index]['content'] = ' '.join([steps[index]['content'],
|
||||
s])
|
||||
return steps
|
||||
|
||||
|
||||
def _parse_duration(s):
|
||||
match = DURATION_PATTERN.search(s)
|
||||
return match.group(1).replace(' ', '') if match else '3m'
|
||||
|
||||
|
||||
def _get_custom_cases_fields(case_fields, project_id):
|
||||
custom_cases_fields = {}
|
||||
for field in case_fields:
|
||||
for config in field['configs']:
|
||||
if ((project_id in
|
||||
config['context']['project_ids'] or
|
||||
not config['context']['project_ids']) and
|
||||
config['options']['is_required']):
|
||||
try:
|
||||
custom_cases_fields[field['system_name']] = \
|
||||
int(config['options']['items'].split(',')[0])
|
||||
except:
|
||||
logger.error("Couldn't find default value for required "
|
||||
"field '{0}', setting '1' (index)!".format(
|
||||
field['system_name']))
|
||||
custom_cases_fields[field['system_name']] = 1
|
||||
return custom_cases_fields
|
||||
|
||||
|
||||
def _get_fields_to_update(test_case, testrail_case):
|
||||
"""Produces dictionary with fields to be updated
|
||||
"""
|
||||
fields_to_update = {}
|
||||
for field in ('title', 'estimate', 'custom_test_case_description',
|
||||
'custom_test_case_steps'):
|
||||
if test_case[field] and \
|
||||
test_case[field] != testrail_case[field]:
|
||||
if field == 'estimate':
|
||||
testcase_estimate_raw = int(test_case[field][:-1])
|
||||
testcase_estimate = \
|
||||
datetime_util.duration_to_testrail_estimate(
|
||||
testcase_estimate_raw)
|
||||
if testrail_case[field] == testcase_estimate:
|
||||
continue
|
||||
elif field == 'custom_test_case_description' and \
|
||||
test_case[field] == testrail_case[field].replace('\r', ''):
|
||||
continue
|
||||
fields_to_update[field] = test_case[field]
|
||||
return fields_to_update
|
||||
|
||||
|
||||
def _get_testrail_case(testrail_cases, test_case, group_field):
|
||||
"""Returns testrail case that corresponds to test case from repo
|
||||
"""
|
||||
return next((case for case in testrail_cases
|
||||
if case[group_field] == test_case[group_field]))
|
||||
|
||||
|
||||
def main():
|
||||
parser = OptionParser(
|
||||
description="Upload tests cases to TestRail. "
|
||||
|
|
Loading…
Reference in New Issue