unity_test_parser.py 13 KB

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