Skip to content

Commit bedb7d1

Browse files
committed
* Terminology: rename sections to buckets
* Implements bucket shuffling * Adds random-order-mode option "class" to use classes as buckets. * Flake8 cleanup * Adds TestCase class based tests to the tests
1 parent 47c1dc8 commit bedb7d1

File tree

8 files changed

+323
-194
lines changed

8 files changed

+323
-194
lines changed

.flake8

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[flake8]
2+
ignore = E501

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ Usage
2626

2727
$ pytest -v --random-order-mode=module
2828

29+
$ pytest -v --random-order-mode=class
30+
2931

3032
License
3133
-------

pytest_random_order.py

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,15 @@
22

33
import random
44

5-
import pytest
6-
75

86
def pytest_addoption(parser):
97
group = parser.getgroup('random-order')
10-
# group.addoption(
11-
# '--random-order-seed',
12-
# action='store',
13-
# type=int,
14-
# dest='random_order_seed',
15-
# default=None,
16-
# help='Seed value to reproduce a particular order',
17-
# )
188
group.addoption(
199
'--random-order-mode',
2010
action='store',
2111
dest='random_order_mode',
2212
default='module',
23-
choices=('global', 'package', 'module'),
13+
choices=('global', 'package', 'module', 'class'),
2414
help='Limit reordering of test items across units of code',
2515
)
2616

@@ -39,32 +29,52 @@ def pytest_report_header(config):
3929
'global': lambda x: None,
4030
'package': lambda x: x.module.__package__,
4131
'module': lambda x: x.module.__name__,
32+
'class': lambda x: (x.module.__name__, x.cls.__name__) if x.cls else None,
4233
}
4334

4435

45-
def pytest_collection_modifyitems(session, config, items):
46-
sections = []
36+
def _shuffle_items(items, key=None, preserve_bucket_order=False):
37+
"""
38+
Shuffles `items`, a list, in place.
4739
48-
# One of: global, package, module
49-
shuffle_mode = config.getoption('random_order_mode')
40+
If `key` is None, items are shuffled across the entire list.
41+
42+
Otherwise `key` is a function called for each item in `items` to
43+
calculate key of bucket in which the item falls.
44+
45+
Bucket defines the boundaries across which tests will not
46+
be reordered.
47+
48+
`preserve_bucket_order` is only customisable for testing purposes.
49+
There is no use case for predefined bucket order, is there?
50+
"""
51+
52+
# If `key` is falsey, shuffle is global.
53+
if not key:
54+
random.shuffle(items)
55+
return
5056

51-
item_key = _random_order_item_keys[shuffle_mode]
52-
53-
# Mark the beginning and ending of each key's test items in the items collection
54-
# so we know the boundaries of reshuffle.
55-
for i, item in enumerate(items):
56-
key = item_key(item)
57-
if not sections:
58-
assert i == 0
59-
sections.append([key, i, None])
60-
elif sections[-1][0] != key:
61-
sections[-1][2] = i
62-
sections.append([key, i, None])
63-
64-
if sections:
65-
sections[-1][2] = len(items)
66-
67-
for key, i, j in sections:
68-
key_items = items[i:j]
69-
random.shuffle(key_items)
70-
items[i:j] = key_items
57+
buckets = []
58+
this_key = '__not_initialised__'
59+
for item in items:
60+
prev_key = this_key
61+
this_key = key(item)
62+
if this_key != prev_key:
63+
buckets.append([])
64+
buckets[-1].append(item)
65+
66+
# Shuffle within bucket
67+
for bucket in buckets:
68+
random.shuffle(bucket)
69+
70+
# Shuffle buckets
71+
if not preserve_bucket_order:
72+
random.shuffle(buckets)
73+
74+
items[:] = [item for bucket in buckets for item in bucket]
75+
return
76+
77+
78+
def pytest_collection_modifyitems(session, config, items):
79+
shuffle_mode = config.getoption('random_order_mode')
80+
_shuffle_items(items, key=_random_order_item_keys[shuffle_mode])

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ def read(fname):
1313

