diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..69fe9b2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,15 @@ +name: Test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.10', '3.11', '3.14.6'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m unittest discover -s tests -v diff --git a/codewars_unittest/test_result.py b/codewars_unittest/test_result.py index 5bf1116..104bc4e 100644 --- a/codewars_unittest/test_result.py +++ b/codewars_unittest/test_result.py @@ -53,6 +53,20 @@ def addFailure(self, test, err): ) super(CodewarsTestResult, self).addFailure(test, err) + def addSubTest(self, test, subtest, err): + # Keep unittest's own outcome bookkeeping (including failfast) exactly + # once. Successful subtests still produce the parent test's one pass. + super(CodewarsTestResult, self).addSubTest(test, subtest, err) + if err is None: + return + failure = issubclass(err[0], test.failureException) + marker = 'FAILED' if failure else 'ERROR' + self.stream.writeln('\n<{}::>Subtest {}'.format(marker, esc(str(subtest)))) + self.stream.writeln('\n'.format('Failure' if failure else 'Error') + + esc(''.join(traceback.format_exception_only(err[0], err[1])))) + self.stream.writeln('\n' + + esc(self._exc_info_to_string(err, subtest))) + # from unittest/result.py def _exc_info_to_string(self, err, test): exctype, value, tb = err diff --git a/tests/test_subtests.py b/tests/test_subtests.py new file mode 100644 index 0000000..8e0a3d4 --- /dev/null +++ b/tests/test_subtests.py @@ -0,0 +1,80 @@ +import io +import re +import unittest + +from codewars_unittest import CodewarsTestRunner + + +class TestSubtests(unittest.TestCase): + def run_case(self, body): + case = type('Example', (unittest.TestCase,), {'test_example': body}) + stream = io.StringIO() + result = CodewarsTestRunner(stream=stream).run( + unittest.defaultTestLoader.loadTestsFromTestCase(case)) + output = stream.getvalue() + markers = re.findall(r'^<(PASSED|FAILED|ERROR)::>', output, re.MULTILINE) + self.assertEqual(output.count(''), 1) + self.assertEqual(output.count(''), 2) + self.assertEqual(result.testsRun, 1) + return result, output, markers + + def test_failed_subtests_emit_failures_and_continue(self): + def body(case): + for number in (1, 2): + with case.subTest(number=number): + case.assertEqual(number, 0) + result, output, markers = self.run_case(body) + self.assertFalse(result.wasSuccessful()) + self.assertEqual(len(result.failures), 2) + self.assertEqual(markers, ['FAILED', 'FAILED']) + self.assertIn('number=1', output) + self.assertIn('number=2', output) + self.assertIn('', output) + + def test_error_subtest_emits_error(self): + def body(case): + with case.subTest(name='broken'): + raise ValueError('child error') + result, output, markers = self.run_case(body) + self.assertFalse(result.wasSuccessful()) + self.assertEqual(len(result.errors), 1) + self.assertEqual(markers, ['ERROR']) + self.assertIn('ValueError: child error', output) + + def test_successful_subtests_keep_one_parent_pass(self): + def body(case): + for number in (1, 2): + with case.subTest(number=number): + case.assertTrue(number) + result, _, markers = self.run_case(body) + self.assertTrue(result.wasSuccessful()) + self.assertEqual(markers, ['PASSED']) + + def test_nested_subtest_failure_is_not_duplicated(self): + def body(case): + with case.subTest(outer=1): + with case.subTest(inner=2): + case.fail('original failure') + result, output, markers = self.run_case(body) + self.assertEqual(len(result.failures), 1) + self.assertEqual(markers, ['FAILED']) + self.assertIn('outer=1', output) + self.assertIn('inner=2', output) + + def test_multiline_messages_cannot_add_protocol_events(self): + def body(case): + with case.subTest(name='café\nliteral'): + case.fail('message\nliteral') + _, output, markers = self.run_case(body) + self.assertEqual(markers, ['FAILED']) + self.assertIn('<:LF:>literal', output) + + def test_regular_tests_keep_existing_reporting(self): + result, _, markers = self.run_case(lambda case: case.assertEqual(1, 1)) + self.assertTrue(result.wasSuccessful()) + self.assertEqual(markers, ['PASSED']) + + def test_regular_failure_remains_failure(self): + result, _, markers = self.run_case(lambda case: case.assertEqual(1, 2)) + self.assertFalse(result.wasSuccessful()) + self.assertEqual(markers, ['FAILED'])