diff --git a/src/pytest_split/algorithms.py b/src/pytest_split/algorithms.py index d98b896..d452254 100644 --- a/src/pytest_split/algorithms.py +++ b/src/pytest_split/algorithms.py @@ -60,6 +60,7 @@ def least_duration(splits: int, items: "List[nodes.Item]", durations: "Dict[str, def duration_based_chunks(splits: int, items: "List[nodes.Item]", durations: "Dict[str, float]") -> "List[TestGroup]": """ Split tests into groups by runtime. + Ensures tests are split into non-overlapping groups. The original list of test items is split into groups by finding boundary indices i_0, i_1, i_2 and creating group_1 = items[0:i_0], group_2 = items[i_0, i_1], group_3 = items[i_1, i_2], ... @@ -80,15 +81,33 @@ def duration_based_chunks(splits: int, items: "List[nodes.Item]", durations: "Di duration: "List[float]" = [0 for i in range(splits)] group_idx = 0 - for item in items: - if duration[group_idx] >= time_per_group: + test_count = len(items) + for index, item in enumerate(items): + item_duration = tests_and_durations.pop(item) + + tests_left = test_count - index + groups_left = splits - group_idx + + if not selected[group_idx]: + # Add test to current group if group has no tests + pass + elif tests_left < groups_left: + # Make sure that we assign at least one test to each group group_idx += 1 + elif duration[group_idx] + item_duration > time_per_group: + if group_idx + 1 >= splits: + # Stay with group index if it's the final group in the split + pass + else: + # Otherwise, bump index to add to the next group + group_idx += 1 selected[group_idx].append(item) + duration[group_idx] += item_duration + for i in range(splits): if i != group_idx: deselected[i].append(item) - duration[group_idx] += tests_and_durations.pop(item) return [TestGroup(selected=selected[i], deselected=deselected[i], duration=duration[i]) for i in range(splits)] diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 0228b96..6142022 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -2,7 +2,7 @@ import pytest -from pytest_split.algorithms import Algorithms +from src.pytest_split.algorithms import Algorithms, duration_based_chunks item = namedtuple("item", "nodeid") @@ -11,7 +11,7 @@ class TestAlgorithms: @pytest.mark.parametrize("algo_name", Algorithms.names()) def test__split_test(self, algo_name): durations = {"a": 1, "b": 1, "c": 1} - items = [item(x) for x in durations.keys()] + items = [item(x) for x in durations] algo = Algorithms[algo_name].value first, second, third = algo(splits=3, items=items, durations=durations) @@ -83,3 +83,27 @@ def test__split_tests_calculates_avg_test_duration_only_on_present_tests(self, a expected_first, expected_second = expected assert first.selected == expected_first assert second.selected == expected_second + + def test_each_group_is_assigned_a_test_front_loaded(self): + item = namedtuple("item", "nodeid") + + durations = {"a": 100, "b": 1, "c": 1, "d": 1, "e": 1, "f": 1, "g": 1, "h": 1} + + items = [item(x) for x in ["a", "b", "c", "d", "e", "f", "g", "h"]] + + groups = duration_based_chunks(8, items, durations) + + for i in range(7): + assert groups[i].selected != [] + + def test_each_group_is_assigned_a_test_back_loaded(self): + item = namedtuple("item", "nodeid") + + durations = {"a": 1, "b": 1, "c": 1, "d": 1, "e": 1, "f": 1, "g": 1, "h": 100} + + items = [item(x) for x in ["a", "b", "c", "d", "e", "f", "g", "h"]] + + groups = duration_based_chunks(8, items, durations) + + for i in range(7): + assert groups[i].selected != [] diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 4123005..42c3729 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -61,20 +61,20 @@ class TestSplitToSuites: "least_duration", ["test_1", "test_2", "test_3", "test_4", "test_5", "test_6", "test_7", "test_8", "test_9", "test_10"], ), - (2, 1, "duration_based_chunks", ["test_1", "test_2", "test_3", "test_4", "test_5", "test_6", "test_7"]), - (2, 2, "duration_based_chunks", ["test_8", "test_9", "test_10"]), + (2, 1, "duration_based_chunks", ["test_1", "test_2", "test_3", "test_4", "test_5", "test_6"]), + (2, 2, "duration_based_chunks", ["test_7", "test_8", "test_9", "test_10"]), (2, 1, "least_duration", ["test_1", "test_3", "test_5", "test_7", "test_9"]), (2, 2, "least_duration", ["test_2", "test_4", "test_6", "test_8", "test_10"]), (3, 1, "duration_based_chunks", ["test_1", "test_2", "test_3", "test_4", "test_5"]), - (3, 2, "duration_based_chunks", ["test_6", "test_7", "test_8"]), - (3, 3, "duration_based_chunks", ["test_9", "test_10"]), + (3, 2, "duration_based_chunks", ["test_6", "test_7"]), + (3, 3, "duration_based_chunks", ["test_8", "test_9", "test_10"]), (3, 1, "least_duration", ["test_1", "test_4", "test_7", "test_10"]), (3, 2, "least_duration", ["test_2", "test_5", "test_8"]), (3, 3, "least_duration", ["test_3", "test_6", "test_9"]), - (4, 1, "duration_based_chunks", ["test_1", "test_2", "test_3", "test_4"]), - (4, 2, "duration_based_chunks", ["test_5", "test_6", "test_7"]), - (4, 3, "duration_based_chunks", ["test_8", "test_9"]), - (4, 4, "duration_based_chunks", ["test_10"]), + (4, 1, "duration_based_chunks", ["test_1", "test_2", "test_3"]), + (4, 2, "duration_based_chunks", ["test_4", "test_5"]), + (4, 3, "duration_based_chunks", ["test_6"]), + (4, 4, "duration_based_chunks", ["test_7", "test_8", "test_9", "test_10"]), (4, 1, "least_duration", ["test_1", "test_5", "test_9"]), (4, 2, "least_duration", ["test_2", "test_6", "test_10"]), (4, 3, "least_duration", ["test_3", "test_7"]), @@ -107,6 +107,7 @@ def test_it_splits(self, test_idx, splits, group, algo, expected, legacy_flag, e "--splitting-algorithm", algo, ) + result.assertoutcome(passed=len(expected)) assert _passed_test_names(result) == expected @@ -128,16 +129,16 @@ def test_it_adapts_splits_based_on_new_and_deleted_tests(self, example_suite, du json.dump(durations, f) result = example_suite.inline_run("--splits", "3", "--group", "1", "--durations-path", durations_path) - result.assertoutcome(passed=4) - assert _passed_test_names(result) == ["test_1", "test_2", "test_3", "test_4"] + result.assertoutcome(passed=3) + assert _passed_test_names(result) == ["test_1", "test_2", "test_3"] # 3 sec result = example_suite.inline_run("--splits", "3", "--group", "2", "--durations-path", durations_path) - result.assertoutcome(passed=3) - assert _passed_test_names(result) == ["test_5", "test_6", "test_7"] + result.assertoutcome(passed=1) + assert _passed_test_names(result) == ["test_4"] # 1 sec result = example_suite.inline_run("--splits", "3", "--group", "3", "--durations-path", durations_path) - result.assertoutcome(passed=3) - assert _passed_test_names(result) == ["test_8", "test_9", "test_10"] + result.assertoutcome(passed=6) + assert _passed_test_names(result) == ["test_5", "test_6", "test_7", "test_8", "test_9", "test_10"] # 3 sec def test_handles_case_of_no_durations_for_group(self, example_suite, durations_path): with open(durations_path, "w") as f: @@ -149,8 +150,8 @@ def test_handles_case_of_no_durations_for_group(self, example_suite, durations_p def test_it_splits_with_other_collect_hooks(self, testdir, durations_path): expected_tests_per_group = [ - ["test_1", "test_2", "test_3"], - ["test_4", "test_5"], + ["test_1", "test_2"], + ["test_3", "test_4", "test_5"], ] tests_to_run = "".join(f"@pytest.mark.mark_one\ndef test_{num}(): pass\n" for num in range(1, 6))