Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 796a598

Browse files
committedSep 12, 2022
unittest: Move back to python-stdlib.
In order to make this more suitable for non-unix ports, the discovery functionality is moved to a separate 'extension' module which can be optionally installed. Signed-off-by: Jim Mussared <[email protected]>
1 parent cb88a6a commit 796a598

File tree

13 files changed

+290
-225
lines changed

13 files changed

+290
-225
lines changed
 
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
metadata(version="0.1.0")
2+
3+
require("argparse")
4+
require("fnmatch")
5+
require("unittest")
6+
7+
module("unittest_discover.py")
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Module that is used in both test_isolated_1.py and test_isolated_2.py.
2+
# The module should be clean reloaded for each.
3+
4+
state = None
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import unittest
2+
import isolated
3+
4+
5+
class TestUnittestIsolated1(unittest.TestCase):
6+
def test_NotChangedByOtherTest(self):
7+
self.assertIsNone(isolated.state)
8+
isolated.state = True
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import unittest
2+
import isolated
3+
4+
5+
class TestUnittestIsolated2(unittest.TestCase):
6+
def test_NotChangedByOtherTest(self):
7+
self.assertIsNone(isolated.state)
8+
isolated.state = True
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Extension for "unittest" that adds the ability to run via "micropython -m unittest".
2+
3+
import argparse
4+
import os
5+
import sys
6+
from fnmatch import fnmatch
7+
from micropython import const
8+
9+
from unittest import TestRunner, TestResult, TestSuite
10+
11+
12+
# Run a single test in a clean environment.
13+
def _run_test_module(runner: TestRunner, module_name: str, *extra_paths: list[str]):
14+
module_snapshot = {k: v for k, v in sys.modules.items()}
15+
path_snapshot = sys.path[:]
16+
try:
17+
for path in reversed(extra_paths):
18+
if path:
19+
sys.path.insert(0, path)
20+
21+
module = __import__(module_name)
22+
suite = TestSuite(module_name)
23+
suite._load_module(module)
24+
return runner.run(suite)
25+
finally:
26+
sys.path[:] = path_snapshot
27+
sys.modules.clear()
28+
sys.modules.update(module_snapshot)
29+
30+
31+
_DIR_TYPE = const(0x4000)
32+
33+
34+
def _run_all_in_dir(runner: TestRunner, path: str, pattern: str, top: str):
35+
result = TestResult()
36+
for fname, ftype, *_ in os.ilistdir(path):
37+
if fname in ("..", "."):
38+
continue
39+
if ftype == _DIR_TYPE:
40+
result += _run_all_in_dir(
41+
runner=runner,
42+
path="/".join((path, fname)),
43+
pattern=pattern,
44+
top=top,
45+
)
46+
if fnmatch(fname, pattern):
47+
module_name = fname.rsplit(".", 1)[0]
48+
result += _run_test_module(runner, module_name, path, top)
49+
return result
50+
51+
52+
# Implements discovery inspired by https://docs.python.org/3/library/unittest.html#test-discovery
53+
def _discover(runner: TestRunner):
54+
parser = argparse.ArgumentParser()
55+
# parser.add_argument(
56+
# "-v",
57+
# "--verbose",
58+
# action="store_true",
59+
# help="Verbose output",
60+
# )
61+
parser.add_argument(
62+
"-s",
63+
"--start-directory",
64+
dest="start",
65+
default=".",
66+
help="Directory to start discovery",
67+
)
68+
parser.add_argument(
69+
"-p",
70+
"--pattern ",
71+
dest="pattern",
72+
default="test*.py",
73+
help="Pattern to match test files",
74+
)
75+
parser.add_argument(
76+
"-t",
77+
"--top-level-directory",
78+
dest="top",
79+
help="Top level directory of project (defaults to start directory)",
80+
)
81+
args = parser.parse_args(args=sys.argv[2:])
82+
83+
path = args.start
84+
top = args.top or path
85+
86+
return _run_all_in_dir(
87+
runner=runner,
88+
path=path,
89+
pattern=args.pattern,
90+
top=top,
91+
)
92+
93+
94+
# TODO: Use os.path for path handling.
95+
PATH_SEP = getattr(os, "sep", "/")
96+
97+
98+
# foo/bar/x.y.z --> foo/bar, x.y
99+
def _dirname_filename_no_ext(path):
100+
# Workaround: The Windows port currently reports "/" for os.sep
101+
# (and MicroPython doesn't have os.altsep). So for now just
102+
# always work with os.sep (i.e. "/").
103+
path = path.replace("\\", PATH_SEP)
104+
105+
split = path.rsplit(PATH_SEP, 1)
106+
if len(split) == 1:
107+
dirname, filename = "", split[0]
108+
else:
109+
dirname, filename = split
110+
return dirname, filename.rsplit(".", 1)[0]
111+
112+
113+
# This is called from unittest when __name__ == "__main__".
114+
def discover_main():
115+
failures = 0
116+
runner = TestRunner()
117+
118+
if len(sys.argv) == 1 or (
119+
len(sys.argv) >= 2
120+
and _dirname_filename_no_ext(sys.argv[0])[1] == "unittest"
121+
and sys.argv[1] == "discover"
122+
):
123+
# No args, or `python -m unittest discover ...`.
124+
result = _discover(runner)
125+
failures += result.failuresNum or result.errorsNum
126+
else:
127+
for test_spec in sys.argv[1:]:
128+
try:
129+
os.stat(test_spec)
130+
# File exists, strip extension and import with its parent directory in sys.path.
131+
dirname, module_name = _dirname_filename_no_ext(test_spec)
132+
result = _run_test_module(runner, module_name, dirname)
133+
except OSError:
134+
# Not a file, treat as named module to import.
135+
result = _run_test_module(runner, test_spec)
136+
137+
failures += result.failuresNum or result.errorsNum
138+
139+
# Terminate with non zero return code in case of failures.
140+
sys.exit(failures)

