Skip to content

Commit

Permalink
Add test class detection, update test suite to exercise test class de…
Browse files Browse the repository at this point in the history
…tection, refine docs, black formatted.
  • Loading branch information
ntoll committed Sep 10, 2024
1 parent 4fdcc45 commit f49b2a4
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 57 deletions.
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ those who use PyTest, when using PyScript.
(This is demonstrated in the `main.py` file in this repository.)
4. The specification may be simply a string describing the directory in
which to start looking for test modules (e.g. `"./tests"`), or strings
representing the names of specific test modules / tests to run (of the
form: "module_path" or "module_path::test_function"; e.g.
`"tests/test_module.py"` or `"tests/test_module.py::test_stuff"`).
representing the names of specific test modules / test classes, tests to run
(of the form: "module_path", "module_path::TestClass" or
"module_path::test_function"; e.g. `"tests/test_module.py"`,
`"tests/test_module.py::TestClass"` or
`"tests/test_module.py::test_stuff"`).
5. If a named `pattern` argument is provided, it will be used to match test
modules in the specification for target directories. The default pattern is
"test_*.py".
Expand Down Expand Up @@ -109,6 +111,17 @@ Just like PyTest, use the `assert` statement to verify test expectations. As
shown above, a string following a comma is used as the value for any resulting
`AssertionError` should the `assert` fail.

If you need to group tests together within a test module, use a class
definition whose name starts with `Test` and whose test methods start with
`test_`:

```python
class TestClass:

def test_something(self):
assert True, "This will not fail"
```

Sometimes you need to skip existing tests. Simply use the `skip` decorator like
this:

Expand All @@ -122,15 +135,15 @@ def test_skipped():
```

The `skip` decorator takes an optional string to describe why the test function
is to be skipped. It also takes an optional `when` argument whose default value
is `True`. If `when` is false-y, the decorated test **will NOT be skipped**.
This is useful for conditional skipping of tests. E.g.:
is to be skipped. It also takes an optional `skip_when` argument whose default
value is `True`. If `skip_when` is false-y, the decorated test **will NOT be
skipped**. This is useful for conditional skipping of tests. E.g.:

```python
import upytest


