unity_test_parser.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. # SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD
  2. # SPDX-License-Identifier: Apache-2.0
  3. """
  4. Modification version of https://github.com/ETCLabs/unity-test-parser/blob/develop/unity_test_parser.py
  5. since only python 3.6 or higher version have ``enum.auto()``
  6. unity_test_parser.py
  7. Parse the output of the Unity Test Framework for C. Parsed results are held in the TestResults
  8. object format, which can then be converted to various XML formats.
  9. """
  10. import enum
  11. import re
  12. import junit_xml
  13. _NORMAL_TEST_REGEX = re.compile(r'(?P<file>.+):(?P<line>\d+):(?P<test_name>[^\s:]+):(?P<result>PASS|FAIL|IGNORE)(?:: (?P<message>.+))?')
  14. _UNITY_FIXTURE_VERBOSE_PREFIX_REGEX = re.compile(r'(?P<prefix>TEST\((?P<test_group>[^\s,]+), (?P<test_name>[^\s\)]+)\))(?P<remainder>.+)?$')
  15. _UNITY_FIXTURE_REMAINDER_REGEX = re.compile(r'^(?P<file>.+):(?P<line>\d+)::(?P<result>PASS|FAIL|IGNORE)(?:: (?P<message>.+))?')
  16. _TEST_SUMMARY_BLOCK_REGEX = re.compile(
  17. r'^(?P<num_tests>\d+) Tests (?P<num_failures>\d+) Failures (?P<num_ignored>\d+) Ignored\s*\r?\n(?P<overall_result>OK|FAIL)(?:ED)?', re.MULTILINE
  18. )
  19. _TEST_RESULT_ENUM = ['PASS', 'FAIL', 'IGNORE']
  20. class TestFormat(enum.Enum):
  21. """Represents the flavor of Unity used to produce a given output."""
  22. UNITY_BASIC = 0
  23. # UNITY_FIXTURE = enum.auto()
  24. UNITY_FIXTURE_VERBOSE = 1
  25. globals().update(TestFormat.__members__)
  26. class TestStats:
  27. """Statistics about a test collection"""
  28. def __init__(self):
  29. self.total = 0
  30. self.passed = 0
  31. self.failed = 0
  32. self.ignored = 0
  33. def __eq__(self, other):
  34. if isinstance(other, self.__class__):
  35. return (self.total == other.total
  36. and self.passed == other.passed
  37. and self.failed == other.failed
  38. and self.ignored == other.ignored)
  39. return False
  40. class TestResult:
  41. """
  42. Class representing the result of a single test.
  43. Contains the test name, its result (either PASS, FAIL or IGNORE), the file and line number if
  44. the test result was not PASS, and an optional message.
  45. """
  46. def __init__(
  47. self,
  48. test_name,
  49. result,
  50. group='default',
  51. file='',
  52. line=0,
  53. message='',
  54. full_line='',
  55. ):
  56. if result not in _TEST_RESULT_ENUM:
  57. raise ValueError('result must be one of {}.'.format(_TEST_RESULT_ENUM))
  58. self._test_name = test_name
  59. self._result = result
  60. self._group = group
  61. self._message = message
  62. self._full_line = full_line
  63. if result != 'PASS':
  64. self._file = file
  65. self._line = line
  66. else:
  67. self._file = ''
  68. self._line = 0
  69. def file(self):
  70. """The file name - returns empty string if the result is PASS."""
  71. return self._file
  72. def line(self):
  73. """The line number - returns 0 if the result is PASS."""
  74. return self._line
  75. def name(self):
  76. """The test name."""
  77. return self._test_name
  78. def result(self):
  79. """The test result, one of PASS, FAIL or IGNORED."""
  80. return self._result
  81. def group(self):
  82. """
  83. The test group, if applicable.
  84. For basic Unity output, this will always be "default".
  85. """
  86. return self._group
  87. def message(self):
  88. """The accompanying message - returns empty string if the result is PASS."""
  89. return self._message
  90. def full_line(self):
  91. """The original, full line of unit test output that this object was created from."""
  92. return self._full_line
  93. class TestResults:
  94. """
  95. Class representing Unity test results.
  96. After being initialized with raw test output, it parses the output and represents it as a list
  97. of TestResult objects which can be inspected or converted to other types of output, e.g. JUnit
  98. XML.
  99. """
  100. def __init__(self, test_output, test_format=TestFormat.UNITY_BASIC):
  101. """
  102. Create a new TestResults object from Unity test output.
  103. Keyword arguments:
  104. test_output -- The full test console output, must contain the overall result and summary
  105. block at the bottom.
  106. Optional arguments:
  107. test_format -- TestFormat enum representing the flavor of Unity used to create the output.
  108. Exceptions:
  109. ValueError, if the test output is not formatted properly.
  110. """
  111. self._tests = []
  112. self._test_stats = self._find_summary_block(test_output)
  113. if test_format is TestFormat.UNITY_BASIC:
  114. self._parse_unity_basic(test_output)
  115. elif test_format is TestFormat.UNITY_FIXTURE_VERBOSE:
  116. self._parse_unity_fixture_verbose(test_output)
  117. else:
  118. raise ValueError(
  119. 'test_format must be one of UNITY_BASIC or UNITY_FIXTURE_VERBOSE.'
  120. )
  121. def num_tests(self):
  122. """The total number of tests parsed."""
  123. return self._test_stats.total
  124. def num_passed(self):
  125. """The number of tests with result PASS."""
  126. return self._test_stats.passed
  127. def num_failed(self):
  128. """The number of tests with result FAIL."""
  129. return self._test_stats.failed
  130. def num_ignored(self):
  131. """The number of tests with result IGNORE."""
  132. return self._test_stats.ignored
  133. def test_iter(self):
  134. """Get an iterator for iterating over individual tests.
  135. Returns an iterator over TestResult objects.
  136. Example:
  137. for test in unity_results.test_iter():
  138. print(test.name())
  139. """
  140. return iter(self._tests)
  141. def tests(self):
  142. """Get a list of all the tests (TestResult objects)."""
  143. return self._tests
  144. def to_junit(
  145. self, suite_name='all_tests',
  146. ):
  147. """
  148. Convert the tests to JUnit XML.
  149. Returns a junit_xml.TestSuite containing all of the test cases. One test suite will be
  150. generated with the name given in suite_name. Unity Fixture test groups are mapped to the
  151. classname attribute of test cases; for basic Unity output there will be one class named
  152. "default".
  153. Optional arguments:
  154. suite_name -- The name to use for the "name" and "package" attributes of the testsuite element.
  155. Sample output:
  156. <testsuite disabled="0" errors="0" failures="1" name="[suite_name]" package="[suite_name]" skipped="0" tests="8" time="0">
  157. <testcase classname="test_group_1" name="group_1_test" />
  158. <testcase classname="test_group_2" name="group_2_test" />
  159. </testsuite>
  160. """
  161. test_case_list = []
  162. for test in self._tests:
  163. if test.result() == 'PASS':
  164. test_case_list.append(
  165. junit_xml.TestCase(name=test.name(), classname=test.group())
  166. )
  167. else:
  168. junit_tc = junit_xml.TestCase(
  169. name=test.name(),
  170. classname=test.group(),
  171. file=test.file(),
  172. line=test.line(),
  173. )
  174. if test.result() == 'FAIL':
  175. junit_tc.add_failure_info(
  176. message=test.message(), output=test.full_line()
  177. )
  178. elif test.result() == 'IGNORE':
  179. junit_tc.add_skipped_info(
  180. message=test.message(), output=test.full_line()
  181. )
  182. test_case_list.append(junit_tc)
  183. return junit_xml.TestSuite(
  184. name=suite_name, package=suite_name, test_cases=test_case_list
  185. )
  186. def _find_summary_block(self, unity_output):
  187. """
  188. Find and parse the test summary block.
  189. Unity prints a test summary block at the end of a test run of the form:
  190. -----------------------
  191. X Tests Y Failures Z Ignored
  192. [PASS|FAIL]
  193. Returns the contents of the test summary block as a TestStats object.
  194. """
  195. match = _TEST_SUMMARY_BLOCK_REGEX.search(unity_output)
  196. if not match:
  197. raise ValueError('A Unity test summary block was not found.')
  198. try:
  199. stats = TestStats()
  200. stats.total = int(match.group('num_tests'))
  201. stats.failed = int(match.group('num_failures'))
  202. stats.ignored = int(match.group('num_ignored'))
  203. stats.passed = stats.total - stats.failed - stats.ignored
  204. return stats
  205. except ValueError:
  206. raise ValueError('The Unity test summary block was not valid.')
  207. def _parse_unity_basic(self, unity_output):
  208. """
  209. Parse basic unity output.
  210. This is of the form file:line:test_name:result[:optional_message]
  211. """
  212. found_test_stats = TestStats()
  213. for test in _NORMAL_TEST_REGEX.finditer(unity_output):
  214. try:
  215. new_test = TestResult(
  216. test.group('test_name'),
  217. test.group('result'),
  218. file=test.group('file'),
  219. line=int(test.group('line')),
  220. message=test.group('message')
  221. if test.group('message') is not None
  222. else '',
  223. full_line=test.group(0),
  224. )
  225. except ValueError:
  226. continue
  227. self._add_new_test(new_test, found_test_stats)
  228. if len(self._tests) == 0:
  229. raise ValueError('No tests were found.')
  230. if found_test_stats != self._test_stats:
  231. raise ValueError('Test output does not match summary block.')
  232. def _parse_unity_fixture_verbose(self, unity_output):
  233. """
  234. Parse the output of the unity_fixture add-in invoked with the -v flag.
  235. This is a more complex operation than basic unity output, because the output for a single
  236. test can span multiple lines. There is a prefix of the form "TEST(test_group, test_name)"
  237. that always exists on the first line for a given test. Immediately following that can be a
  238. pass or fail message, or some number of diagnostic messages followed by a pass or fail
  239. message.
  240. """
  241. found_test_stats = TestStats()
  242. line_iter = iter(unity_output.splitlines())
  243. try:
  244. line = next(line_iter)
  245. while True:
  246. prefix_match = _UNITY_FIXTURE_VERBOSE_PREFIX_REGEX.search(line)
  247. line = next(line_iter)
  248. if prefix_match:
  249. # Handle the remaining portion of a test case line after the unity_fixture
  250. # prefix.
  251. remainder = prefix_match.group('remainder')
  252. if remainder:
  253. self._parse_unity_fixture_remainder(
  254. prefix_match, remainder, found_test_stats
  255. )
  256. # Handle any subsequent lines with more information on the same test case.
  257. while not _UNITY_FIXTURE_VERBOSE_PREFIX_REGEX.search(line):
  258. self._parse_unity_fixture_remainder(
  259. prefix_match, line, found_test_stats
  260. )
  261. line = next(line_iter)
  262. except StopIteration:
  263. pass
  264. if len(self._tests) == 0:
  265. raise ValueError('No tests were found.')
  266. if found_test_stats != self._test_stats:
  267. raise ValueError('Test output does not match summary block.')
  268. def _parse_unity_fixture_remainder(self, prefix_match, remainder, test_stats):
  269. """
  270. Parse the remainder of a Unity Fixture test case.
  271. Can be on the same line as the prefix or on subsequent lines.
  272. """
  273. new_test = None
  274. if remainder == ' PASS':
  275. new_test = TestResult(
  276. prefix_match.group('test_name'),
  277. 'PASS',
  278. group=prefix_match.group('test_group'),
  279. full_line=prefix_match.group(0),
  280. )
  281. else:
  282. remainder_match = _UNITY_FIXTURE_REMAINDER_REGEX.match(remainder)
  283. if remainder_match:
  284. new_test = TestResult(
  285. prefix_match.group('test_name'),
  286. remainder_match.group('result'),
  287. group=prefix_match.group('test_group'),
  288. file=remainder_match.group('file'),
  289. line=int(remainder_match.group('line')),
  290. message=remainder_match.group('message')
  291. if remainder_match.group('message') is not None
  292. else '',
  293. full_line=prefix_match.group('prefix') + remainder_match.group(0),
  294. )
  295. if new_test is not None:
  296. self._add_new_test(new_test, test_stats)
  297. def _add_new_test(self, new_test, test_stats):
  298. """Add a new test and increment the proper members of test_stats."""
  299. test_stats.total += 1
  300. if new_test.result() == 'PASS':
  301. test_stats.passed += 1
  302. elif new_test.result() == 'FAIL':
  303. test_stats.failed += 1
  304. else:
  305. test_stats.ignored += 1
  306. self._tests.append(new_test)