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 <jim.mussared@gmail.com>
pull/537/head
Jim Mussared 2022-09-10 01:18:24 +10:00
rodzic cb88a6a554
commit 796a5986cd
13 zmienionych plików z 290 dodań i 225 usunięć

Wyświetl plik

@ -0,0 +1,7 @@
metadata(version="0.1.0")
require("argparse")
require("fnmatch")
require("unittest")
module("unittest_discover.py")

Wyświetl plik

@ -0,0 +1,4 @@
# Module that is used in both test_isolated_1.py and test_isolated_2.py.
# The module should be clean reloaded for each.
state = None

Wyświetl plik

@ -0,0 +1,8 @@
import unittest
import isolated
class TestUnittestIsolated1(unittest.TestCase):
def test_NotChangedByOtherTest(self):
self.assertIsNone(isolated.state)
isolated.state = True

Wyświetl plik

@ -0,0 +1,8 @@
import unittest
import isolated
class TestUnittestIsolated2(unittest.TestCase):
def test_NotChangedByOtherTest(self):
self.assertIsNone(isolated.state)
isolated.state = True

Wyświetl plik

@ -0,0 +1,140 @@
# Extension for "unittest" that adds the ability to run via "micropython -m unittest".
import argparse
import os
import sys
from fnmatch import fnmatch
from micropython import const
from unittest import TestRunner, TestResult, TestSuite
# Run a single test in a clean environment.
def _run_test_module(runner: TestRunner, module_name: str, *extra_paths: list[str]):
module_snapshot = {k: v for k, v in sys.modules.items()}
path_snapshot = sys.path[:]
try:
for path in reversed(extra_paths):
if path:
sys.path.insert(0, path)
module = __import__(module_name)
suite = TestSuite(module_name)
suite._load_module(module)
return runner.run(suite)
finally:
sys.path[:] = path_snapshot
sys.modules.clear()
sys.modules.update(module_snapshot)
_DIR_TYPE = const(0x4000)
def _run_all_in_dir(runner: TestRunner, path: str, pattern: str, top: str):
result = TestResult()
for fname, ftype, *_ in os.ilistdir(path):
if fname in ("..", "."):
continue
if ftype == _DIR_TYPE:
result += _run_all_in_dir(
runner=runner,
path="/".join((path, fname)),
pattern=pattern,
top=top,
)
if fnmatch(fname, pattern):
module_name = fname.rsplit(".", 1)[0]
result += _run_test_module(runner, module_name, path, top)
return result
# Implements discovery inspired by https://docs.python.org/3/library/unittest.html#test-discovery
def _discover(runner: TestRunner):
parser = argparse.ArgumentParser()
# parser.add_argument(
# "-v",
# "--verbose",
# action="store_true",
# help="Verbose output",
# )
parser.add_argument(
"-s",
"--start-directory",
dest="start",
default=".",
help="Directory to start discovery",
)
parser.add_argument(
"-p",
"--pattern ",
dest="pattern",
default="test*.py",
help="Pattern to match test files",
)
parser.add_argument(
"-t",
"--top-level-directory",
dest="top",
help="Top level directory of project (defaults to start directory)",
)
args = parser.parse_args(args=sys.argv[2:])
path = args.start
top = args.top or path
return _run_all_in_dir(
runner=runner,
path=path,
pattern=args.pattern,
top=top,
)
# TODO: Use os.path for path handling.
PATH_SEP = getattr(os, "sep", "/")
# foo/bar/x.y.z --> foo/bar, x.y
def _dirname_filename_no_ext(path):
# Workaround: The Windows port currently reports "/" for os.sep
# (and MicroPython doesn't have os.altsep). So for now just
# always work with os.sep (i.e. "/").
path = path.replace("\\", PATH_SEP)
split = path.rsplit(PATH_SEP, 1)
if len(split) == 1:
dirname, filename = "", split[0]
else:
dirname, filename = split
return dirname, filename.rsplit(".", 1)[0]
# This is called from unittest when __name__ == "__main__".
def discover_main():
failures = 0
runner = TestRunner()
if len(sys.argv) == 1 or (
len(sys.argv) >= 2
and _dirname_filename_no_ext(sys.argv[0])[1] == "unittest"
and sys.argv[1] == "discover"
):
# No args, or `python -m unittest discover ...`.
result = _discover(runner)
failures += result.failuresNum or result.errorsNum
else:
for test_spec in sys.argv[1:]:
try:
os.stat(test_spec)
# File exists, strip extension and import with its parent directory in sys.path.
dirname, module_name = _dirname_filename_no_ext(test_spec)
result = _run_test_module(runner, module_name, dirname)
except OSError:
# Not a file, treat as named module to import.
result = _run_test_module(runner, test_spec)
failures += result.failuresNum or result.errorsNum
# Terminate with non zero return code in case of failures.
sys.exit(failures)

