Skip to content

Commit 50cb2f3

Browse files
committed
Fix handling of dependency markers pointing to tests with same name
- add performance test for dependency markers
1 parent 56432c0 commit 50cb2f3

File tree

10 files changed

+245
-51
lines changed

10 files changed

+245
-51
lines changed

.github/workflows/performance.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ jobs:
3737
run: |
3838
python -m pip install wheel
3939
python -m pip install pytest
40+
python -m pip install pytest-dependency
4041
- name: Run tests
4142
run: |
4243
python -m pytest -s perf_tests

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
## Unreleased
44

5+
### Fixes
6+
- fixed sorting of dependency markers that depend on an item with the same
7+
name in different modules
8+
59
### Infrastructure
6-
- add performance tests to prevent performance degradation
10+
- added performance tests to prevent performance degradation
711

812
## [Version 0.9.5](https://pypi.org/project/pytest-order/0.9.5/) (2021-02-16)
913
Introduces hierarchical ordering option and fixes ordering of session-scoped

perf_tests/test_dependencies.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import os
2+
import shutil
3+
from unittest import mock
4+
5+
import pytest
6+
7+
import pytest_order
8+
from perf_tests.util import TimedSorter
9+
from tests.utils import write_test
10+
11+
12+
@pytest.fixture
13+
def fixture_path_relative(tmpdir_factory):
14+
fixture_path = str(tmpdir_factory.mktemp("dep_perf_sparse"))
15+
for module_index in range(10):
16+
testname = os.path.join(
17+
fixture_path, "test_dep_perf{}.py".format(module_index))
18+
test_contents = """
19+
import pytest
20+
"""
21+
for i in range(40):
22+
test_contents += """
23+
@pytest.mark.dependency(depends=["test_{}"])
24+
def test_{}():
25+
assert True
26+
""".format(i + 50, i)
27+
for i in range(60):
28+
test_contents += """
29+
@pytest.mark.dependency
30+
def test_{}():
31+
assert True
32+
""".format(i + 40)
33+
write_test(testname, test_contents)
34+
yield fixture_path
35+
shutil.rmtree(fixture_path, ignore_errors=True)
36+
37+
38+
@mock.patch("pytest_order.Sorter", TimedSorter)
39+
def test_performance_dependency(fixture_path_relative):
40+
"""Test performance of dependency markers that point to tests without
41+
an order mark (same as for test_relative does for after markers)."""
42+
args = ["--order-dependencies", fixture_path_relative]
43+
TimedSorter.nr_marks = 400
44+
pytest.main(args, [pytest_order])
45+
assert TimedSorter.elapsed < 0.4

perf_tests/test_ordinal.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,6 @@ def test_{}():
3131
@mock.patch("pytest_order.Sorter", TimedSorter)
3232
def test_performance_ordinal(fixture_path_ordinal):
3333
args = [fixture_path_ordinal]
34+
TimedSorter.nr_marks = 1000
3435
pytest.main(args, [pytest_order])
3536
assert TimedSorter.elapsed < 0.02

perf_tests/test_relative.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,27 @@ def fixture_path_relative(tmpdir_factory):
1818
test_contents = """
1919
import pytest
2020
"""
21-
for i in range(90):
21+
for i in range(40):
2222
test_contents += """
2323
@pytest.mark.order(after="test_{}")
2424
def test_{}():
2525
assert True
26-
""".format(i + 10, i)
27-
for i in range(10):
26+
""".format(i + 50, i)
27+
for i in range(60):
2828
test_contents += """
2929
def test_{}():
3030
assert True
31-
""".format(i + 90)
31+
""".format(i + 40)
3232
write_test(testname, test_contents)
3333
yield fixture_path
3434
shutil.rmtree(fixture_path, ignore_errors=True)
3535

3636

3737
@mock.patch("pytest_order.Sorter", TimedSorter)
3838
def test_performance_relative(fixture_path_relative):
39+
"""Test performance of after markers that point to tests without
40+
an order mark (the usual case)."""
3941
args = [fixture_path_relative]
42+
TimedSorter.nr_marks = 400
4043
pytest.main(args, [pytest_order])
41-
assert TimedSorter.elapsed < 1.2
44+
assert TimedSorter.elapsed < 0.5

