Skip to content

Commit 70e75e4

Browse files
committed
Split deterministically regardless of test order Fix #23
1 parent d16e618 commit 70e75e4

File tree

5 files changed

+55
-20
lines changed

5 files changed

+55
-20
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55

66
## [Unreleased]
77

8+
### Fixed
9+
- The `least_duration` algorithm should now split deterministically regardless of starting test order.
10+
This should fix the main problem when running with test-randomization packages such as `pytest-randomly` or `pytest-random-order`
11+
See #52
12+
813
## [0.7.0] - 2022-03-13
914
### Added
1015
- Support for pytest 7.x, see https://github.com/jerry-git/pytest-split/pull/47

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,20 @@ This is of course a fundamental problem in the suite itself but sometimes it's n
2727
Additionally, `pytest-split` may be a better fit in some use cases considering distributed execution.
2828

2929
## Installation
30-
```
30+
```sh
3131
pip install pytest-split
3232
```
3333

3434
## Usage
3535
First we have to store test durations from a complete test suite run.
3636
This produces .test_durations file which should be stored in the repo in order to have it available during future test runs.
3737
The file path is configurable via `--durations-path` CLI option.
38-
```
38+
```sh
3939
pytest --store-durations
4040
```
4141

4242
Then we can have as many splits as we want:
43-
```
43+
```sh
4444
pytest --splits 3 --group 1
4545
pytest --splits 3 --group 2
4646
pytest --splits 3 --group 3
@@ -59,7 +59,10 @@ Lists the slowest tests based on the information stored in the test durations fi
5959
information.
6060