1414
setup(
1515
name='pytest-random-order',
16-
version='0.1.0',
16+
version='0.2.0',
1717
author='Jazeps Basko',
1818
author_email='[email protected]',
1919
maintainer='Jazeps Basko',

tests/test_actual_test_runs.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# -*- coding: utf-8 -*-
2+
import collections
3+
4+
import py
5+
import pytest
6+
7+
8+
Call = collections.namedtuple('Call', field_names=('package', 'module', 'cls', 'name'))
9+
10+
11+
def get_runtest_call_sequence(result, key=None):
12+
"""
13+
Returns a tuple of names of test methods that were run
14+
in the order they were run.
15+
"""
16+
calls = []
17+
18+
for c in result.reprec.getcalls('pytest_runtest_call'):
19+
calls.append(Call(
20+
package=c.item.module.__package__,
21+
module=c.item.module.__name__,
22+
cls=(c.item.module.__name__, c.item.cls.__name__) if c.item.cls else None,
23+
name=c.item.name,
24+
))
25+
return tuple(calls)
26+
27+
28+
@pytest.fixture
29+
def tmp_tree_of_tests(testdir):
30+
"""
31+
Creates a directory structure:
32+
tmpdir/
33+
shallow_tests/
34+
test_a.py 2 passing, 1 failing
35+
deep_tests/
36+
test_b.py 2 passing, 1 failing
37+
test_c.py 1 passing
38+
test_d.py 2 passing
39+
test_e.py a class, 2 passing, 1 failing
40+
41+
If module name doesn't start with "test_", it isn't picked up by runpytest.
42+
"""
43+
44+
sup = testdir.mkpydir('shallow_tests')
45+
46+
sup.join('test_a.py').write(py.code.Source("""
47+
def test_a1():
48+
assert False
49+
def test_a2():
50+
assert True
51+
def test_a3():
52+
assert True
53+
"""))
54+
55+
sup.join('test_ax.py').write(py.code.Source("""
56+
def test_ax1():
57+
assert True
58+
def test_ax2():
59+
assert True
60+
def test_ax3():
61+
assert True
62+
"""))
63+
64+
sub = testdir.mkpydir('shallow_tests/deep_tests')
65+
66+
sub.join('test_b.py').write(py.code.Source("""
67+
def test_b1():
68+
assert True
69+
def test_b2():
70+
assert False
71+
def test_b3():
72+
assert True
73+
"""))
74+
75+
sub.join('test_c.py').write(py.code.Source("""
76+
def test_c1():
77+
assert True
78+
"""))
79+
80+
sub.join('test_d.py').write(py.code.Source("""
81+
def test_d1():
82+
assert True
83+
def test_d2():
84+
assert True
85+
"""))
86+
87+
sub.join('test_e.py').write(py.code.Source("""
88+
from unittest import TestCase
89+
class EeTest(TestCase):
90+
def test_ee1(self):
91+
self.assertTrue(True)
92+
def test_ee2(self):
93+
self.assertFalse(True)
94+
def test_ee3(self):
95+
self.assertTrue(True)
96+
97+
class ExTest(TestCase):
98+
def test_ex1(self):
99+
self.assertTrue(True)
100+
def test_ex2(self):
101+
self.assertTrue(True)
102+
"""))
103+
104+
return testdir
105+
106+
107+
def check_call_sequence(seq, shuffle_mode='module'):
108+
all_values = collections.defaultdict(list)
109+
num_switches = collections.defaultdict(int)
110+
111+
def inspect_attr(this_call, prev_call, attr_name):
112+
attr_value = getattr(this_call, attr_name)
113+
prev_value = getattr(prev_call, attr_name) if prev_call else -1
114+
all_values[attr_name].append(attr_value)
115+
if attr_value != prev_value:
116+
num_switches[attr_name] += 1
117+
118+
for i, this_call in enumerate(seq):
119+
prev_call = seq[i - 1] if i > 0 else None
120+
inspect_attr(this_call, prev_call, 'package')
121+
inspect_attr(this_call, prev_call, 'module')
122+
inspect_attr(this_call, prev_call, 'cls')
123+
124+
num_packages = len(set(all_values['package']))
125+
num_package_switches = num_switches['package']
126+
num_modules = len(set(all_values['module']))
127+
num_module_switches = num_switches['module']
128+
num_classes = len(set(all_values['class']))
129+
num_class_switches = num_switches['class']
130+
131+
# These are just sanity tests, the actual shuffling is tested in test_shuffle,
132+
# assertions here are very relaxed.
133+
134+
if shuffle_mode == 'global':
135+
if num_module_switches <= num_modules:
136+
pytest.fail('Too few module switches for global shuffling')
137+
if num_package_switches <= num_packages:
138+
pytest.fail('Too few package switches for global shuffling')
139+
140+
elif shuffle_mode == 'package':
141+
assert num_package_switches == num_packages
142+
if num_module_switches <= num_modules:
143+
pytest.fail('Too few module switches for package-limited shuffling')
144+
145+
elif shuffle_mode == 'module':
146+
assert num_module_switches == num_modules
147+
148+
elif shuffle_mode == 'cls':
149+
# Each class can contribute to 1 or 2 switches.
150+
assert num_class_switches <= num_classes * 2
151+
152+
# Class shuffle is a subset of module shuffle
153+
assert num_module_switches == num_modules
154+
155+
156+
@pytest.mark.parametrize('mode', ['class', 'module', 'package', 'global'])
157+
def test_it_works_with_actual_tests(tmp_tree_of_tests, mode):
158+
sequences = set()
159+
160+
for x in range(5):
161+
result = tmp_tree_of_tests.runpytest('--random-order-mode={}'.format(mode), '--verbose')
162+
result.assert_outcomes(passed=14, failed=3)
163+
seq = get_runtest_call_sequence(result)
164+
check_call_sequence(seq, shuffle_mode=mode)
165+
assert len(seq) == 17
166+
sequences.add(seq)
167+
168+
assert 1 < len(sequences) <= 5

tests/test_cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def test_help_message(testdir):
2+
result = testdir.runpytest(
3+
'--help',
4+
)
5+
result.stdout.fnmatch_lines([
6+
'random-order:',
7+
'*--random-order-mode={global,package,module,class}*',
8+
])

0 commit comments

Comments
 (0)