Skip to content

Commit 0120d94

Browse files
committed
unittest: Make implementation closer to CPython.
NOTE: This code is based on the CPython unittest implementation, and probably falls under the Python License. The main things I wanted to accomplish with this were: - Ensure that each test runs in its own `TestCase` instance + This makes it possible to move some per-test setup code from `setUp` to `__init__`. - Add `TestCase.run(test_result)` + In the CPython implementation, this method gives people a single place to add custom logic that can run _after_ the tests have completed, with access to the `unittest.TestResult` instance. This is useful, as it allows people to only output debug information on test failures. While here I ended up making the following changes: - Moved the test executing code into `TestCase` (and `_Outcome`), from `_run_suite`. + As part of this, I also migrated the exception handling logic to use `ucontextlib.contextmanager` - Generate nicer error messages for failures in class setup/teardown methods. - Removed `__test_result__` and `__current_test__` hidden variables - Migrate `TestCase.subTest` to use `ucontextlib.contextmanager` + This significantly simplifies the logic - Make subtest failures output show inline similar to CPython. Bugs fixed: - Allow `skip` decorator to work on bare functions - Stop `expectedFailure` decorator from masking non-assertion failures - Show correct test name when wrapped with `expectedFailure` decorator - Show correct test name when TestCase.runTest method exists - Exceptions in setUp/tearDown should show as test failures + Previously they went up the stack, likely causing the process to exit - Exceptions in class setUp/tearDown should show as test failures + Previously they went up the stack, likely causing the process to exit - Don't invoke properties with names that start with `test` - Non-AssertionError exceptions in subtests show up as "FAIL" instead of "ERROR". - Tests now execute in an explicit order + This makes it easier to write output tests for the unittest framework. We _may_ want to explicitly shuffle this order so that user's don't have cross-test dependencies. Signed-off-by: Greg Darke <micropython@me.tsukasa.au>
1 parent 8c85a44 commit 0120d94

6 files changed

Lines changed: 323 additions & 234 deletions

File tree

python-stdlib/unittest/manifest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
metadata(version="0.10.4")
22

3+
require("ucontextlib")
34
package("unittest")

