Skip to content

Commit f49b2a4

Browse files
committed
Add test class detection, update test suite to exercise test class detection, refine docs, black formatted.
1 parent 4fdcc45 commit f49b2a4

File tree

5 files changed

+131
-57
lines changed

5 files changed

+131
-57
lines changed

README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ those who use PyTest, when using PyScript.
5656
(This is demonstrated in the `main.py` file in this repository.)
5757
4. The specification may be simply a string describing the directory in
5858
which to start looking for test modules (e.g. `"./tests"`), or strings
59-
representing the names of specific test modules / tests to run (of the
60-
form: "module_path" or "module_path::test_function"; e.g.
61-
`"tests/test_module.py"` or `"tests/test_module.py::test_stuff"`).
59+
representing the names of specific test modules / test classes, tests to run
60+
(of the form: "module_path", "module_path::TestClass" or
61+
"module_path::test_function"; e.g. `"tests/test_module.py"`,
62+
`"tests/test_module.py::TestClass"` or
63+
`"tests/test_module.py::test_stuff"`).
6264
5. If a named `pattern` argument is provided, it will be used to match test
6365
modules in the specification for target directories. The default pattern is
6466
"test_*.py".
@@ -109,6 +111,17 @@ Just like PyTest, use the `assert` statement to verify test expectations. As
109111
shown above, a string following a comma is used as the value for any resulting
110112
`AssertionError` should the `assert` fail.
111113

114+
If you need to group tests together within a test module, use a class
115+
definition whose name starts with `Test` and whose test methods start with
116+
`test_`:
117+
118+
```python
119+
class TestClass:
120+
121+
def test_something(self):
122+
assert True, "This will not fail"
123+
```
124+
112125
Sometimes you need to skip existing tests. Simply use the `skip` decorator like
113126
this:
114127

@@ -122,15 +135,15 @@ def test_skipped():
122135
```
123136

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

129142
```python
130143
import upytest
131144

132145