@skip("Skip this if using MicroPython", when=upytest.is_micropython)
@skip("Skip this if using MicroPython", skip_when=upytest.is_micropython)
def test_something():
assert 1 == 1 # Only asserted if using Pyodide.
```
Expand Down
33 changes: 27 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@

expected_results = {
"result_all": {
"passes": 8,
"fails": 6,
"skipped": 4,
"passes": 11,
"fails": 9,
"skipped": 6,
},
"result_module": {
"passes": 7,
"fails": 6,
"skipped": 4,
"passes": 10,
"fails": 9,
"skipped": 6,
},
"result_class": {
"passes": 3,
"fails": 3,
"skipped": 2,
},
"result_specific": {
"passes": 1,
Expand All @@ -48,12 +53,20 @@

actual_results = {}
# Run all tests in the tests directory.
print("\033[1mRunning all tests in directory...\033[0m")
actual_results["result_all"] = await upytest.run("./tests")
# Run all tests in a specific module.
print("\n\n\033[1mRunning all tests in a specific module...\033[0m")
actual_results["result_module"] = await upytest.run(
"tests/test_core_functionality.py"
)
# Run all tests in a specific test class.
print("\n\n\033[1mRunning all tests in a specific class...\033[0m")
actual_results["result_class"] = await upytest.run(
"tests/test_core_functionality.py::TestClass"
)
# Run a specific test function.
print("\n\n\033[1mRun a specific function...\033[0m")
actual_results["result_specific"] = await upytest.run(
"tests/test_core_functionality.py::test_passes"
)
Expand Down Expand Up @@ -96,6 +109,14 @@
f" Skipped: {len(actual_results['result_module']['skipped'])}.",
),
),
div(
p(
b("Tests in a Specified Test Class: "),
f"Passes: {len(actual_results['result_class']['passes'])},"
f" Fails: {len(actual_results['result_class']['fails'])},"
f" Skipped: {len(actual_results['result_class']['skipped'])}.",
),
),
div(
p(
b("Test a Specific Test: "),
Expand Down
49 changes: 45 additions & 4 deletions tests/test_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def test_skipped():


@upytest.skip(
"This test will be skipped with a when condition", skip_when=True
"This test will be skipped with a skip_when condition", skip_when=True
)
def test_when_skipped():
"""
Expand All @@ -26,7 +26,7 @@ def test_when_skipped():


@upytest.skip(
"This test will NOT be skipped with a False-y when",
"This test will NOT be skipped with a False-y skip_when",
skip_when=False,
)
def test_when_not_skipped_passes():
Expand Down Expand Up @@ -90,6 +90,47 @@ def test_does_not_raise_expected_exception_fails():
raise TypeError("This is a TypeError")


class TestClass:
"""
A class based version of the above tests.
"""

@upytest.skip("This test will be skipped")
def test_skipped(self):
assert False # This will not be executed.

@upytest.skip(
"This test will be skipped with a skip_when condition", skip_when=True
)
def test_when_skipped(self):
assert False # This will not be executed.

@upytest.skip(
"This test will NOT be skipped with a False-y skip_when",
skip_when=False,
)
def test_when_not_skipped_passes(self):
assert True, "This test passes"

def test_passes(self):
assert True, "This test passes"

def test_fails(self):
assert False, "This test will fail"

def test_raises_exception_passes(self):
with upytest.raises(ValueError):
raise ValueError("This is a ValueError")

def test_does_not_raise_exception_fails(self):
with upytest.raises(ValueError):
pass

def test_does_not_raise_expected_exception_fails(self):
with upytest.raises(ValueError, AssertionError):
raise TypeError("This is a TypeError")


# Async versions of the above.


Expand All @@ -99,14 +140,14 @@ async def test_async_skipped():


@upytest.skip(
"This test will be skipped with a when condition", skip_when=True
"This test will be skipped with a skip_when condition", skip_when=True
)
async def test_async_when_skipped():
assert False # This will not be executed.


@upytest.skip(
"This test will NOT be skipped with a False-y when",
"This test will NOT be skipped with a False-y skip_when",
skip_when=False,
)
async def test_async_when_not_skipped_passes():
Expand Down
4 changes: 1 addition & 3 deletions tests/test_with_setup_teardown.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ async def setup():


async def teardown():
window.console.log(
"Teardown from async teardown function in module"
)
window.console.log("Teardown from async teardown function in module")


def test_with_local_setup_teardown_passes():
Expand Down
75 changes: 38 additions & 37 deletions upytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,7 @@ def import_module(module_path):
Import a module from a given file path, in a way that works with both
MicroPython and Pyodide.
"""
dotted_path = (
str(module_path).replace("/", ".").replace(".py", "")
)
dotted_path = str(module_path).replace("/", ".").replace(".py", "")
dotted_path = dotted_path.lstrip(".")
module = __import__(dotted_path)
for part in dotted_path.split(".")[1:]:
Expand Down Expand Up @@ -125,29 +123,28 @@ class TestCase:
Represents an individual test to run.
"""

def __init__(self, test_function, module_name, test_name):
def __init__(self, test_function, module_name, test_name, function_id):
"""
A TestCase is instantiated with a callable test_function, the name of
the module containing the test, and the name of the test within the
module.
the module containing the test, the name of the test within the module
and the unique Python id of the test function.
"""
self.test_function = test_function
self.module_name = module_name
self.test_name = test_name
self.function_id = function_id
self.status = PENDING # the initial state of the test.
self.traceback = None # to contain details of any failure.
self.reason = (
None # to contain the reason for skipping the test.
)
self.reason = None # to contain the reason for skipping the test.

async def run(self):
"""
Run the test function and set the status and traceback attributes, as
required.
"""
if id(self.test_function) in _SKIPPED_TESTS:
if self.function_id in _SKIPPED_TESTS:
self.status = SKIPPED
self.reason = _SKIPPED_TESTS.get(id(self.test_function))
self.reason = _SKIPPED_TESTS.get(self.function_id)
if not self.reason:
self.reason = "No reason given."
return
Expand Down Expand Up @@ -186,12 +183,28 @@ def __init__(self, path, module, setup=None, teardown=None):
for name, item in self.module.__dict__.items():
if callable(item) or is_awaitable(item):
if name.startswith("test"):
t = TestCase(item, self.path, name)
# A simple test function.
t = TestCase(item, self.path, name, id(item))
self._tests.append(t)
elif inspect.isclass(item) and name.startswith("Test"):
# A test class, so check for test methods.
instance = item()
for method_name, method in item.__dict__.items():
if callable(method) or is_awaitable(method):
if method_name.startswith("test"):
t = TestCase(
getattr(instance, method_name),
self.path,
f"{name}.{method_name}",
id(method),
)
self._tests.append(t)
elif name == "setup":
# A local setup function.
self._setup = item
local_setup_teardown = True
elif name == "teardown":
# A local teardown function.
self._teardown = item
local_setup_teardown = True
if local_setup_teardown:
Expand Down Expand Up @@ -223,10 +236,14 @@ def teardown(self):

def limit_tests_to(self, test_names):
"""
Limit the tests run to the provided test_names list of names.
Limit the tests run to the provided test_names list of names of test
functions or test classes.
"""
self._tests = [
t for t in self._tests if t.test_name in test_names
t
for t in self._tests
if (t.test_name in test_names)
or (t.test_name.split(".")[0] in test_names)
]

async def run(self):
Expand Down Expand Up @@ -270,11 +287,7 @@ def gather_conftest_functions(conftest_path, target):
)
conftest = import_module(conftest_path)
setup = conftest.setup if hasattr(conftest, "setup") else None
teardown = (
conftest.teardown
if hasattr(conftest, "teardown")
else None
)
teardown = conftest.teardown if hasattr(conftest, "teardown") else None
return setup, teardown
return None, None

Expand Down Expand Up @@ -302,24 +315,16 @@ def discover(targets, pattern, setup=None, teardown=None):
result = []
for target in targets:
if "::" in target:
conftest_path = (
Path(target.split("::")[0]).parent / "conftest.py"
)
setup, teardown = gather_conftest_functions(
conftest_path, target
)
conftest_path = Path(target.split("::")[0]).parent / "conftest.py"
setup, teardown = gather_conftest_functions(conftest_path, target)
module_path, test_names = target.split("::")
module_instance = import_module(module_path)
module = TestModule(
module_path, module_instance, setup, teardown
)
module = TestModule(module_path, module_instance, setup, teardown)
module.limit_tests_to(test_names.split(","))
result.append(module)
elif os.path.isdir(target):
conftest_path = Path(target) / "conftest.py"
setup, teardown = gather_conftest_functions(
conftest_path, target
)
setup, teardown = gather_conftest_functions(conftest_path, target)
for module_path in Path(target).rglob(pattern):
module_instance = import_module(module_path)
module = TestModule(
Expand All @@ -328,13 +333,9 @@ def discover(targets, pattern, setup=None, teardown=None):
result.append(module)
else:
conftest_path = Path(target).parent / "conftest.py"
setup, teardown = gather_conftest_functions(
conftest_path, target
)
setup, teardown = gather_conftest_functions(conftest_path, target)
module_instance = import_module(target)
module = TestModule(
target, module_instance, setup, teardown
)
module = TestModule(target, module_instance, setup, teardown)
result.append(module)
return result

Expand Down

0 comments on commit f49b2a4

Please sign in to comment.