‎python-stdlib/unittest/manifest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
metadata(version="0.10.0")
2+
3+
module("unittest.py")

‎unix-ffi/unittest/test_unittest.py renamed to ‎python-stdlib/unittest/tests/test_assertions.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import unittest
2-
from test_unittest_isolated import global_context
32

43

54
class TestUnittestAssertions(unittest.TestCase):
@@ -143,11 +142,6 @@ def testInner():
143142
else:
144143
self.fail("Unexpected success was not detected")
145144

146-
def test_NotChangedByOtherTest(self):
147-
global global_context
148-
assert global_context is None
149-
global_context = True
150-
151145
def test_subtest_even(self):
152146
"""
153147
Test that numbers between 0 and 5 are all even.
@@ -157,24 +151,5 @@ def test_subtest_even(self):
157151
self.assertEqual(i % 2, 0)
158152

159153

160-
class TestUnittestSetup(unittest.TestCase):
161-
class_setup_var = 0
162-
163-
def setUpClass(self):
164-
TestUnittestSetup.class_setup_var += 1
165-
166-
def tearDownClass(self):
167-
# Not sure how to actually test this, but we can check (in the test case below)
168-
# that it hasn't been run already at least.
169-
TestUnittestSetup.class_setup_var = -1
170-
171-
def testSetUpTearDownClass_1(self):
172-
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
173-
174-
def testSetUpTearDownClass_2(self):
175-
# Test this twice, as if setUpClass() gets run like setUp() it would be run twice
176-
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
177-
178-
179154
if __name__ == "__main__":
180155
unittest.main()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import unittest
2+
3+
4+
class TestWithRunTest(unittest.TestCase):
5+
run = False
6+
7+
def runTest(self):
8+
TestWithRunTest.run = True
9+
10+
def testRunTest(self):
11+
self.fail()
12+
13+
@staticmethod
14+
def tearDownClass():
15+
if not TestWithRunTest.run:
16+
raise ValueError()
17+
18+
19+
def test_func():
20+
pass
21+
22+
23+
@unittest.expectedFailure
24+
def test_foo():
25+
raise ValueError()
26+
27+
28+
if __name__ == "__main__":
29+
unittest.main()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import unittest
2+
3+
4+
class TestUnittestSetup(unittest.TestCase):
5+
class_setup_var = 0
6+
7+
@classmethod
8+
def setUpClass(cls):
9+
assert cls is TestUnittestSetup
10+
TestUnittestSetup.class_setup_var += 1
11+
12+
@classmethod
13+
def tearDownClass(cls):
14+
assert cls is TestUnittestSetup
15+
# Not sure how to actually test this, but we can check (in the test case below)
16+
# that it hasn't been run already at least.
17+
TestUnittestSetup.class_setup_var = -1
18+
19+
def testSetUpTearDownClass_1(self):
20+
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
21+
22+
def testSetUpTearDownClass_2(self):
23+
# Test this twice, as if setUpClass() gets run like setUp() it would be run twice
24+
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
25+
26+
27+
if __name__ == "__main__":
28+
unittest.main()

‎unix-ffi/unittest/unittest.py renamed to ‎python-stdlib/unittest/unittest.py

Lines changed: 63 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,13 @@
1+
import io
2+
import os
13
import sys
2-
import uos
34

45
try:
5-
import io
66
import traceback
77
except ImportError:
8-
import uio as io
9-
108
traceback = None
119

1210

13-
def _snapshot_modules():
14-
return {k: v for k, v in sys.modules.items()}
15-
16-
17-
__modules__ = _snapshot_modules()
18-
19-
2011
class SkipTest(Exception):
2112
pass
2213

@@ -61,7 +52,7 @@ def __exit__(self, *exc_info):
6152
detail = ", ".join(f"{k}={v}" for k, v in self.params.items())
6253
test_details += (f" ({detail})",)
6354

64-
handle_test_exception(test_details, __test_result__, exc_info, False)
55+
_handle_test_exception(test_details, __test_result__, exc_info, False)
6556
# Suppress the exception as we've captured it above
6657
return True
6758

@@ -258,9 +249,17 @@ def addTest(self, cls):
258249

259250
def run(self, result):
260251
for c in self._tests:
261-
run_suite(c, result, self.name)
252+
_run_suite(c, result, self.name)
262253
return result
263254

255+
def _load_module(self, mod):
256+
for tn in dir(mod):
257+
c = getattr(mod, tn)
258+
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase):
259+
self.addTest(c)
260+
elif tn.startswith("test") and callable(c):
261+
self.addTest(c)
262+
264263

265264
class TestRunner:
266265
def run(self, suite: TestSuite):
@@ -331,7 +330,7 @@ def __add__(self, other):
331330
return self
332331

333332

334-
def capture_exc(exc, traceback):
333+
def _capture_exc(exc, traceback):
335334
buf = io.StringIO()
336335
if hasattr(sys, "print_exception"):
337336
sys.print_exception(exc, buf)
@@ -340,12 +339,12 @@ def capture_exc(exc, traceback):
340339
return buf.getvalue()
341340

342341

343-
def handle_test_exception(
342+
def _handle_test_exception(
344343
current_test: tuple, test_result: TestResult, exc_info: tuple, verbose=True
345344
):
346345
exc = exc_info[1]
347346
traceback = exc_info[2]
348-
ex_str = capture_exc(exc, traceback)
347+
ex_str = _capture_exc(exc, traceback)
349348
if isinstance(exc, AssertionError):
350349
test_result.failuresNum += 1
351350
test_result.failures.append((current_test, ex_str))
@@ -359,7 +358,7 @@ def handle_test_exception(
359358
test_result._newFailures += 1
360359

361360

362-
def run_suite(c, test_result: TestResult, suite_name=""):
361+
def _run_suite(c, test_result: TestResult, suite_name=""):
363362
if isinstance(c, TestSuite):
364363
c.run(test_result)
365364
return
@@ -388,9 +387,7 @@ def run_one(test_function):
388387
try:
389388
test_result._newFailures = 0
390389
test_result.testsRun += 1
391-
test_globals = dict(**globals())
392-
test_globals["test_function"] = test_function
393-
exec("test_function()", test_globals, test_globals)
390+
test_function()
394391
# No exception occurred, test passed
395392
if test_result._newFailures:
396393
print(" FAIL")
@@ -402,7 +399,7 @@ def run_one(test_function):
402399
test_result.skippedNum += 1
403400
test_result.skipped.append((name, c, reason))
404401
except Exception as ex:
405-
handle_test_exception(
402+
_handle_test_exception(
406403
current_test=(name, c), test_result=test_result, exc_info=sys.exc_info()
407404
)
408405
# Uncomment to investigate failure in detail
@@ -417,102 +414,59 @@ def run_one(test_function):
417414
pass
418415

419416
set_up_class()
420-
421-
if hasattr(o, "runTest"):
422-
name = str(o)
423-
run_one(o.runTest)
424-
return
425-
426-
for name in dir(o):
427-
if name.startswith("test"):
428-
m = getattr(o, name)
429-
if not callable(m):
430-
continue
431-
run_one(m)
432-
433-
if callable(o):
434-
name = o.__name__
435-
run_one(o)
436-
437-
tear_down_class()
438-
439-
return exceptions
440-
441-
442-
def _test_cases(mod):
443-
for tn in dir(mod):
444-
c = getattr(mod, tn)
445-
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase):
446-
yield c
447-
elif tn.startswith("test_") and callable(c):
448-
yield c
449-
450-
451-
def run_module(runner, module, path, top):
452-
if not module:
453-
raise ValueError("Empty module name")
454-
455-
# Reset the python environment before running test
456-
sys.modules.clear()
457-
sys.modules.update(__modules__)
458-
459-
sys_path_initial = sys.path[:]
460-
# Add script dir and top dir to import path
461-
sys.path.insert(0, str(path))
462-
if top:
463-
sys.path.insert(1, top)
464417
try:
465-
suite = TestSuite(module)
466-
m = __import__(module) if isinstance(module, str) else module
467-
for c in _test_cases(m):
468-
suite.addTest(c)
469-
result = runner.run(suite)
470-
return result
471-
472-
finally:
473-
sys.path[:] = sys_path_initial
418+
if hasattr(o, "runTest"):
419+
name = str(o)
420+
run_one(o.runTest)
421+
return
474422

423+
for name in dir(o):
424+
if name.startswith("test"):
425+
m = getattr(o, name)
426+
if not callable(m):
427+
continue
428+
run_one(m)
475429

476-
def discover(runner: TestRunner):
477-
from unittest_discover import discover
430+
if callable(o):
431+
name = o.__name__
432+
run_one(o)
433+
finally:
434+
tear_down_class()
478435

479-
global __modules__
480-
__modules__ = _snapshot_modules()
481-
return discover(runner=runner)
436+
return exceptions
482437

483438

439+
# This supports either:
440+
#
441+
# >>> import mytest
442+
# >>> unitttest.main(mytest)
443+
#
444+
# >>> unittest.main("mytest")
445+
#
446+
# Or, a script that ends with:
447+
# if __name__ == "__main__":
448+
# unittest.main()
449+
# e.g. run via `mpremote run mytest.py`
484450
def main(module="__main__", testRunner=None):
485-
if testRunner:
486-
if isinstance(testRunner, type):
487-
runner = testRunner()
488-
else:
489-
runner = testRunner
490-
else:
491-
runner = TestRunner()
492-
493-
if len(sys.argv) <= 1:
494-
result = discover(runner)
495-
elif sys.argv[0].split(".")[0] == "unittest" and sys.argv[1] == "discover":
496-
result = discover(runner)
497-
else:
498-
for test_spec in sys.argv[1:]:
499-
try:
500-
uos.stat(test_spec)
501-
# test_spec is a local file, run it directly
502-
if "/" in test_spec:
503-
path, fname = test_spec.rsplit("/", 1)
504-
else:
505-
path, fname = ".", test_spec
506-
modname = fname.rsplit(".", 1)[0]
507-
result = run_module(runner, modname, path, None)
451+
if testRunner is None:
452+
testRunner = TestRunner()
453+
elif isinstance(testRunner, type):
454+
testRunner = testRunner()
508455

509-
except OSError:
510-
# Not a file, treat as import name
511-
result = run_module(runner, test_spec, ".", None)
512-
513-
# Terminate with non zero return code in case of failures
514-
sys.exit(result.failuresNum or result.errorsNum)
456+
if isinstance(module, str):
457+
module = __import__(module)
458+
suite = TestSuite(module.__name__)
459+
suite._load_module(module)
460+
return testRunner.run(suite)
515461

516462

463+
# Support `micropython -m unittest` (only useful if unitest-discover is
464+
# installed).
517465
if __name__ == "__main__":
518-
main()
466+
try:
467+
# If unitest-discover is installed, use the main() provided there.
468+
from unittest_discover import discover_main
469+
470+
discover_main()
471+
except ImportError:
472+
pass

‎unix-ffi/unittest/manifest.py

Lines changed: 0 additions & 6 deletions
This file was deleted.

‎unix-ffi/unittest/test_unittest_isolated.py

Lines changed: 0 additions & 15 deletions
This file was deleted.

‎unix-ffi/unittest/unittest_discover.py

Lines changed: 0 additions & 70 deletions
This file was deleted.

0 commit comments

Comments
 (0)
Please sign in to comment.