From 7155a2a3e4cf3b8afc311728dfc595c68f46aaf0 Mon Sep 17 00:00:00 2001 From: Tristan FAURE Date: Sun, 22 Oct 2023 03:44:12 -0400 Subject: [PATCH] =?UTF-8?q?First=20commit,=20add=20cache=20option=20to=20z?= =?UTF-8?q?eep=20and=20create=20a=20basic=20test=20case=20w=E2=80=A6=20(#1?= =?UTF-8?q?41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * First commit, add cache option to zeep and create a basic test case with no properties * #140 add traceability import through properties * add documentation and example --- docs/xml.rst | 20 +- polarion/polarion.py | 35 ++-- polarion/xml.py | 97 +++++++-- tests/junit/no_properties.xml | 28 +++ tests/junit/properties-errors.xml | 27 +++ tests/junit/properties-trace-sys-out.xml | 58 ++++++ tests/junit/properties.xml | 46 +++++ tests/test_junit.py | 246 +++++++++++++++++++++++ 8 files changed, 528 insertions(+), 29 deletions(-) create mode 100644 tests/junit/no_properties.xml create mode 100644 tests/junit/properties-errors.xml create mode 100644 tests/junit/properties-trace-sys-out.xml create mode 100644 tests/junit/properties.xml create mode 100644 tests/test_junit.py diff --git a/docs/xml.rst b/docs/xml.rst index 38fa5b5..8713152 100644 --- a/docs/xml.rst +++ b/docs/xml.rst @@ -11,7 +11,7 @@ Import an xml file of results ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Import an xml file of results (see xml_junit.xsd for the standard format) into a new or an existing test run in a project - +Import traceability to Workitems using their ids or their titles .. code:: python @@ -30,6 +30,7 @@ Import an xml file of results (see xml_junit.xsd for the standard format) into a Config.PROJECT_ID: 'project_id_in_polarion', Config.TESTRUN_ID: 'testrun_id_in_polarion', # if not set, create a new test run Config.TESTRUN_COMMENT: 'comment to add in the test run' # as an option. + Config.USE_CACHE : 'True or False (Default) use the zeep cache' # as an option }) testrun=Importer.from_xml(config) @@ -37,6 +38,23 @@ Import an xml file of results (see xml_junit.xsd for the standard format) into a # if want to save the test_run as json, add: ResultExporter.save_json("result.json", testrun) +For traceability: + +.. code-block:: XML + + + + + + + + + + + [[PROPERTY|verifies=ISSUE-011]] + + Export a test run as json ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/polarion/polarion.py b/polarion/polarion.py index f07edd4..f7f45d7 100644 --- a/polarion/polarion.py +++ b/polarion/polarion.py @@ -2,7 +2,9 @@ import re from urllib.parse import urljoin, urlparse import requests -from zeep import Client, Transport +import tempfile +import os +from zeep import Client, CachingClient from zeep.plugins import HistoryPlugin from .project import Project @@ -27,7 +29,7 @@ class Polarion(object): """ def __init__(self, polarion_url, user, password=None, token=None, static_service_list=False, verify_certificate=True, - svn_repo_url=None, proxy=None, request_session=None): + svn_repo_url=None, proxy=None, request_session=None, cache=False): self.user = user self.password = password self.token = token @@ -36,6 +38,8 @@ def __init__(self, polarion_url, user, password=None, token=None, static_service self.svn_repo_url = svn_repo_url self.proxy = None self.request_session = request_session + self.cache = cache + self.transport = None if proxy is not None: self.proxy = { 'http': proxy, @@ -90,10 +94,9 @@ def _createSession(self): """ if 'Session' in self.services: self.history = HistoryPlugin() - self.services['Session']['client'] = Client( - self.services['Session']['url'] + '?wsdl', plugins=[self.history], transport=self._getTransport()) + self.services['Session']['client'] = self.get_client('Session',[self.history]) if self.proxy is not None: - self.services['Session']['client'] .transport.session.proxies = self.proxy + self.services['Session']['client'].transport.session.proxies = self.proxy try: self.sessionHeaderElement = None self.sessionCookieJar = None @@ -116,6 +119,15 @@ def _createSession(self): else: raise Exception( 'Cannot login because WSDL has no SessionWebService') + + def get_client(self,service,plugins=[]): + client = None + if self.cache: + client = CachingClient(self.services[service]['url'] + '?wsdl', plugins=plugins) + else: + client = Client(self.services[service]['url'] + '?wsdl', plugins=plugins) + client.transport.session.verify = self.verify_certificate + return client def _updateServices(self): """ @@ -126,8 +138,7 @@ def _updateServices(self): for service in self.services: if service != 'Session': if 'client' not in service: - self.services[service]['client'] = Client( - self.services[service]['url'] + '?wsdl', transport=self._getTransport()) + self.services[service]['client'] = self.get_client(service) self.services[service]['client'].set_default_soapheaders( [self.sessionHeaderElement]) if self.proxy is not None: @@ -189,14 +200,6 @@ def PdfProperties(self): raise Exception(f'PDF not supported in this Polarion version') return self._PdfProperties - def _getTransport(self): - """ - Gets the zeep transport object - """ - transport = Transport(session=self.request_session) - transport.session.verify = self.verify_certificate - return transport - def hasService(self, name: str): """ Checks if a WSDL service is available @@ -207,7 +210,7 @@ def hasService(self, name: str): def getService(self, name: str): """ - Get a WSDL service client. The name can be 'Trakcer' or 'Session' + Get a WSDL service client. The name can be 'Tracker' or 'Session' """ # request user info to see if we're still logged in try: diff --git a/polarion/xml.py b/polarion/xml.py index 4aa0a65..fc83019 100644 --- a/polarion/xml.py +++ b/polarion/xml.py @@ -3,6 +3,7 @@ from .record import Record from datetime import datetime import logging, json +import re logger = logging.getLogger(__name__) class Config: @@ -21,11 +22,14 @@ class Config: TESTRUN_TYPE='testrun_type' # type of the test run if testrun is created. If not set 'xUnit Test Manual Upload' TESTRUN_COMMENT='testrun_comment' # test run comment to add if set. SKIP_MISSING_TESTCASE='skip_missing_testcase' # if set to True, skip result on unknown test cases + VERIFY_CERT ='verify_cert' # verify or not the cert + USE_CACHE ='use_cache' # verify or not the cert ATTRIBUTES = [ XML_FILE, URL, USERNAME, PASSWORD, TOKEN, PROJECT_ID, TESTRUN_ID, TESTRUN_ID_GENERATOR, - TESTRUN_TITLE, TESTRUN_TYPE, TESTRUN_COMMENT, SKIP_MISSING_TESTCASE] + TESTRUN_TITLE, TESTRUN_TYPE, TESTRUN_COMMENT, SKIP_MISSING_TESTCASE, VERIFY_CERT, USE_CACHE] MANDATORY = [XML_FILE, URL, PROJECT_ID] # and also either user/password or token + _classinitialised = False def __new__(cls, *args, **kwargs): @@ -44,6 +48,10 @@ def _default_value(cls, attribute_name): return 'xUnit Test Manual Upload' elif attribute_name==Config.SKIP_MISSING_TESTCASE: return False + elif attribute_name==Config.VERIFY_CERT: + return True + elif attribute_name==Config.USE_CACHE: + return False return None @classmethod @@ -61,6 +69,7 @@ def from_dict(cls, data): """ return Config(data) + def __init__(self, data): """ Init from existing data @@ -84,6 +93,7 @@ def generate_test_run_id(self): else: self._data[Config.TESTRUN_ID]=f'unit-{datetime.now().strftime("%Y-%m-%d-%H-%M-%S-%f")}' return getattr(self, Config.TESTRUN_ID) + class XmlParser: """ @@ -92,6 +102,9 @@ class XmlParser: TEST_SUITES='testsuites' TEST_SUITE='testsuite' TEST_CASE='testcase' + PROPERTIES='properties' + PROPERTY='property' + SYSOUT='system-out' @classmethod def parse_root(cls, xml_file): @@ -131,6 +144,21 @@ def _parse_suite(cls, test_suite, parent, returned_cases): else: raise Exception(f'Unmanaged {XmlParser.TEST_SUITE} {test_suite.tag} in {parent["path"]}') + # matches expressions like [[PROPERTY|verifies=REQ-001]] + RE_PATTTERN = re.compile("\\[\\[PROPERTY\\|(.*)\\=(.*)\\]\\]") + + @classmethod + def tranform_string_properties(cls, value): + result = [] + tmp = XmlParser.RE_PATTTERN.findall(value) + for res in tmp: + if len(res) == 2: + result.append({ + "name":res[0], + "value":res[1] + }) + return result + @classmethod def _parse_case(cls, test_case, parent, returned_cases): if test_case.tag == XmlParser.TEST_CASE: @@ -146,14 +174,26 @@ def _parse_case(cls, test_case, parent, returned_cases): if 'time' in test_case.attrib.keys(): case.update({ 'time': test_case.attrib['time'] }) - # error & failure + # error & failure & properties for elem in test_case: - if elem.tag in ['error', 'failure']: + if elem.tag in ['error', 'failure','skipped']: text = [] for attrib in ['type', 'message']: if attrib in elem.attrib.keys(): text.append(elem.attrib[attrib]) - text.append(elem.text) + if elem.text is not None: + text.append(elem.text) case.update({ elem.tag: '\n'.join(text)}) + elif elem.tag == XmlParser.PROPERTIES: + if "properties" not in case: + case.update({'properties':[]}) + for property in elem: + if XmlParser.PROPERTY == property.tag and 'name' in property.attrib.keys() and 'value' in property.attrib.keys(): + case['properties'].append({property.get('name') : property.get('value')}) + elif elem.tag == XmlParser.SYSOUT: + if "properties" not in case: + case.update({'properties':[]}) + for property in XmlParser.tranform_string_properties(elem.text): + case['properties'].append({property['name'] : property['value']}) returned_cases.append(case) else: raise Exception(f'Unmanaged {XmlParser.TEST_CASE} {test_case.tag} in {parent["path"]}') @@ -166,12 +206,12 @@ def _xmlnode_name(cls, node): if 'name' in node.attrib.keys(): return f'{node.tag}[name={node.attrib["name"]}]' return node.tag - + class Importer: """ Import xml file to polarion using a config """ - TEST_CASE_ID_CUSTOM_FILED='testCaseID' + TEST_CASE_ID_CUSTOM_FIELD='testCaseID' TEST_CASE_TYPE='type:testcase' TEST_CASE_WI_TYPE='testcase' TEST_CASE_WI_TITLE='title' @@ -186,16 +226,17 @@ def from_xml(cls, config): cases=XmlParser.parse_root(config.xml_file) logger.info(f'Connection to polarion {config.url} on project {config.project_id}') - polarion=Polarion(polarion_url=config.url, user=config.username, password=config.password, token=config.token) + + polarion=Polarion(polarion_url=config.url, user=config.username, password=config.password, token=config.token, verify_certificate=config.verify_cert, cache=config.use_cache) project=polarion.getProject(config.project_id) # Indexing existing cases with custom field ' - test_cases=project.searchWorkitem(Importer.TEST_CASE_TYPE, field_list=['id', f'customFields.{Importer.TEST_CASE_ID_CUSTOM_FILED}']) + test_cases=project.searchWorkitem(Importer.TEST_CASE_TYPE, field_list=['id', f'customFields.{Importer.TEST_CASE_ID_CUSTOM_FIELD}']) test_cases_from_id={} for test_case in test_cases: if hasattr(test_case,'customFields') and hasattr(test_case.customFields,'Custom'): for custom in test_case.customFields.Custom: - if getattr(custom, 'key', None) == Importer.TEST_CASE_ID_CUSTOM_FILED and hasattr(custom,'value'): + if getattr(custom, 'key', None) == Importer.TEST_CASE_ID_CUSTOM_FIELD and hasattr(custom,'value'): test_cases_from_id[custom.value]=test_case.id # Getting or creating test run @@ -222,16 +263,19 @@ def from_xml(cls, config): test_run.setCustomField(Importer.TEST_RUN_COMMENT_CUSTOM_FIELD, test_run._polarion.TextType( content=comment, type='text/html', contentLossy=False)) + # cache for work items traced + cache_for_workitems = {} + # Filling logger.info('Saving results') for case in cases: if case['id'] not in test_cases_from_id.keys(): if config.skip_missing_testcase: - logger.warn(f'Skipping case with {Importer.TEST_CASE_ID_CUSTOM_FILED} {case["id"]}') + logger.warn(f'Skipping case with {Importer.TEST_CASE_ID_CUSTOM_FIELD} {case["id"]}') continue - print(f'Creating case with {Importer.TEST_CASE_ID_CUSTOM_FILED} {case["id"]}') + print(f'Creating case with {Importer.TEST_CASE_ID_CUSTOM_FIELD} {case["id"]}') wi_case=project.createWorkitem(workitem_type=Importer.TEST_CASE_WI_TYPE, new_workitem_fields={Importer.TEST_CASE_WI_TITLE: case['id']}) - wi_case.setCustomField(key=Importer.TEST_CASE_ID_CUSTOM_FILED, value=case['id']) + wi_case.setCustomField(key=Importer.TEST_CASE_ID_CUSTOM_FIELD, value=case['id']) else: wi_case=project.getWorkitem(test_cases_from_id[case['id']]) test_run.addTestcase(wi_case) @@ -246,8 +290,37 @@ def from_xml(cls, config): test_run.records[-1].setResult(Record.ResultType.FAILED, case['failure']) elif 'error' in case.keys(): test_run.records[-1].setResult(Record.ResultType.BLOCKED, case['error']) + elif 'skipped' in case.keys(): + test_run.records[-1].setResult(Record.ResultType.NOTTESTED, case['skipped']) else: test_run.records[-1].setResult(Record.ResultType.PASSED) + + # handle traceability, because of API, traqceability must use default traceability role + # and not the opposite one. + # traceability links are made using IDs or titles + # this implementation does not allow traceability between test cases + if 'properties' in case.keys(): + for property in case['properties']: + for key in property.keys(): + linked_item = None + title=property.get(key) + if title in cache_for_workitems: + linked_item = cache_for_workitems[title] + else: + try: + linked_item=project.getWorkitem(property.get(key)) + except Exception: + linked_items=project.searchWorkitem(query=f'title:{title}', field_list=['id','title']) + if len(linked_items) > 0 and linked_items[0]['title']==title: + linked_item=linked_items[0] + # work item is reload to avoid isues of class not correctly loaded by search work item + linked_item=project.getWorkitem(linked_item['id']) + else: + logger.error(f'impossible to link{wi_case.id} to {title}') + cache_for_workitems[title] = linked_item + if linked_item is not None: + wi_case.addLinkedItem(linked_item, key) + logger.info(f'Results saved in {config.url}/#/project/{config.project_id}/testrun?id={config.testrun_id}') return test_run diff --git a/tests/junit/no_properties.xml b/tests/junit/no_properties.xml new file mode 100644 index 0000000..1d9bffb --- /dev/null +++ b/tests/junit/no_properties.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/junit/properties-errors.xml b/tests/junit/properties-errors.xml new file mode 100644 index 0000000..8c5326d --- /dev/null +++ b/tests/junit/properties-errors.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/junit/properties-trace-sys-out.xml b/tests/junit/properties-trace-sys-out.xml new file mode 100644 index 0000000..a33bf3f --- /dev/null +++ b/tests/junit/properties-trace-sys-out.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + +Output line #1 +Output line #2 + +[[PROPERTY|verifies=REQ-011]] +[[PROPERTY|verifies=REQ-012]] + + + + + + + + + +Output line #1 +Output line #2 + +[[PROPERTY|relates_to=REQ-012]] + +Output line #1 +Output line #3 + + + + + + + + +[[PROPERTY|verifies=ISSUE-011]] + + + + \ No newline at end of file diff --git a/tests/junit/properties.xml b/tests/junit/properties.xml new file mode 100644 index 0000000..10de645 --- /dev/null +++ b/tests/junit/properties.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/test_junit.py b/tests/test_junit.py new file mode 100644 index 0000000..6bed90a --- /dev/null +++ b/tests/test_junit.py @@ -0,0 +1,246 @@ +import unittest + +import zeep +from polarion.polarion import Polarion +from polarion.xml import Config, Importer, ResultExporter, XmlParser +from polarion.record import Record +# from keys import polarion_url, polarion_project_id, polarion_user, polarion_password, polarion_token +from keys import polarion_url, polarion_project_id, polarion_user, polarion_password + +from unittest import mock + +REQ_TYPE = 'requirement' +ISSUE_TYPE = 'issue' + +class TestPolarionJunit(unittest.TestCase): + + def get_config (self, file): + # return Config.from_dict({ + # Config.XML_FILE: file, + # Config.URL: polarion_url, + # Config.USERNAME: polarion_user, + # Config.TOKEN: polarion_token, + # Config.PROJECT_ID: polarion_project_id, + # Config.TESTRUN_COMMENT: '', + # Config.USE_CACHE : True + # }) + return Config.from_dict({ + Config.XML_FILE: file, + Config.URL: polarion_url, + Config.USERNAME: polarion_user, + Config.PASSWORD: polarion_password, + Config.PROJECT_ID: polarion_project_id, + Config.TESTRUN_COMMENT: '', + Config.USE_CACHE : True + }) + + def import_xml (self, file): + return Importer.from_xml(self.get_config(file)) + + def get_project(self): + polarion=Polarion(polarion_url=polarion_url, user=polarion_user, password=polarion_password, cache=True) + # polarion=Polarion(polarion_url=polarion_url, user=polarion_user, token=polarion_token, cache=True) + project=polarion.getProject(polarion_project_id) + return project + + def search_wi(self,project, wi): + tmp = None + try: + tmp = project.getWorkitem(wi) + except Exception: + result = project.searchWorkitem(query=f'title:{wi}',field_list=['id','title']) + if len(result) > 0 and result[0]['title'] == wi: + tmp = project.getWorkitem(result[0]['id']) + return tmp + + def create_wi (self, project, type, delete_before=False, *wis): + result = {} + for wi in wis: + tmp = self.search_wi(project, wi) + if tmp is None: + result[wi] = project.createWorkitem(type, new_workitem_fields={'title': wi}) + else: + if delete_before: + tmp.delete() + result[wi] = project.createWorkitem(type, new_workitem_fields={'title': wi}) + else: + result[wi] = tmp + return result + + def create_reqs (self, project, delete_before=False, *reqs): + return self.create_wi(project, REQ_TYPE, delete_before, *reqs) + + def create_issue (self, project, delete_before=False, *crs): + return self.create_wi(project, ISSUE_TYPE, delete_before, *crs) + + def test_regular_expression(self): + test1 = { + "value":""" + Output line #1 + Output line #2 + + [[PROPERTY|verifies=REQ-001]] + [[PROPERTY|verifies=REQ-002]] + """, + "result":[{ + "name":"verifies", + "value":"REQ-001" + }, + { + "name":"verifies", + "value":"REQ-002" + } + ] + } + test2={ + "value":""" + Output line #1 + Output line #2 + + [[PROPERTY|relates_to=REQ-002]] + + Output line #1 + Output line #3 + """, + "result":[{ + "name":"relates_to", + "value":"REQ-002" + } + ] + } + + test3={ + "value":"""" + [[PROPERTY|verifies=ISSUE-001]] + """, + "result":[{ + "name":"verifies", + "value":"ISSUE-001" + } + ] + } + # intended error in the syntax + test4={ + "value":"""" + [PROPERTY|verifies=ISSUE-001]] + """, + "result":[] + } + test5={ + "value":"", + "result":[] + } + self.assertEqual(XmlParser.tranform_string_properties(test1["value"]),test1["result"]) + self.assertEqual(XmlParser.tranform_string_properties(test2["value"]),test2["result"]) + self.assertEqual(XmlParser.tranform_string_properties(test3["value"]),test3["result"]) + self.assertEqual(XmlParser.tranform_string_properties(test4["value"]),test4["result"]) + self.assertEqual(XmlParser.tranform_string_properties(test5["value"]),test5["result"]) + + def test_import_xml_basic(self): + testrun=self.import_xml('junit/no_properties.xml') + self.assertIsNotNone(testrun) + self.assertEqual(len(testrun.records),4) + # impossible with the current api to map (without server calls) in the test to ID in the file + nbFailed = 1 + nbBlocked = 1 + nbPassed = 1 + nbNotTested = 1 + for rec in testrun.records: + if rec.getResult() == Record.ResultType.NOTTESTED: + nbNotTested = nbNotTested - 1 + elif rec.getResult() == Record.ResultType.FAILED: + nbFailed = nbFailed - 1 + elif rec.getResult() == Record.ResultType.PASSED: + nbPassed = nbPassed - 1 + elif rec.getResult() == Record.ResultType.BLOCKED: + nbBlocked = nbBlocked - 1 + self.assertEqual(nbFailed,0) + self.assertEqual(nbPassed,0) + self.assertEqual(nbNotTested,0) + self.assertEqual(nbBlocked,0) + + def test_import_xml_properties(self): + # create work items for testing purpose + project = self.get_project() + self.create_reqs(project, True, "REQ-001","REQ-002") + self.create_issue(project, True, "ISSUE-001") + + testrun=self.import_xml('junit/properties.xml') + self.assertIsNotNone(testrun) + self.assertEqual(len(testrun.records),3) + + # verify REQ-001 traceability + req001 = self.search_wi(project, "REQ-001") + links = req001.getLinkedItemWithRoles() + self.assertEqual(len(links), 1) + self.assertEqual(links[0][0],"verifies") + self.assertEqual(links[0][1].title, "Tests.Registration.testCase8") + + # verify REQ-002 traceability + req002 = self.search_wi(project, "REQ-002") + links_req002=req002.getLinkedItemWithRoles() + self.assertEqual(len(links_req002), 2) + for link in links_req002: + if link[0] == "verifies": + self.assertEqual(link[1].title, "Tests.Registration.testCase8") + elif link[0] == "relates_to": + self.assertEqual(link[1].title, "Tests.Registration.testCase9") + + # verify ISSUE-001 traceability + issue001 = self.search_wi(project, "ISSUE-001") + links_issue = issue001.getLinkedItemWithRoles() + self.assertEqual(len(links_issue), 1) + self.assertEqual(links_issue[0][0],"verifies") + self.assertEqual(links_issue[0][1].title, "Tests.Registration.testCase10") + + def test_import_xml_with_bad_link(self): + testrun=self.import_xml('junit/properties-errors.xml') + self.assertIsNotNone(testrun) + self.assertEqual(len(testrun.records),1) + + project = self.get_project() + req_does_not_exist =self.search_wi(project, "REQ-THAT-DOES-NOT-EXIST") + self.assertIsNone(req_does_not_exist) + + + def test_import_xml_properties_sys_out(self): + # create work items for testing purpose + project = self.get_project() + self.create_reqs(project, True,"REQ-011","REQ-012") + self.create_issue(project, True,"ISSUE-011","ISSUE-012") + + testrun=self.import_xml('junit/properties-trace-sys-out.xml') + self.assertIsNotNone(testrun) + self.assertEqual(len(testrun.records),3) + + # verify REQ-011 traceability + req011 = self.search_wi(project, "REQ-011") + links = req011.getLinkedItemWithRoles() + self.assertEqual(len(links), 1) + self.assertEqual(links[0][0],"verifies") + self.assertEqual(links[0][1].title, "Tests.Registration.testCase8") + + # verify REQ-012 traceability + req012 = self.search_wi(project, "REQ-012") + links_req012=req012.getLinkedItemWithRoles() + self.assertEqual(len(links_req012), 2) + for link in links_req012: + if link[0] == "verifies": + self.assertEqual(link[1].title, "Tests.Registration.testCase8") + elif link[0] == "relates_to": + self.assertEqual(link[1].title, "Tests.Registration.testCase9") + + # test case 10 has two links one with tag and one in sys out + # verify ISSUE-011 traceability + issue011 = self.search_wi(project, "ISSUE-011") + links_issue = issue011.getLinkedItemWithRoles() + self.assertEqual(len(links_issue), 1) + self.assertEqual(links_issue[0][0],"verifies") + self.assertEqual(links_issue[0][1].title, "Tests.Registration.testCase10") + + # verify ISSUE-012 traceability + issue012 = self.search_wi(project, "ISSUE-012") + links_issue12 = issue012.getLinkedItemWithRoles() + self.assertEqual(len(links_issue12), 1) + self.assertEqual(links_issue12[0][0],"verifies") + self.assertEqual(links_issue12[0][1].title, "Tests.Registration.testCase10")