perf_tests/test_relative_dense.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import os
2+
import shutil
3+
from unittest import mock
4+
5+
import pytest
6+
7+
import pytest_order
8+
from perf_tests.util import TimedSorter
9+
from tests.utils import write_test
10+
11+
12+
@pytest.fixture
13+
def fixture_path_relative_dense(tmpdir_factory):
14+
fixture_path = str(tmpdir_factory.mktemp("relative_dense_perf"))
15+
for module_index in range(10):
16+
testname = os.path.join(
17+
fixture_path, "test_relative_dense_perf{}.py".format(module_index))
18+
test_contents = """
19+
import pytest
20+
"""
21+
for i in range(90):
22+
test_contents += """
23+
@pytest.mark.order(after="test_{}")
24+
def test_{}():
25+
assert True
26+
""".format(i + 10, i)
27+
for i in range(10):
28+
test_contents += """
29+
def test_{}():
30+
assert True
31+
""".format(i + 90)
32+
write_test(testname, test_contents)
33+
yield fixture_path
34+
shutil.rmtree(fixture_path, ignore_errors=True)
35+
36+
37+
@mock.patch("pytest_order.Sorter", TimedSorter)
38+
def test_performance_relative(fixture_path_relative_dense):
39+
"""Test performance of after markers that mostly point to tests with
40+
another order mark, so items are evaluated multiple times."""
41+
args = [fixture_path_relative_dense]
42+
TimedSorter.nr_marks = 900
43+
pytest.main(args, [pytest_order])
44+
assert TimedSorter.elapsed < 1.2

perf_tests/util.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
class TimedSorter(Sorter):
77
elapsed = 0
8+
nr_marks = 1000
89

910
def sort_items(self):
1011
self.__class__.elapsed = 0
1112
start_time = time.time()
1213
items = super().sort_items()
13-
self.__class__.elapsed += time.time() - start_time
14+
self.__class__.elapsed = ((time.time() - start_time)
15+
/ self.nr_marks * 1000)
1416
print("Time per test: {:.3f} ms".format(self.__class__.elapsed))
1517
return items

pytest_order/sorter.py

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -197,14 +197,25 @@ def show_unresolved_dep_items(self):
197197
sys.stdout.flush()
198198

199199

200-
def label_from_alias(alias_names, label, scope):
200+
def scope_from_name(name):
201+
if name == "module":
202+
return MODULE
203+
if name == "class":
204+
return CLASS
205+
return SESSION
206+
207+
208+
def label_from_alias(alias_names, label, scope, prefix):
201209
name = alias_names.get(label)
202-
if name or scope in ("session", "package"):
210+
if name or scope == SESSION:
203211
return name
212+
prefixed_label = prefix + "::" + label
213+
if prefixed_label in alias_names:
214+
return alias_names[prefixed_label]
204215
for alias in alias_names:
205216
if "::" in alias:
206217
name = alias.split("::", 1)[1]
207-
if scope == "class" and "::" in name:
218+
if scope == CLASS and "::" in name:
208219
name = name.split("::")[1]
209220
else:
210221
name = alias
@@ -220,10 +231,18 @@ def scoped_label(label, scope):
220231
if scope == CLASS:
221232
if "::" not in label:
222233
return label
223-
return label.split("::")[0]
234+
return label[:label.index("::")]
224235
return label
225236

226237

238+
def scoped_node_id(node_id, scope):
239+
if scope == MODULE:
240+
return node_id[:node_id.index("::")]
241+
if scope == CLASS:
242+
return node_id[:node_id.rindex("::")]
243+
return ""
244+
245+
227246
class GroupSorter:
228247
def __init__(self, scope, groups, before_items, after_items, dep_items):
229248
self.scope = scope
@@ -291,13 +310,13 @@ def handle_after_items(self):
291310

292311
def handle_dep_items(self):
293312
remove_labels = []
294-
for (label, dep_scope), after in self.dep_items.items():
313+
for (label, dep_scope, prefix), after in self.dep_items.items():
295314
group_index = self.group_index_from_label(
296-
label, dep_scope)
315+
label, dep_scope, prefix)
297316
if self.insert_after_dep_group(group_index, after):
298-
remove_labels.append((label, dep_scope))
299-
for (label, dep_scope) in remove_labels:
300-
del self.dep_items[(label, dep_scope)]
317+
remove_labels.append((label, dep_scope, prefix))
318+
for (label, dep_scope, prefix) in remove_labels:
319+
del self.dep_items[(label, dep_scope, prefix)]
301320

302321
def insert_before_group(self, label, items):
303322
label = scoped_label(label, self.scope)
@@ -351,9 +370,9 @@ def insert_after_dep_group(self, group_index, items):
351370
return found
352371
return False
353372

354-
def group_index_from_label(self, label, scope):
373+
def group_index_from_label(self, label, scope, prefix):
355374
for index, group in enumerate(self.groups):
356-
if label_from_alias(group.aliases, label, scope):
375+
if label_from_alias(group.aliases, label, scope, prefix):
357376
return index
358377

359378

@@ -439,9 +458,11 @@ def handle_dependency_mark(self, item, has_order):
439458
if self.settings.order_dependencies or has_order:
440459
dependent_mark = mark.kwargs.get("depends")
441460
if dependent_mark:
442-
scope = mark.kwargs.get("scope", "module")
461+
scope = scope_from_name(mark.kwargs.get("scope", "module"))
462+
prefix = scoped_node_id(item.node_id, scope)
443463
for name in dependent_mark:
444-
self.dep_items.setdefault((name, scope), []).append(item)
464+
self.dep_items.setdefault(
465+
(name, scope, prefix), []).append(item)
445466
item.is_rel_mark = True
446467
# we always collect the names of the dependent items, because
447468
# we need them in both cases
@@ -557,8 +578,9 @@ def handle_unhandled_items(self, items, out_items, scope):
557578