133-
@skip("Skip this if using MicroPython", when=upytest.is_micropython)
146+
@skip("Skip this if using MicroPython", skip_when=upytest.is_micropython)
134147
def test_something():
135148
assert 1 == 1 # Only asserted if using Pyodide.
136149
```

main.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,19 @@
3030

3131
expected_results = {
3232
"result_all": {
33-
"passes": 8,
34-
"fails": 6,
35-
"skipped": 4,
33+
"passes": 11,
34+
"fails": 9,
35+
"skipped": 6,
3636
},
3737
"result_module": {
38-
"passes": 7,
39-
"fails": 6,
40-
"skipped": 4,
38+
"passes": 10,
39+
"fails": 9,
40+
"skipped": 6,
41+
},
42+
"result_class": {
43+
"passes": 3,
44+
"fails": 3,
45+
"skipped": 2,
4146
},
4247
"result_specific": {
4348
"passes": 1,
@@ -48,12 +53,20 @@
4853

4954
actual_results = {}
5055
# Run all tests in the tests directory.
56+
print("\033[1mRunning all tests in directory...\033[0m")
5157
actual_results["result_all"] = await upytest.run("./tests")
5258
# Run all tests in a specific module.
59+
print("\n\n\033[1mRunning all tests in a specific module...\033[0m")
5360
actual_results["result_module"] = await upytest.run(
5461
"tests/test_core_functionality.py"
5562
)
63+
# Run all tests in a specific test class.
64+
print("\n\n\033[1mRunning all tests in a specific class...\033[0m")
65+
actual_results["result_class"] = await upytest.run(
66+
"tests/test_core_functionality.py::TestClass"
67+
)
5668
# Run a specific test function.
69+
print("\n\n\033[1mRun a specific function...\033[0m")
5770
actual_results["result_specific"] = await upytest.run(
5871
"tests/test_core_functionality.py::test_passes"
5972
)
@@ -96,6 +109,14 @@
96109
f" Skipped: {len(actual_results['result_module']['skipped'])}.",
97110
),
98111
),
112+
div(
113+
p(
114+
b("Tests in a Specified Test Class: "),
115+
f"Passes: {len(actual_results['result_class']['passes'])},"
116+
f" Fails: {len(actual_results['result_class']['fails'])},"
117+
f" Skipped: {len(actual_results['result_class']['skipped'])}.",
118+
),
119+
),
99120
div(
100121
p(
101122
b("Test a Specific Test: "),

tests/test_core_functionality.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def test_skipped():
1515

1616

1717
@upytest.skip(
18-
"This test will be skipped with a when condition", skip_when=True
18+
"This test will be skipped with a skip_when condition", skip_when=True
1919
)
2020
def test_when_skipped():
2121
"""
@@ -26,7 +26,7 @@ def test_when_skipped():
2626

2727

2828
@upytest.skip(
29-
"This test will NOT be skipped with a False-y when",
29+
"This test will NOT be skipped with a False-y skip_when",
3030
skip_when=False,
3131
)
3232
def test_when_not_skipped_passes():
@@ -90,6 +90,47 @@ def test_does_not_raise_expected_exception_fails():
9090
raise TypeError("This is a TypeError")
9191

9292

93+
class TestClass:
94+
"""
95+
A class based version of the above tests.
96+
"""
97+
98+
@upytest.skip("This test will be skipped")
99+
def test_skipped(self):
100+
assert False # This will not be executed.
101+
102+
@upytest.skip(
103+
"This test will be skipped with a skip_when condition", skip_when=True
104+
)
105+
def test_when_skipped(self):
106+
assert False # This will not be executed.
107+
108+
@upytest.skip(
109+
"This test will NOT be skipped with a False-y skip_when",
110+
skip_when=False,
111+
)
112+
def test_when_not_skipped_passes(self):
113+
assert True, "This test passes"
114+
115+
def test_passes(self):
116+
assert True, "This test passes"
117+
118+
def test_fails(self):
119+
assert False, "This test will fail"
120+
121+
def test_raises_exception_passes(self):
122+
with upytest.raises(ValueError):
123+
raise ValueError("This is a ValueError")
124+
125+
def test_does_not_raise_exception_fails(self):
126+
with upytest.raises(ValueError):
127+
pass
128+
129+
def test_does_not_raise_expected_exception_fails(self):
130+
with upytest.raises(ValueError, AssertionError):
131+
raise TypeError("This is a TypeError")
132+
133+
93134
# Async versions of the above.
94135

95136

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

100141

101142
@upytest.skip(
102-
"This test will be skipped with a when condition", skip_when=True
143+
"This test will be skipped with a skip_when condition", skip_when=True
103144
)
104145
async def test_async_when_skipped():
105146
assert False # This will not be executed.
106147

107148

108149
@upytest.skip(
109-
"This test will NOT be skipped with a False-y when",
150+
"This test will NOT be skipped with a False-y skip_when",
110151
skip_when=False,
111152
)
112153
async def test_async_when_not_skipped_passes():

tests/test_with_setup_teardown.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@ async def setup():
1313

1414

1515
async def teardown():
16-
window.console.log(
17-
"Teardown from async teardown function in module"
18-
)
16+
window.console.log("Teardown from async teardown function in module")
1917

2018

2119
def test_with_local_setup_teardown_passes():

upytest.py

Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ def import_module(module_path):
8989
Import a module from a given file path, in a way that works with both
9090
MicroPython and Pyodide.
9191
"""
92-
dotted_path = (
93-
str(module_path).replace("/", ".").replace(".py", "")
94-
)
92+
dotted_path = str(module_path).replace("/", ".").replace(".py", "")
9593
dotted_path = dotted_path.lstrip(".")
9694
module = __import__(dotted_path)
9795
for part in dotted_path.split(".")[1:]:
@@ -125,29 +123,28 @@ class TestCase:
125123
Represents an individual test to run.
126124
"""
127125

128-
def __init__(self, test_function, module_name, test_name):
126+
def __init__(self, test_function, module_name, test_name, function_id):
129127
"""
130128
A TestCase is instantiated with a callable test_function, the name of
131-
the module containing the test, and the name of the test within the
132-
module.
129+
the module containing the test, the name of the test within the module
130+
and the unique Python id of the test function.
133131
"""
134132
self.test_function = test_function
135133
self.module_name = module_name
136134
self.test_name = test_name
135+
self.function_id = function_id
137136
self.status = PENDING # the initial state of the test.
138137
self.traceback = None # to contain details of any failure.
139-
self.reason = (
140-
None # to contain the reason for skipping the test.
141-
)
138+
self.reason = None # to contain the reason for skipping the test.
142139

143140
async def run(self):
144141
"""
145142
Run the test function and set the status and traceback attributes, as
146143
required.
147144
"""
148-
if id(self.test_function) in _SKIPPED_TESTS:
145+
if self.function_id in _SKIPPED_TESTS:
149146
self.status = SKIPPED
150-
self.reason = _SKIPPED_TESTS.get(id(self.test_function))
147+
self.reason = _SKIPPED_TESTS.get(self.function_id)
151148
if not self.reason:
152149
self.reason = "No reason given."
153150
return
@@ -186,12 +183,28 @@ def __init__(self, path, module, setup=None, teardown=None):
186183
for name, item in self.module.__dict__.items():
187184
if callable(item) or is_awaitable(item):
188185
if name.startswith("test"):
189-
t = TestCase(item, self.path, name)
186+
# A simple test function.
187+
t = TestCase(item, self.path, name, id(item))
190188
self._tests.append(t)
189+
elif inspect.isclass(item) and name.startswith("Test"):
190+
# A test class, so check for test methods.
191+
instance = item()
192+
for method_name, method in item.__dict__.items():
193+
if callable(method) or is_awaitable(method):
194+
if method_name.startswith("test"):
195+
t = TestCase(
196+
getattr(instance, method_name),
197+
self.path,
198+
f"{name}.{method_name}",
199+
id(method),
200+
)
201+
self._tests.append(t)
191202
elif name == "setup":
203+
# A local setup function.
192204
self._setup = item
193205
local_setup_teardown = True
194206
elif name == "teardown":
207+
# A local teardown function.
195208
self._teardown = item
196209
local_setup_teardown = True
197210
if local_setup_teardown:
@@ -223,10 +236,14 @@ def teardown(self):
223236

224237
def limit_tests_to(self, test_names):
225238
"""
226-
Limit the tests run to the provided test_names list of names.
239+
Limit the tests run to the provided test_names list of names of test
240+
functions or test classes.
227241
"""
228242
self._tests = [
229-
t for t in self._tests if t.test_name in test_names
243+
t
244+
for t in self._tests
245+
if (t.test_name in test_names)
246+
or (t.test_name.split(".")[0] in test_names)
230247
]
231248

232249
async def run(self):
@@ -270,11 +287,7 @@ def gather_conftest_functions(conftest_path, target):
270287
)
271288
conftest = import_module(conftest_path)
272289
setup = conftest.setup if hasattr(conftest, "setup") else None
273-
teardown = (
274-
conftest.teardown
275-
if hasattr(conftest, "teardown")
276-
else None
277-
)
290+
teardown = conftest.teardown if hasattr(conftest, "teardown") else None
278291
return setup, teardown
279292
return None, None
280293

@@ -302,24 +315,16 @@ def discover(targets, pattern, setup=None, teardown=None):
302315
result = []
303316
for target in targets:
304317
if "::" in target:
305-
conftest_path = (
306-
Path(target.split("::")[0]).parent / "conftest.py"
307-
)
308-
setup, teardown = gather_conftest_functions(
309-
conftest_path, target
310-
)
318+
conftest_path = Path(target.split("::")[0]).parent / "conftest.py"
319+
setup, teardown = gather_conftest_functions(conftest_path, target)
311320
module_path, test_names = target.split("::")
312321
module_instance = import_module(module_path)
313-
module = TestModule(
314-
module_path, module_instance, setup, teardown
315-
)
322+
module = TestModule(module_path, module_instance, setup, teardown)
316323
module.limit_tests_to(test_names.split(","))
317324
result.append(module)
318325
elif os.path.isdir(target):
319326
conftest_path = Path(target) / "conftest.py"
320-
setup, teardown = gather_conftest_functions(
321-
conftest_path, target
322-
)
327+
setup, teardown = gather_conftest_functions(conftest_path, target)
323328
for module_path in Path(target).rglob(pattern):
324329
module_instance = import_module(module_path)
325330
module = TestModule(
@@ -328,13 +333,9 @@ def discover(targets, pattern, setup=None, teardown=None):
328333
result.append(module)
329334
else:
330335
conftest_path = Path(target).parent / "conftest.py"
331-
setup, teardown = gather_conftest_functions(
332-
conftest_path, target
333-
)
336+
setup, teardown = gather_conftest_functions(conftest_path, target)
334337
module_instance = import_module(target)
335-
module = TestModule(
336-
target, module_instance, setup, teardown
337-
)
338+
module = TestModule(target, module_instance, setup, teardown)
338339
result.append(module)
339340
return result
340341

0 commit comments

Comments
 (0)