Skip to content

Commit 01418c0

Browse files
committed
assert returned value against expected result
1 parent b89d308 commit 01418c0

File tree

11 files changed

+246
-20
lines changed

11 files changed

+246
-20
lines changed

README.md

+29-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# lymbo
22

3-
`lymbo` is a test runner designed for large test suites.
3+
* **lymbo** is a test runner designed for large test suites and small scripts.
44

5-
The key features of `lymbo` are:
5+
The key features of **lymbo** are:
66

77
* Parallel execution by default
88
* Simplicity
@@ -11,7 +11,7 @@ The key features of `lymbo` are:
1111

1212
## Concetps
1313

14-
In `lymbo`, there are only two key concepts to understand: `test` and `resource`.
14+
In **lymbo**, there are only two key concepts to understand: `test` and `resource`.
1515

1616
### Test
1717

@@ -100,6 +100,31 @@ We can group the tests to ensure they run sequentially on the same worker. In th
100100
==== 5 tests in 2 groups
101101
```
102102

103+
For a very simple unit test, you can decorate the function you want to test and verify the returned value to assert the test outcome.
104+
105+
```python
106+
@lymbo.test(args(a=4, b=2), expected(2))
107+
@lymbo.test(args(a=9, b=2), expected=expected(4.5))
108+
@lymbo.test(args(a=9, b=0), expected=expected(ZeroDivisionError))
109+
def division(a, b):
110+
return a / b
111+
```
112+
113+
```
114+
(venv) ~/dev/lymbo$ lymbo examples/readme.py --filter=division
115+
** lymbo 0.3.0 (python 3.9.18) (Linux-5.15.0-122-generic-x86_64-with-glibc2.35) **
116+
==== collecting tests
117+
==== 3 tests in 3 groups
118+
==== running tests
119+
.P.P.P
120+
==== tests executed in 0 second
121+
==== results
122+
- examples/readme.py::division(a=4,b=2)->(value=2) [PASSED]
123+
- examples/readme.py::division(a=9,b=2)->(value=4.5) [PASSED]
124+
- examples/readme.py::division(a=9,b=0)->(value=ZeroDivisionError) [PASSED]
125+
==== 3 passed
126+
```
127+
103128
### Resource
104129

105130
A resource is a standard context manager.
@@ -216,7 +241,7 @@ usage: lymbo [-h] [--version] [--collect] [--groupby {GroupBy.NONE,GroupBy.MODUL
216241
[--filter FILTER]
217242
[PATH ...]
218243
219-
A test runner designed for large test suites.
244+
A test runner designed for large test suites and small scripts.
220245
221246
positional arguments:
222247
PATH Path(s) for test collection

examples/readme.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import time
44

55
import lymbo
6-
from lymbo import args, expand
6+
from lymbo import args, expand, expected
77
from lymbo import scope_global
88

99

@@ -12,6 +12,13 @@ def addition():
1212
assert 1 + 2 == 3, "Addition test failed: 1 + 2 did not equal 3"
1313

1414

15+
@lymbo.test(args(a=4, b=2), expected(2))
16+
@lymbo.test(args(a=9, b=2), expected=expected(4.5))
17+
@lymbo.test(args(a=9, b=0), expected=expected(ZeroDivisionError))
18+
def division(a, b):
19+
return a / b
20+
21+
1522
@lymbo.test(args(n=expand(1, 4, 9, 116)))
1623
def is_perfect_square(n):
1724
assert (

lymbo/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,19 @@
44

55
from lymbo.cm import args
66
from lymbo.cm import expand
7+
from lymbo.cm import expected
78
from lymbo.cm import test
89
from lymbo.resource_manager import scope_class
910
from lymbo.resource_manager import scope_function
1011
from lymbo.resource_manager import scope_global
1112
from lymbo.resource_manager import scope_module
1213

13-
__version__ = "0.2.2"
14+
__version__ = "0.3.0"
1415

1516
__all__ = [
1617
"args",
1718
"expand",
19+
"expected",
1820
"test",
1921
"scope_class",
2022
"scope_function",

lymbo/cm.py

+48-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import contextlib
2+
from dataclasses import dataclass
23
import os
4+
import re
5+
from typing import Any
6+
from typing import Type
7+
from typing import Optional
8+
from typing import Union
39

410
from lymbo.env import LYMBO_TEST_COLLECTION
511

612

713
@contextlib.contextmanager
8-
def test(args=None):
14+
def test(args=None, expected=None):
915
yield
1016

1117

@@ -49,6 +55,47 @@ def args(*args, **kwargs):
4955
return flattened_params
5056

5157

58+
@dataclass
59+
class ExpectedAssertion:
60+
value: Union[Type[Any], Any] = None
61+
match: Union[str, None] = None
62+
63+
def assert_(self, returned_value: Any) -> Optional[str]:
64+
"""
65+
Compare the value returned by a function with its expected value.
66+
67+
Return a description of the failure if they don't match, or None if they matched.
68+
"""
69+
failure: Optional[str] = None
70+
71+
if self.value:
72+
if isinstance(self.value, type):
73+
if type(returned_value) is not self.value:
74+
failure = f"Expected type {self.value.__name__}, but got type {type(returned_value).__name__}."
75+
else:
76+
if returned_value != self.value:
77+
failure = f"Expected value {self.value}, but got {returned_value}."
78+
79+
if not failure:
80+
if self.match:
81+
if not re.match(self.match, str(returned_value)):
82+
failure = f"Value '{returned_value}' does not match the expected pattern '{self.match}'."
83+
84+
return failure
85+
86+
87+
def expected(
88+
value: Union[Type[Any], Any, None] = None, match: Union[str, None] = None
89+
) -> ExpectedAssertion:
90+
"""
91+
Define what we expect the function to return.
92+
93+
- value: The expected type or object value. Can be None.
94+
- match: A regular expression to match. Can be None.
95+
"""
96+
return ExpectedAssertion(value, match)
97+
98+
5299
class ArgParams:
53100

54101
def __init__(self, *args):

lymbo/collect.py

+16-4
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,18 @@ def parse_body(
133133
if getattr(decorator.func.value, "id", None) == "lymbo":
134134

135135
args_call = None
136+
expected = None
136137

137138
if decorator.args:
138139
args_call = decorator.args[0]
139-
else:
140-
for kw in decorator.keywords:
141-
if kw.arg == "args":
142-
args_call = kw.value
140+
if len(decorator.args) == 2:
141+
expected = decorator.args[1]
142+
143+
for kw in decorator.keywords:
144+
if kw.arg == "args":
145+
args_call = kw.value
146+
if kw.arg == "expected":
147+
expected = kw.value
143148

144149
if args_call:
145150
flattened_args = eval_ast_call(
@@ -148,6 +153,12 @@ def parse_body(
148153
else:
149154
flattened_args = args()
150155

156+
expected_assertion = None
157+
if expected:
158+
expected_assertion = eval_ast_call(
159+
expected, global_vars, local_vars
160+
)
161+
151162
tests = []
152163
for f_args in flattened_args:
153164
tests.append(
@@ -158,6 +169,7 @@ def parse_body(
158169
item.name,
159170
f_args,
160171
classdef.name if classdef else None,
172+
expected_assertion,
161173
),
162174
]
163175
)

lymbo/config.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
def parse_args() -> argparse.Namespace:
1010

1111
parser = argparse.ArgumentParser(
12-
description="A test runner designed for large test suites."
12+
description="A test runner designed for large test suites and small scripts."
1313
)
1414

1515
parser.add_argument(

lymbo/item.py

+18-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from typing import Union
1717

1818
import lymbo
19+
from lymbo.cm import ExpectedAssertion
1920
from lymbo.env import LYMBO_REPORT_PATH
2021
from lymbo.env import LYMBO_TEST_SCOPE_CLASS
2122
from lymbo.env import LYMBO_TEST_SCOPE_FUNCTION
@@ -58,12 +59,14 @@ def __init__(
5859
fnc: str,
5960
parameters: tuple[tuple[Any], dict[str, Any]],
6061
cls: Optional[str],
62+
expected: Optional[ExpectedAssertion],
6163
):
6264
self.path = path
6365
self.asynchronous = asynchronous
6466
self.fnc = fnc
6567
self.parameters = parameters
6668
self.cls = cls
69+
self.expected: ExpectedAssertion = expected if expected else ExpectedAssertion()
6770

6871
md5 = hashlib.md5(str(self).encode()).hexdigest()
6972
timestamp = int(time.time() * 1000000)
@@ -101,7 +104,21 @@ def print_variable(variable):
101104
call.append(f"{k}={print_variable(v)}")
102105
s += ",".join(call)
103106
s += ")"
104-
107+
if self.expected.value or self.expected.match:
108+
s += "->("
109+
if self.expected.value:
110+
s += "value="
111+
if type(self.expected.value) is str:
112+
s += f'"{self.expected.value}"'
113+
elif isinstance(self.expected.value, type):
114+
s += f"{self.expected.value.__name__}"
115+
else:
116+
s += f"{self.expected.value}"
117+
if self.expected.value and self.expected.match:
118+
s += ", "
119+
if self.expected.match:
120+
s += f"match={self.expected.match}"
121+
s += ")"
105122
return s
106123

107124
def __repr__(self) -> str:

lymbo/run.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -161,14 +161,32 @@ def run_test(test_item: TestItem):
161161
try:
162162
test_item.start()
163163
if asynchronous:
164-
asyncio.run(test_function(*args, **kwargs))
164+
returned_value = asyncio.run(test_function(*args, **kwargs))
165165
else:
166-
test_function(*args, **kwargs)
167-
test_item.end()
168-
print(f"{color.GREEN}P{color.RESET}", end="", flush=True)
166+
returned_value = test_function(*args, **kwargs)
167+
failure = test_item.expected.assert_(returned_value)
168+
if failure:
169+
ex = AssertionError(failure)
170+
test_item.end(reason=ex)
171+
print(f"{color.RED}F{color.RESET}", end="", flush=True)
172+
else:
173+
test_item.end()
174+
print(f"{color.GREEN}P{color.RESET}", end="", flush=True)
169175
except AssertionError as ex:
170176
test_item.end(reason=ex)
171177
print(f"{color.RED}F{color.RESET}", end="", flush=True)
172178
except Exception as ex:
173-
test_item.end(reason=ex)
174-
print(f"{color.YELLOW}B{color.RESET}", end="", flush=True)
179+
if isinstance(test_item.expected.value, type) and issubclass(
180+
test_item.expected.value, BaseException
181+
):
182+
failure = test_item.expected.assert_(ex)
183+
if failure:
184+
ex = AssertionError(failure)
185+
test_item.end(reason=ex)
186+
print(f"{color.RED}F{color.RESET}", end="", flush=True)
187+
else:
188+
test_item.end()
189+
print(f"{color.GREEN}P{color.RESET}", end="", flush=True)
190+
else:
191+
test_item.end(reason=ex)
192+
print(f"{color.YELLOW}B{color.RESET}", end="", flush=True)

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ name = "lymbo"
77
authors = [
88
{name = "cle-b", email = "cle@tictac.pm"},
99
]
10-
description="A test runner designed for large test suites."
10+
description="A test runner designed for large test suites and small scripts."
1111
readme="README.md"
1212
requires-python = ">=3.9"
1313
license = {text = "Apache-2.0"}

tests/data_run/run_expected.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import lymbo
2+
from lymbo import args
3+
from lymbo import expected
4+
5+
6+
@lymbo.test(args(a=4, b=2), expected(2))
7+
def value_passed(a, b):
8+
return a / b
9+
10+
11+
@lymbo.test(args(a=4, b=2), expected(1))
12+
def value_failed(a, b):
13+
return a / b
14+
15+
16+
@lymbo.test(args(a=4, b=2), expected(1))
17+
def value_broken(a, b):
18+
raise NameError("boum")
19+
20+
21+
@lymbo.test(args(a=4, b=2), expected(float))
22+
def type_passed(a, b):
23+
return a / b
24+
25+
26+
@lymbo.test(args(a=4, b=2), expected(str))
27+
def type_failed(a, b):
28+
return a / b
29+
30+
31+
@lymbo.test(args(a=4, b=2), expected(int))
32+
def type_broken(a, b):
33+
raise NameError("boum")
34+
35+
36+
@lymbo.test(args(a=4, b=0), expected(ZeroDivisionError))
37+
def exception_passed(a, b):
38+
return a / b
39+
40+
41+
@lymbo.test(args(a=4, b=0), expected(NameError))
42+
def exception_failed(a, b):
43+
return a / b
44+
45+
46+
@lymbo.test(args(name="cle"), expected(match="H.* cle.*"))
47+
def match_passed(name):
48+
return "Hi cle!"
49+
50+
51+
@lymbo.test(args(name="cle"), expected(match="H.* cle.*"))
52+
def match_failed(name):
53+
return "Bonjour cle!"
54+
55+
56+
@lymbo.test(args(name="cle"), expected(match="H.* cle.*"))
57+
def match_broken(name):
58+
raise NameError("boum")

0 commit comments

Comments
 (0)