6161
## Interactions with other pytest plugins
62-
* [`pytest-random-order`](https://github.com/jbasko/pytest-random-order): ⚠️ The **default settings** of that plugin (setting only `--random-order` to activate it) are **incompatible** with `pytest-split`. Test selection in the groups happens after randomization, potentially causing some tests to be selected in several groups and others not at all. Instead, a global random seed needs to be computed before running the tests (for example using `$RANDOM` from the shell) and that single seed then needs to be used for all groups by setting the `--random-order-seed` option.
62+
* [`pytest-random-order`](https://github.com/jbasko/pytest-random-order) and [`pytest-randomly`](https://github.com/pytest-dev/pytest-randomly):
63+
⚠️ `pytest-split` running with the `duration_based_chunks` algorithm is **incompatible** with test-order-randomization plugins.
64+
Test selection in the groups happens after randomization, potentially causing some tests to be selected in several groups and others not at all.
65+
Instead, a global random seed needs to be computed before running the tests (for example using `$RANDOM` from the shell) and that single seed then needs to be used for all groups by setting the `--random-order-seed` option.
6366

6467
* [`nbval`](https://github.com/computationalmodelling/nbval): `pytest-split` could, in principle, break up a single IPython Notebook into different test groups. This most likely causes broken up pieces to fail (for the very least, package `import`s are usually done at Cell 1, and so, any broken up piece that doesn't contain Cell 1 will certainly fail). To avoid this, after splitting step is done, test groups are reorganized based on a simple algorithm illustrated in the following cartoon:
6568

@@ -71,14 +74,15 @@ where the letters (A to E) refer to individual IPython Notebooks, and the number
7174
The plugin supports multiple algorithms to split tests into groups.
7275
Each algorithm makes different tradeoffs, but generally `least_duration` should give more balanced groups.
7376

74-
| Algorithm | Maintains Absolute Order | Maintains Relative Order | Split Quality |
75-
|----------------|--------------------------|--------------------------|---------------|
76-
| duration_based_chunks || | Good |
77-
| least_duration || | Better |
77+
| Algorithm | Maintains Absolute Order | Maintains Relative Order | Split Quality | Works with random ordering |
78+
|----------------|--------------------------|--------------------------|---------------|----------------------------|
79+
| duration_based_chunks ||| Good | |
80+
| least_duration ||| Better | |
7881

7982
Explanation of the terms in the table:
8083
* Absolute Order: whether each group contains all tests between first and last element in the same order as the original list of tests
8184
* Relative Order: whether each test in each group has the same relative order to its neighbours in the group as in the original list of tests
85+
* Works with random ordering: whether the algorithm works with test-shuffling tools such as [`pytest-randomly`](https://github.com/pytest-dev/pytest-randomly)
8286

8387
The `duration_based_chunks` algorithm aims to find optimal boundaries for the list of tests and every test group contains all tests between the start and end boundary.
8488
The `least_duration` algorithm walks the list of tests and assigns each test to the group with the smallest current duration.

src/pytest_split/algorithms.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,19 @@ def least_duration(
4141
(*tup, i) for i, tup in enumerate(items_with_durations)
4242
]
4343

44+
# Sort by name to ensure it's always the same order
45+
items_with_durations_indexed = sorted(
46+
items_with_durations_indexed, key=lambda tup: str(tup[0])
47+
)
48+
4449
# sort in ascending order
4550
sorted_items_with_durations = sorted(
4651
items_with_durations_indexed, key=lambda tup: tup[1], reverse=True
4752
)
4853

49-
selected: "List[List[Tuple[nodes.Item, int]]]" = [[] for i in range(splits)]
50-
deselected: "List[List[nodes.Item]]" = [[] for i in range(splits)]
51-
duration: "List[float]" = [0 for i in range(splits)]
54+
selected: "List[List[Tuple[nodes.Item, int]]]" = [[] for _ in range(splits)]
55+
deselected: "List[List[nodes.Item]]" = [[] for _ in range(splits)]
56+
duration: "List[float]" = [0 for _ in range(splits)]
5257

5358
# create a heap of the form (summed_durations, group_index)
5459
heap: "List[Tuple[float, int]]" = [(0, i) for i in range(splits)]

tests/test_algorithms.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
import itertools
12
from collections import namedtuple
3+
from typing import TYPE_CHECKING
24

35
import pytest
46

7+
if TYPE_CHECKING:
8+
from typing import List, Set
9+
from _pytest.nodes import Item
10+
511
from pytest_split.algorithms import Algorithms
612

713
item = namedtuple("item", "nodeid")
@@ -110,3 +116,18 @@ def test__split_tests_maintains_relative_order_of_tests(self, algo_name, expecte
110116
expected_first, expected_second = expected
111117
assert first.selected == expected_first
112118
assert second.selected == expected_second
119+
120+
def test__split_tests_same_set_regardless_of_order(self):
121+
"""NOTE: only least_duration does this correctly"""
122+
tests = ["a", "b", "c", "d", "e", "f", "g"]
123+
durations = {t: 1 for t in tests}
124+
items = [item(t) for t in tests]
125+
algo = Algorithms["least_duration"].value
126+
for n in (2, 3, 4):
127+
selected_each: "List[Set[Item]]" = [set() for _ in range(n)]
128+
for order in itertools.permutations(items):
129+
splits = algo(splits=n, items=order, durations=durations)
130+
for i, group in enumerate(splits):
131+
if not selected_each[i]:
132+
selected_each[i] = set(group.selected)
133+
assert selected_each[i] == set(group.selected)

tests/test_plugin.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ class TestSplitToSuites:
141141
["test_1", "test_2", "test_3", "test_4", "test_5", "test_6", "test_7"],
142142
),
143143
(2, 2, "duration_based_chunks", ["test_8", "test_9", "test_10"]),
144-
(2, 1, "least_duration", ["test_3", "test_5", "test_6", "test_8", "test_10"]),
145-
(2, 2, "least_duration", ["test_1", "test_2", "test_4", "test_7", "test_9"]),
144+
(2, 1, "least_duration", ["test_3", "test_5", "test_7", "test_9", "test_10"]),
145+
(2, 2, "least_duration", ["test_1", "test_2", "test_4", "test_6", "test_8"]),
146146
(
147147
3,
148148
1,
@@ -151,17 +151,17 @@ class TestSplitToSuites:
151151
),
152152
(3, 2, "duration_based_chunks", ["test_6", "test_7", "test_8"]),
153153
(3, 3, "duration_based_chunks", ["test_9", "test_10"]),
154-
(3, 1, "least_duration", ["test_3", "test_6", "test_9"]),
155-
(3, 2, "least_duration", ["test_4", "test_7", "test_10"]),
156-
(3, 3, "least_duration", ["test_1", "test_2", "test_5", "test_8"]),
154+
(3, 1, "least_duration", ["test_3", "test_8", "test_10"]),
155+
(3, 2, "least_duration", ["test_4", "test_6", "test_9"]),
156+
(3, 3, "least_duration", ["test_1", "test_2", "test_5", "test_7"]),
157157
(4, 1, "duration_based_chunks", ["test_1", "test_2", "test_3", "test_4"]),
158158
(4, 2, "duration_based_chunks", ["test_5", "test_6", "test_7"]),
159159
(4, 3, "duration_based_chunks", ["test_8", "test_9"]),
160160
(4, 4, "duration_based_chunks", ["test_10"]),
161-
(4, 1, "least_duration", ["test_6", "test_10"]),
162-
(4, 2, "least_duration", ["test_1", "test_4", "test_7"]),
163-
(4, 3, "least_duration", ["test_2", "test_5", "test_8"]),
164-
(4, 4, "least_duration", ["test_3", "test_9"]),
161+
(4, 1, "least_duration", ["test_9", "test_10"]),
162+
(4, 2, "least_duration", ["test_1", "test_4", "test_6"]),
163+
(4, 3, "least_duration", ["test_2", "test_5", "test_7"]),
164+
(4, 4, "least_duration", ["test_3", "test_8"]),
165165
]
166166
legacy_duration = [True, False]
167167
all_params = [

0 commit comments

Comments
 (0)