558579
def handle_unhandled_dep_items(self, out_items):
559580
msg = ""
560-
for (label, dep_scope), entries in self.dep_items.items():
561-
out_items.setdefault((label, dep_scope), []).extend(entries)
581+
for (label, dep_scope, prefix), entries in self.dep_items.items():
582+
out_items.setdefault(
583+
(label, dep_scope, prefix), []).extend(entries)
562584
return msg
563585

564586
def number_of_rel_groups(self):
@@ -585,8 +607,8 @@ def handle_after_items(self, sorted_list):
585607
def handle_dep_items(self, sorted_list):
586608
remove_labels = []
587609
nr_unhandled = 0
588-
for (label, dep_scope), after in self.dep_items.items():
589-
name = label_from_alias(self.aliases, label, dep_scope)
610+
for (label, dep_scope, prefix), after in self.dep_items.items():
611+
name = label_from_alias(self.aliases, label, dep_scope, prefix)
590612
if name is None:
591613
for item in after:
592614
if item.is_rel_mark:
@@ -595,9 +617,9 @@ def handle_dep_items(self, sorted_list):
595617
item.is_rel_mark = False
596618
nr_unhandled += 1
597619
elif self.insert_after(name, after, sorted_list):
598-
remove_labels.append((label, dep_scope))
599-
for (label, dep_scope) in remove_labels:
600-
del self.dep_items[(label, dep_scope)]
620+
remove_labels.append((label, dep_scope, prefix))
621+
for (label, dep_scope, prefix) in remove_labels:
622+
del self.dep_items[(label, dep_scope, prefix)]
601623
return nr_unhandled
602624

603625
def group_order(self):

tests/test_dependency.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,27 @@ def test_f(self):
269269
]
270270

271271

272+
def test_class_scope_dependencies(item_names_for, order_dependencies):
273+
tests_content = """
274+
import pytest
275+
276+
class TestA:
277+
@pytest.mark.dependency(depends=["test_c"], scope='class')
278+
def test_a(self):
279+
assert True
280+
281+
def test_b(self):
282+
assert True
283+
284+
@pytest.mark.dependency
285+
def test_c(self):
286+
assert True
287+
"""
288+
assert item_names_for(tests_content) == [
289+
"test_b", "test_c", "test_a"
290+
]
291+
292+
272293
@pytest.fixture
273294
def fixture_path_named(tmpdir_factory):
274295
fixture_path = str(tmpdir_factory.mktemp("named_dep"))
@@ -369,6 +390,56 @@ def test_dependency_in_modules(fixture_path_unnamed, capsys):
369390
assert "SKIPPED" not in out
370391

371392

393+
@pytest.fixture
394+
def fixture_path_modules_with_same_dep(tmpdir_factory):
395+
fixture_path = str(tmpdir_factory.mktemp("modules_dep"))
396+
testname = os.path.join(fixture_path, "test_module_dep1.py")
397+
test_contents = """
398+
import pytest
399+
400+
@pytest.mark.dependency(depends=['test_two'])
401+
def test_one():
402+
assert True
403+
404+
@pytest.mark.dependency
405+
def test_two():
406+
assert True
407+
"""
408+
write_test(testname, test_contents)
409+
test_contents = """
410+
import pytest
411+
412+
@pytest.mark.dependency(depends=['test_two'])
413+
def test_one():
414+
assert True
415+
416+
@pytest.mark.dependency
417+
def test_two():
418+
assert True
419+
"""
420+
testname = os.path.join(fixture_path, "test_module_dep2.py")
421+
write_test(testname, test_contents)
422+
yield fixture_path
423+
shutil.rmtree(fixture_path, ignore_errors=True)
424+
425+
426+
def test_same_dependency_in_modules(
427+
fixture_path_modules_with_same_dep, capsys):
428+
# regression test - make sure that the same dependency in different
429+
# modules works correctly
430+
args = ["-v", "--order-dependencies", fixture_path_modules_with_same_dep]
431+
pytest.main(args, [pytest_order])
432+
out, err = capsys.readouterr()
433+
expected = (
434+
"test_module_dep1.py::test_two",
435+
"test_module_dep1.py::test_one",
436+
"test_module_dep2.py::test_two",
437+
"test_module_dep2.py::test_one",
438+
)
439+
assert_test_order(expected, out)
440+
assert "SKIPPED" not in out
441+
442+
372443
def test_unknown_dependency(item_names_for, order_dependencies, capsys):
373444
tests_content = """
374445
import pytest

0 commit comments

Comments
 (0)