python-stdlib/unittest/tests/helpers.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,19 @@ def _run_tests(suite: unittest.TestSuite) -> tuple[unittest.TestResult, str]:
1313
A tuple of (test_result, text_output)
1414
"""
1515
stdout = io.StringIO()
16-
tmp_stdout = unittest._stdout
17-
tmp_current_test = unittest.__current_test__
18-
tmp_test_result = unittest.__test_result__
19-
try:
20-
unittest._stdout = stdout
21-
result = unittest.TestResult()
22-
suite.run(result)
23-
return result, stdout.getvalue()
24-
finally:
25-
unittest._stdout = tmp_stdout
26-
unittest.__current_test__ = tmp_current_test
27-
unittest.__test_result__ = tmp_test_result
16+
result = unittest.TestResult(stream=stdout)
17+
suite.run(result)
18+
return result, stdout.getvalue()
2819

2920

3021
def run_tests_in_module(parent_test: unittest.TestCase, module) -> tuple[unittest.TestResult, str]:
31-
test_name, parent_suite_name = unittest.__current_test__
32-
parent_suite_name = f"{parent_suite_name[1:-1]}.{test_name}"
33-
suite = unittest.TestSuite(name=parent_suite_name)
22+
"""Runs all tests in the given module-like object.
23+
24+
Args:
25+
module: An object that can have its attributes listed with the `dir` function.
26+
"""
27+
_, parent_suite_name = parent_test._test_name
28+
suite = unittest.TestSuite(name=f"{parent_suite_name[1:-1]}.{parent_test._test_method_name}")
3429
suite._load_module(module)
3530
return _run_tests(suite)
3631

@@ -64,8 +59,7 @@ def convert(cls, test_result: unittest.TestResult):
6459

6560
class BaseTestCase(unittest.TestCase):
6661
def full_test_name(self):
67-
my_name, cls_name = unittest.__current_test__
68-
return f"{cls_name[1:-1]}.{my_name}"
62+
return f"{self.__module__}.{self.__class__.__name__}.{self._test_method_name}"
6963

7064
def assertTestResult(
7165
self,

python-stdlib/unittest/tests/test_basics.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,8 @@ def test_func_fail():
4949
result, output = helpers.run_tests_in_module(self, FakeModule)
5050
self.assertTestResult(result, testsRun=1, numFailures=0, numErrors=0, numSkipped=0)
5151
self.assertTrue(result.wasSuccessful())
52-
# FIXME: This should be "test_func_fail", but the existing
53-
# implementation pulls the wrong name
54-
self.assertEqual(output, f"test_exp_fail ({self.full_test_name()}) ... ok\n")
52+
self.assertEqual(output, f"test_func_fail ({self.full_test_name()}) ... ok\n")
5553

56-
@unittest.skip("expectedFailure incorrectly consumes all failure types")
5754
def test_bare_function__expect_failure__error(self):
5855
class FakeModule:
5956
@unittest.expectedFailure
@@ -65,7 +62,6 @@ def test_func_error():
6562
self.assertFalse(result.wasSuccessful())
6663
self.assertEqual(output, f"test_func_error ({self.full_test_name()}) ... ERROR\n")
6764

68-
@unittest.skip("expectedFailure incorrectly consumes the SkipTest exception")
6965
def test_bare_function__expect_failure__skip(self):
7066
class FakeModule:
7167
@unittest.expectedFailure
@@ -80,7 +76,6 @@ def test_func_error():
8076
output, f"test_func_error ({self.full_test_name()}) ... skipped: reason1\n"
8177
)
8278

83-
@unittest.skip("expectedFailure incorrectly consumes the SkipTest exception")
8479
def test_testcase__expect_failure__skip(self):
8580
class FakeModule:
8681
class Test(unittest.TestCase):
@@ -138,7 +133,6 @@ def test1(self):
138133
self.assertTestResult(result, testsRun=1, numFailures=0, numErrors=1, numSkipped=0)
139134
self.assertEqual(output, f"test1 ({self.full_test_name()}.Test) ... ERROR\n")
140135

141-
@unittest.skip("unittest framework incorrectly calls `Test.test3`")
142136
def test_only_calls_methods(self):
143137
class FakeModule:
144138
class Test(unittest.TestCase):
@@ -169,10 +163,6 @@ def test1(self):
169163
def runTest(self):
170164
pass
171165

172-
def __repr__(self):
173-
# FIXME: Remove this method, it should not be needed
174-
return "runTest"
175-
176166
result, output = helpers.run_tests_in_module(self, FakeModule)
177167
self.assertTestResult(result, testsRun=1, numFailures=0, numErrors=0, numSkipped=0)
178168
self.assertEqual(output, f"runTest ({self.full_test_name()}.Test) ... ok\n")
@@ -186,7 +176,6 @@ def test(self):
186176
with self.assertRaises(KeyboardInterrupt):
187177
helpers.run_tests_in_module(self, FakeModule)
188178

189-
@unittest.skip("unittest framework does not call `TestCase.run` method")
190179
def test_run_method_overridable(self):
191180
class FakeModule:
192181
class Test(unittest.TestCase):

python-stdlib/unittest/tests/test_setup.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -112,15 +112,14 @@ def test(self):
112112
result, output = helpers.run_tests_in_testcase(self, Test)
113113
self.assertTestResult(result, testsRun=2, numFailures=0, numErrors=0, numSkipped=0)
114114
self.assertTestMethodCountsEqual(
115-
Test, init=1, setUpClass=1, setUp=2, test=2, tearDown=2, tearDownClass=1
115+
Test, init=2, setUpClass=1, setUp=2, test=2, tearDown=2, tearDownClass=1
116116
)
117117
self.assertEqual(
118118
output,
119119
f"test ({self.full_test_name()}.Test) ... ok\n"
120120
f"test2 ({self.full_test_name()}.Test) ... ok\n",
121121
)
122122

123-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
124123
def test_setup_failure_skips_test(self):
125124
class Test(self.__class__.TracingTestCase):
126125
def setUp(self):
@@ -134,7 +133,6 @@ def setUp(self):
134133
)
135134
self.assertEqual(output, f"test ({self.full_test_name()}.Test) ... FAIL\n")
136135

137-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
138136
def test_setup_errors_skips_test(self):
139137
class Test(self.__class__.TracingTestCase):
140138
def setUp(self):
@@ -148,7 +146,6 @@ def setUp(self):
148146
)
149147
self.assertEqual(output, f"test ({self.full_test_name()}.Test) ... ERROR\n")
150148

151-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
152149
def test_setup_skiptest_skips_test(self):
153150
class Test(self.__class__.TracingTestCase):
154151
def setUp(self):
@@ -162,7 +159,6 @@ def setUp(self):
162159
)
163160
self.assertEqual(output, f"test ({self.full_test_name()}.Test) ... skipped: reason1\n")
164161

165-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
166162
def test_class_setup_failure_skips_test(self):
167163
class Test(self.__class__.TracingTestCase):
168164
@classmethod
@@ -171,13 +167,14 @@ def setUpClass(cls):
171167
raise AssertionError
172168

173169
result, output = helpers.run_tests_in_testcase(self, Test)
174-
self.assertTestResult(result, testsRun=0, numFailures=0, numErrors=1, numSkipped=0)
170+
# NOTE: CPython counts this as an error, but this is neither documented,
171+
# nor tested.
172+
self.assertTestResult(result, testsRun=0, numFailures=1, numErrors=0, numSkipped=0)
175173
self.assertTestMethodCountsEqual(
176174
Test, init=1, setUpClass=1, setUp=0, test=0, tearDown=0, tearDownClass=0
177175
)
178176
self.assertEqual(output, f"setUpClass ({self.full_test_name()}.Test) ... FAIL\n")
179177

180-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
181178
def test_class_setup_errors_skips_test(self):
182179
class Test(self.__class__.TracingTestCase):
183180
@classmethod
@@ -192,7 +189,6 @@ def setUpClass(cls):
192189
)
193190
self.assertEqual(output, f"setUpClass ({self.full_test_name()}.Test) ... ERROR\n")
194191

195-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
196192
def test_class_setup_skiptest_skips_test(self):
197193
class Test(self.__class__.TracingTestCase):
198194
@classmethod
@@ -209,7 +205,6 @@ def setUpClass(cls):
209205
output, f"setUpClass ({self.full_test_name()}.Test) ... skipped: reason1\n"
210206
)
211207

212-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
213208
def test_teardown_failure_is_failure(self):
214209
class Test(self.__class__.TracingTestCase):
215210
def tearDown(self):
@@ -223,7 +218,6 @@ def tearDown(self):
223218
)
224219
self.assertEqual(output, f"test ({self.full_test_name()}.Test) ... FAIL\n")
225220

226-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
227221
def test_teardown_errors_are_errors(self):
228222
class Test(self.__class__.TracingTestCase):
229223
def tearDown(self):
@@ -237,7 +231,6 @@ def tearDown(self):
237231
)
238232
self.assertEqual(output, f"test ({self.full_test_name()}.Test) ... ERROR\n")
239233

240-
@unittest.skip("Exceptions/Assertions in setUp are not caught")
241234
def test_teardown_skiptest_is_skip(self):
242235
class Test(self.__class__.TracingTestCase):
243236
def tearDown(self):
@@ -264,7 +257,7 @@ def test(self):
264257
Test.method_call_order,
265258
(
266259
"__init__",
267-
# NOTE: CPython calls __init__ here a second time
260+
"__init__",
268261
"setUpClass",
269262
"setUp",
270263
"test",

python-stdlib/unittest/tests/test_subtest.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ def test(self):
2121
result, output = helpers.run_tests_in_testcase(self, _Test)
2222
self.assertTestResult(result, testsRun=1, numFailures=2, numErrors=0, numSkipped=0)
2323
self.assertEqual(
24-
output, "test (test_subtest.Test.test_subtest_catches_failures._Test) ... FAIL\n"
24+
output,
25+
"test (test_subtest.Test.test_subtest_catches_failures._Test) ... \n"
26+
" test (test_subtest.Test.test_subtest_catches_failures._Test) (inner=1) ... FAIL\n"
27+
" test (test_subtest.Test.test_subtest_catches_failures._Test) (inner=2) ... FAIL\n",
2528
)
2629

2730
def test_subtest_catches_exceptions(self):
@@ -37,10 +40,11 @@ def test(self):
3740
self.assertEqual(result.testsRun, 1)
3841
self.assertEqual(len(result.failures), 0)
3942
self.assertEqual(len(result.errors), 2)
40-
# FIXME: unittest framework incorrectly prints the test `FAIL`ed, rather
41-
# than `ERROR`ed.
4243
self.assertEqual(
43-
output, "test (test_subtest.Test.test_subtest_catches_exceptions._Test) ... FAIL\n"
44+
output,
45+
"test (test_subtest.Test.test_subtest_catches_exceptions._Test) ... \n"
46+
" test (test_subtest.Test.test_subtest_catches_exceptions._Test) (inner=1) ... ERROR\n"
47+
" test (test_subtest.Test.test_subtest_catches_exceptions._Test) (inner=2) ... ERROR\n",
4448
)
4549

4650

0 commit comments

Comments
 (0)