Skip to content

Commit

Permalink
Add a new metric 'test suites'.
Browse files Browse the repository at this point in the history
Add a new metric 'test suites' that can be used to count test suites (or test scenario's in Robot Framework parlance). Test suites can be filtered by test result. Supporting sources are Robot Framework, JUnit, and TestNG.

Closes #10078.
  • Loading branch information
fniessink committed Dec 5, 2024
1 parent 616bedb commit 3c7b7b1
Show file tree
Hide file tree
Showing 27 changed files with 710 additions and 97 deletions.
67 changes: 66 additions & 1 deletion components/api_server/src/example-reports/example-report.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
"password": "",
"private_token": "",
"languages_to_ignore": [],
"files_to_include": [".*test.*"]
"files_to_include": [
".*test.*"
]
}
}
},
Expand Down Expand Up @@ -128,6 +130,69 @@
"test quality"
]
},
"823001ba-d75e-4f91-83ba-5843708cb775": {
"type": "test_suites",
"sources": {
"462713f2-75c7-00c6-ae85-8987c40ee013": {
"type": "junit",
"parameters": {
"url": "http://testdata:8000/reports/junit/junit-report.xml",
"landing_url": "http://localhost:8000/reports/junit/junit-report.html",
"username": "",
"password": "",
"private_token": "",
"test_result": []
}
},
"2f32b0fe-4bb7-4485-bd11-6aaaaaa77754": {
"type": "robot_framework",
"name": "Robot Framework v3",
"parameters": {
"url": "http://testdata:8000/reports/robot-framework/output-3.2.2.xml",
"landing_url": "http://localhost:8000/reports/robot-framework/output-3.2.2.xml",
"username": "",
"password": "",
"private_token": "",
"test_result": []
}
},
"2f35b0fe-4bb7-8485-9d11-6215a575bc54": {
"type": "robot_framework",
"name": "Robot Framework v4",
"parameters": {
"url": "http://testdata:8000/reports/robot-framework/output-4.0.xml",
"landing_url": "http://localhost:8000/reports/robot-framework/output-4.0.xml",
"username": "",
"password": "",
"private_token": "",
"test_result": []
}
},
"210ba442-6a5c-4959-a88e-a1cb66f91d3e": {
"type": "testng",
"parameters": {
"url": "http://testdata:8000/reports/testng/testng-results.xml",
"landing_url": "http://localhost:8000/reports/testng/testng-results.xml",
"username": "",
"password": "",
"private_token": "",
"test_result": []
}
}
},
"name": null,
"scale": "count",
"unit": null,
"addition": "sum",
"accept_debt": false,
"debt_target": null,
"direction": null,
"target": "0",
"near_target": "0",
"tags": [
"test quality"
]
},
"41b76e57-a3d8-4a65-9814-3df900f2f2ba": {
"type": "source_up_to_dateness",
"sources": {
Expand Down
3 changes: 3 additions & 0 deletions components/collector/src/source_collectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
from .jmeter_json.tests import JMeterJSONTests
from .junit.source_up_to_dateness import JUnitSourceUpToDateness
from .junit.test_cases import JUnitTestCases
from .junit.test_suites import JUnitTestSuites
from .junit.tests import JUnitTests
from .manual_number.all_metrics import ManualNumber
from .ncover.source_up_to_dateness import NCoverSourceUpToDateness
Expand Down Expand Up @@ -127,6 +128,7 @@
from .robot_framework.source_up_to_dateness import RobotFrameworkSourceUpToDateness
from .robot_framework.source_version import RobotFrameworkSourceVersion
from .robot_framework.test_cases import RobotFrameworkTestCases
from .robot_framework.test_suites import RobotFrameworkTestSuites
from .robot_framework.tests import RobotFrameworkTests
from .robot_framework_jenkins_plugin.source_up_to_dateness import RobotFrameworkJenkinsPluginSourceUpToDateness
from .robot_framework_jenkins_plugin.tests import RobotFrameworkJenkinsPluginTests
Expand All @@ -152,6 +154,7 @@
from .sonarqube.violations import SonarQubeViolations
from .testng.source_up_to_dateness import TestNGSourceUpToDateness
from .testng.test_cases import TestNGTestCases
from .testng.test_suites import TestNGTestSuites
from .testng.tests import TestNGTests
from .trello.issues import TrelloIssues
from .trello.source_up_to_dateness import TrelloSourceUpToDateness
Expand Down
58 changes: 58 additions & 0 deletions components/collector/src/source_collectors/junit/test_suites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""JUnit test suites collector."""

from typing import cast
from xml.etree.ElementTree import Element # nosec # Element is not available from defusedxml, but only used as type

from base_collectors import XMLFileSourceCollector
from collector_utilities.functions import parse_source_response_xml
from model import Entities, Entity, SourceMeasurement, SourceResponses


class JUnitTestSuites(XMLFileSourceCollector):
"""Collector for JUnit test suites."""

async def _parse_source_responses(self, responses: SourceResponses) -> SourceMeasurement:
"""Override to parse the test suites from the JUnit XML."""
entities = Entities()
total = 0
for response in responses:
tree = await parse_source_response_xml(response)
test_suites = [tree] if tree.tag == "testsuite" else tree.findall(".//testsuite[testcase]")
for test_suite in test_suites:
parsed_entity = self.__entity(test_suite)
if self._include_entity(parsed_entity):
entities.append(parsed_entity)
total += 1
return SourceMeasurement(entities=entities, total=str(total))

def _include_entity(self, entity: Entity) -> bool:
"""Return whether to include the entity in the measurement."""
results_to_count = cast(list[str], self._parameter("test_result"))
return entity["suite_result"] in results_to_count

@staticmethod
def __entity(suite: Element) -> Entity:
"""Transform a test case into a test suite entity."""
name = suite.get("name", "unknown")
tests = len(suite.findall("testcase"))
skipped = int(suite.get("skipped", 0))
failed = int(suite.get("failures", 0))
errored = int(suite.get("errors", 0))
passed = tests - (errored + failed + skipped)
suite_result = "passed"
if errored:
suite_result = "errored"
elif failed:
suite_result = "failed"
elif skipped:
suite_result = "skipped"
return Entity(
key=name,
suite_name=name,
suite_result=suite_result,
tests=str(tests),
passed=str(passed),
errored=str(errored),
failed=str(failed),
skipped=str(skipped),
)
2 changes: 1 addition & 1 deletion components/collector/src/source_collectors/junit/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _include_entity(self, entity: Entity) -> bool:
def __entity(case_node: Element, case_result: str) -> Entity:
"""Transform a test case into a test case entity."""
class_name = case_node.get("classname", "")
name = case_node.get("name", "<nameless test case>")
name = case_node.get("name", "unknown")
key = f"{class_name}:{name}"
old_key = name # Key was changed after Quality-time 5.16.1, enable migration of user entity data
return Entity(key=key, old_key=old_key, name=name, class_name=class_name, test_result=case_result)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Robot Framework test suites collector."""

from typing import cast
from xml.etree.ElementTree import Element # nosec # Element is not available from defusedxml, but only used as type

from collector_utilities.functions import parse_source_response_xml
from collector_utilities.type import Response
from model import Entities, Entity, SourceMeasurement, SourceResponses

from .base import RobotFrameworkBaseClass


class RobotFrameworkTestSuites(RobotFrameworkBaseClass):
"""Collector for Robot Framework test suites."""

async def _parse_source_responses(self, responses: SourceResponses) -> SourceMeasurement:
"""Override to parse the tests from the Robot Framework XML."""
nr_of_suites, total_nr_of_suites, suite_entities = 0, 0, Entities()
test_results = cast(list[str], self._parameter("test_result"))
for response in responses:
count, total, entities = await self._parse_source_response(response, test_results)
nr_of_suites += count
total_nr_of_suites += total
suite_entities.extend(entities)
return SourceMeasurement(value=str(nr_of_suites), total=str(total_nr_of_suites), entities=suite_entities)

@classmethod
async def _parse_source_response(cls, response: Response, test_results: list[str]) -> tuple[int, int, Entities]:
"""Parse a Robot Framework XML."""
nr_of_suites, total_nr_of_suites, entities = 0, 0, Entities()
tree = await parse_source_response_xml(response)
for suite in tree.findall(".//suite[test]"):
total_nr_of_suites += 1
suite_status = suite.find("status")
if suite_status is None:
continue
suite_result = suite_status.get("status", "").lower()
if suite_result not in test_results:
continue
nr_of_suites += 1
entities.append(cls._create_entity(suite, suite_result))
return nr_of_suites, total_nr_of_suites, entities

@classmethod
def _create_entity(cls, suite: Element, suite_result: str) -> Entity:
"""Create a measurement entity from the test suite element."""
suite_id = suite.get("id", "")
suite_name = suite.get("name", "unknown")
doc = suite.find("doc")
suite_documentation = "" if doc is None else doc.text
tests = len(suite.findall("test"))
failed = cls._nr_tests(suite, "FAIL")
errored = cls._nr_tests(suite, "ERROR")
skipped = cls._nr_tests(suite, "SKIP")
passed = tests - (failed + errored + skipped)
return Entity(
key=suite_id,
suite_name=suite_name,
suite_documentation=suite_documentation,
suite_result=suite_result,
tests=str(tests),
passed=str(passed),
failed=str(failed),
errored=str(errored),
skipped=str(skipped),
)

@staticmethod
def _nr_tests(suite: Element, status: str) -> int:
"""Return the number of tests in the suite with the given status."""
return len(suite.findall(f"test/status[@status='{status}']"))
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ async def _parse_source_response(
continue
nr_of_tests += int(stats.get(test_result, 0))
for suite in tree.findall(".//suite[test]"):
suite_name = suite.get("name", "<Nameless suite>")
suite_name = suite.get("name", "unknown")
for test in suite.findall(f"test/status[@status='{test_result.upper()}']/.."):
test_id = test.get("id", "")
test_name = test.get("name", "<Nameless test>")
test_name = test.get("name", "unknown")
entity = Entity(key=test_id, test_name=test_name, suite_name=suite_name, test_result=test_result)
entities.append(entity)
return nr_of_tests, total_nr_of_tests, entities
62 changes: 62 additions & 0 deletions components/collector/src/source_collectors/testng/test_suites.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""TestNG test suites collector."""

from typing import cast
from xml.etree.ElementTree import Element # nosec # Element is not available from defusedxml, but only used as type

from base_collectors import XMLFileSourceCollector
from collector_utilities.functions import parse_source_response_xml
from model import Entities, Entity, SourceMeasurement, SourceResponses


class TestNGTestSuites(XMLFileSourceCollector):
"""Collector for TestNG test suites."""

async def _parse_source_responses(self, responses: SourceResponses) -> SourceMeasurement:
"""Override to parse the suites for the TestNG XML."""
entities = Entities()
total = 0
for response in responses:
tree = await parse_source_response_xml(response)
for test_suite in tree.findall(".//suite[test]"):
parsed_entity = self.__entity(test_suite)
if self._include_entity(parsed_entity):
entities.append(parsed_entity)
total += 1
return SourceMeasurement(entities=entities, total=str(total))

def _include_entity(self, entity: Entity) -> bool:
"""Return whether to include the entity in the measurement."""
results_to_count = cast(list[str], self._parameter("test_result"))
return entity["suite_result"] in results_to_count

@classmethod
def __entity(cls, suite: Element) -> Entity:
"""Transform a test suite element into a test suite entity."""
name = suite.get("name", "unknown")
tests = len(suite.findall(".//test-method"))
skipped = cls._nr_tests(suite, "SKIP")
failed = cls._nr_tests(suite, "FAIL")
passed = cls._nr_tests(suite, "PASS")
ignored = tests - (skipped + failed + passed)
suite_result = "passed"
if failed:
suite_result = "failed"
elif skipped:
suite_result = "skipped"
elif ignored:
suite_result = "ignored"
return Entity(
key=name,
suite_name=name,
suite_result=suite_result,
tests=str(tests),
passed=str(passed),
ignored=str(ignored),
failed=str(failed),
skipped=str(skipped),
)

@staticmethod
def _nr_tests(suite: Element, status: str) -> int:
"""Return the number of tests in the suite with the given status."""
return len(suite.findall(f".//test-method[@status='{status}']"))
29 changes: 21 additions & 8 deletions components/collector/tests/source_collectors/junit/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,25 @@ class JUnitCollectorTestCase(SourceCollectorTestCase):
"""Base class for Junit collector unit tests."""

SOURCE_TYPE = "junit"
JUNIT_XML = """<testsuites>
<testsuite timestamp="2009-12-19T17:58:59">
<testcase name="tc1" classname="cn"/>
<testcase name="tc2" classname="cn"/>
<testcase name="tc3" classname="cn"><failure/></testcase>
<testcase name="tc4" classname="cn"><error/></testcase>
<testcase name="tc5" classname="cn"><skipped/></testcase>
</testsuite></testsuites>"""
JUNIT_XML = """
<testsuites>
<testsuite name="ts1" timestamp="2009-12-19T17:58:59" failures="1" errors="1" skipped="1" tests="4">
<testcase name="tc1" classname="cn"/>
<testcase name="tc2" classname="cn"><failure/></testcase>
<testcase name="tc3" classname="cn"><error/></testcase>
<testcase name="tc4" classname="cn"><skipped/></testcase>
</testsuite>
<testsuite name="ts2" timestamp="2009-12-19T17:58:59" failures="0" errors="1" skipped="0" tests="1">
<testcase name="tc5" classname="cn"><error/></testcase>
</testsuite>
<testsuite name="ts3" timestamp="2009-12-19T17:58:59" failures="1" errors="0" skipped="0" tests="1">
<testcase name="tc6" classname="cn"><failure/></testcase>
</testsuite>
<testsuite name="ts4" timestamp="2009-12-19T17:58:59" failures="0" errors="0" skipped="1" tests="1">
<testcase name="tc7" classname="cn"><skipped/></testcase>
</testsuite>
<testsuite name="ts5" timestamp="2009-12-19T17:58:59" failures="0" errors="0" skipped="0" tests="1">
<testcase name="tc8" classname="cn"></testcase>
</testsuite>
</testsuites>"""
JUNIT_XML_EMPTY_TEST_SUITES = '<testsuites name="Mocha Tests" time="0.0000" tests="0" failures="0"></testsuites>'
Loading

0 comments on commit 3c7b7b1

Please sign in to comment.