Wyświetl plik

@ -0,0 +1,3 @@
metadata(version="0.10.0")
module("unittest.py")

Wyświetl plik

@ -1,5 +1,4 @@
import unittest
from test_unittest_isolated import global_context
class TestUnittestAssertions(unittest.TestCase):
@ -143,11 +142,6 @@ class TestUnittestAssertions(unittest.TestCase):
else:
self.fail("Unexpected success was not detected")
def test_NotChangedByOtherTest(self):
global global_context
assert global_context is None
global_context = True
def test_subtest_even(self):
"""
Test that numbers between 0 and 5 are all even.
@ -157,24 +151,5 @@ class TestUnittestAssertions(unittest.TestCase):
self.assertEqual(i % 2, 0)
class TestUnittestSetup(unittest.TestCase):
class_setup_var = 0
def setUpClass(self):
TestUnittestSetup.class_setup_var += 1
def tearDownClass(self):
# Not sure how to actually test this, but we can check (in the test case below)
# that it hasn't been run already at least.
TestUnittestSetup.class_setup_var = -1
def testSetUpTearDownClass_1(self):
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
def testSetUpTearDownClass_2(self):
# Test this twice, as if setUpClass() gets run like setUp() it would be run twice
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
if __name__ == "__main__":
unittest.main()

Wyświetl plik

@ -0,0 +1,29 @@
import unittest
class TestWithRunTest(unittest.TestCase):
run = False
def runTest(self):
TestWithRunTest.run = True
def testRunTest(self):
self.fail()
@staticmethod
def tearDownClass():
if not TestWithRunTest.run:
raise ValueError()
def test_func():
pass
@unittest.expectedFailure
def test_foo():
raise ValueError()
if __name__ == "__main__":
unittest.main()

Wyświetl plik

@ -0,0 +1,28 @@
import unittest
class TestUnittestSetup(unittest.TestCase):
class_setup_var = 0
@classmethod
def setUpClass(cls):
assert cls is TestUnittestSetup
TestUnittestSetup.class_setup_var += 1
@classmethod
def tearDownClass(cls):
assert cls is TestUnittestSetup
# Not sure how to actually test this, but we can check (in the test case below)
# that it hasn't been run already at least.
TestUnittestSetup.class_setup_var = -1
def testSetUpTearDownClass_1(self):
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
def testSetUpTearDownClass_2(self):
# Test this twice, as if setUpClass() gets run like setUp() it would be run twice
assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var
if __name__ == "__main__":
unittest.main()

Wyświetl plik

@ -1,22 +1,13 @@
import io
import os
import sys
import uos
try:
import io
import traceback
except ImportError:
import uio as io
traceback = None
def _snapshot_modules():
return {k: v for k, v in sys.modules.items()}
__modules__ = _snapshot_modules()
class SkipTest(Exception):
pass
@ -61,7 +52,7 @@ class SubtestContext:
detail = ", ".join(f"{k}={v}" for k, v in self.params.items())
test_details += (f" ({detail})",)
handle_test_exception(test_details, __test_result__, exc_info, False)
_handle_test_exception(test_details, __test_result__, exc_info, False)
# Suppress the exception as we've captured it above
return True
@ -258,9 +249,17 @@ class TestSuite:
def run(self, result):
for c in self._tests:
run_suite(c, result, self.name)
_run_suite(c, result, self.name)
return result
def _load_module(self, mod):
for tn in dir(mod):
c = getattr(mod, tn)
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase):
self.addTest(c)
elif tn.startswith("test") and callable(c):
self.addTest(c)
class TestRunner:
def run(self, suite: TestSuite):
@ -331,7 +330,7 @@ class TestResult:
return self
def capture_exc(exc, traceback):
def _capture_exc(exc, traceback):
buf = io.StringIO()
if hasattr(sys, "print_exception"):
sys.print_exception(exc, buf)
@ -340,12 +339,12 @@ def capture_exc(exc, traceback):
return buf.getvalue()
def handle_test_exception(
def _handle_test_exception(
current_test: tuple, test_result: TestResult, exc_info: tuple, verbose=True
):
exc = exc_info[1]
traceback = exc_info[2]
ex_str = capture_exc(exc, traceback)
ex_str = _capture_exc(exc, traceback)
if isinstance(exc, AssertionError):
test_result.failuresNum += 1
test_result.failures.append((current_test, ex_str))
@ -359,7 +358,7 @@ def handle_test_exception(
test_result._newFailures += 1
def run_suite(c, test_result: TestResult, suite_name=""):
def _run_suite(c, test_result: TestResult, suite_name=""):
if isinstance(c, TestSuite):
c.run(test_result)
return
@ -388,9 +387,7 @@ def run_suite(c, test_result: TestResult, suite_name=""):
try:
test_result._newFailures = 0
test_result.testsRun += 1
test_globals = dict(**globals())
test_globals["test_function"] = test_function
exec("test_function()", test_globals, test_globals)
test_function()
# No exception occurred, test passed
if test_result._newFailures:
print(" FAIL")
@ -402,7 +399,7 @@ def run_suite(c, test_result: TestResult, suite_name=""):
test_result.skippedNum += 1
test_result.skipped.append((name, c, reason))
except Exception as ex:
handle_test_exception(
_handle_test_exception(
current_test=(name, c), test_result=test_result, exc_info=sys.exc_info()
)
# Uncomment to investigate failure in detail
@ -417,102 +414,59 @@ def run_suite(c, test_result: TestResult, suite_name=""):
pass
set_up_class()
try:
if hasattr(o, "runTest"):
name = str(o)
run_one(o.runTest)
return
if hasattr(o, "runTest"):
name = str(o)
run_one(o.runTest)
return
for name in dir(o):
if name.startswith("test"):
m = getattr(o, name)
if not callable(m):
continue
run_one(m)
for name in dir(o):
if name.startswith("test"):
m = getattr(o, name)
if not callable(m):
continue
run_one(m)
if callable(o):
name = o.__name__
run_one(o)
tear_down_class()
if callable(o):
name = o.__name__
run_one(o)
finally:
tear_down_class()
return exceptions
def _test_cases(mod):
for tn in dir(mod):
c = getattr(mod, tn)
if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase):
yield c
elif tn.startswith("test_") and callable(c):
yield c
def run_module(runner, module, path, top):
if not module:
raise ValueError("Empty module name")
# Reset the python environment before running test
sys.modules.clear()
sys.modules.update(__modules__)
sys_path_initial = sys.path[:]
# Add script dir and top dir to import path
sys.path.insert(0, str(path))
if top:
sys.path.insert(1, top)
try:
suite = TestSuite(module)
m = __import__(module) if isinstance(module, str) else module
for c in _test_cases(m):
suite.addTest(c)
result = runner.run(suite)
return result
finally:
sys.path[:] = sys_path_initial
def discover(runner: TestRunner):
from unittest_discover import discover
global __modules__
__modules__ = _snapshot_modules()
return discover(runner=runner)
# This supports either:
#
# >>> import mytest
# >>> unitttest.main(mytest)
#
# >>> unittest.main("mytest")
#
# Or, a script that ends with:
# if __name__ == "__main__":
# unittest.main()
# e.g. run via `mpremote run mytest.py`
def main(module="__main__", testRunner=None):
if testRunner:
if isinstance(testRunner, type):
runner = testRunner()
else:
runner = testRunner
else:
runner = TestRunner()
if testRunner is None:
testRunner = TestRunner()
elif isinstance(testRunner, type):
testRunner = testRunner()
if len(sys.argv) <= 1:
result = discover(runner)
elif sys.argv[0].split(".")[0] == "unittest" and sys.argv[1] == "discover":
result = discover(runner)
else:
for test_spec in sys.argv[1:]:
try:
uos.stat(test_spec)
# test_spec is a local file, run it directly
if "/" in test_spec:
path, fname = test_spec.rsplit("/", 1)
else:
path, fname = ".", test_spec
modname = fname.rsplit(".", 1)[0]
result = run_module(runner, modname, path, None)
except OSError:
# Not a file, treat as import name
result = run_module(runner, test_spec, ".", None)
# Terminate with non zero return code in case of failures
sys.exit(result.failuresNum or result.errorsNum)
if isinstance(module, str):
module = __import__(module)
suite = TestSuite(module.__name__)
suite._load_module(module)
return testRunner.run(suite)
# Support `micropython -m unittest` (only useful if unitest-discover is
# installed).
if __name__ == "__main__":
main()
try:
# If unitest-discover is installed, use the main() provided there.
from unittest_discover import discover_main
discover_main()
except ImportError:
pass

Wyświetl plik

@ -1,6 +0,0 @@
metadata(version="0.9.0")
require("argparse")
require("fnmatch")
module("unittest.py")

Wyświetl plik

@ -1,15 +0,0 @@
import unittest
global_context = None
class TestUnittestIsolated(unittest.TestCase):
def test_NotChangedByOtherTest(self):
global global_context
assert global_context is None
global_context = True
if __name__ == "__main__":
unittest.main()

Wyświetl plik

@ -1,70 +0,0 @@
import argparse
import sys
import uos
from fnmatch import fnmatch
from unittest import TestRunner, TestResult, run_module
def discover(runner: TestRunner):
"""
Implements discover function inspired by https://docs.python.org/3/library/unittest.html#test-discovery
"""
parser = argparse.ArgumentParser()
# parser.add_argument(
# "-v",
# "--verbose",
# action="store_true",
# help="Verbose output",
# )
parser.add_argument(
"-s",
"--start-directory",
dest="start",
default=".",
help="Directory to start discovery",
)
parser.add_argument(
"-p",
"--pattern ",
dest="pattern",
default="test*.py",
help="Pattern to match test files",
)
parser.add_argument(
"-t",
"--top-level-directory",
dest="top",
help="Top level directory of project (defaults to start directory)",
)
args = parser.parse_args(args=sys.argv[2:])
path = args.start
top = args.top or path
return run_all_in_dir(
runner=runner,
path=path,
pattern=args.pattern,
top=top,
)
def run_all_in_dir(runner: TestRunner, path: str, pattern: str, top: str):
DIR_TYPE = 0x4000
result = TestResult()
for fname, type, *_ in uos.ilistdir(path):
if fname in ("..", "."):
continue
if type == DIR_TYPE:
result += run_all_in_dir(
runner=runner,
path="/".join((path, fname)),
pattern=pattern,
top=top,
)
if fnmatch(fname, pattern):
modname = fname[: fname.rfind(".")]
result += run_module(runner, modname, path, top)
return result