Skip to content

Commit bd697bd

Browse files
Use a different random seed per test (#687)
Redo of #617, with my review changes. Co-authored-by: Bryce <[email protected]>
1 parent acadf46 commit bd697bd

File tree

4 files changed

+80
-116
lines changed

4 files changed

+80
-116
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ Unreleased
77

88
* Support Python 3.14.
99

10+
* Use a different random seed per test, based on the test ID.
11+
12+
This change should mean that tests exercise more random data values in a given run, and that any randomly-generated identifiers have a lower chance of collision when stored in a shared resource like a database.
13+
14+
`PR #687 <https://github.com/pytest-dev/pytest-randomly/issues/687>`__.
15+
Thanks to Bryce Drennan for the suggestion in `Issue #600 <https://github.com/pytest-dev/pytest-randomly/issues/600>`__ and initial implementation in `PR #617 <https://github.com/pytest-dev/pytest-randomly/pull/617>`__.
16+
1017
3.16.0 (2024-10-25)
1118
-------------------
1219

README.rst

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,33 @@ All of these features are on by default but can be disabled with flags.
3636
modules, then at the level of test classes (if you have them), then at the
3737
order of functions. This also works with things like doctests.
3838

39-
* Resets the global ``random.seed()`` at the start of every test case and test
40-
to a fixed number - this defaults to ``time.time()`` from the start of your
41-
test run, but you can pass in ``--randomly-seed`` to repeat a
42-
randomness-induced failure.
43-
44-
* If
45-
`factory boy <https://factoryboy.readthedocs.io/en/latest/reference.html>`_
46-
is installed, its random state is reset at the start of every test. This
47-
allows for repeatable use of its random 'fuzzy' features.
48-
49-
* If `faker <https://pypi.org/project/faker>`_ is installed, its random
50-
state is reset at the start of every test. This is also for repeatable fuzzy
51-
data in tests - factory boy uses faker for lots of data. This is also done
52-
if you're using the ``faker`` pytest fixture, by defining the ``faker_seed``
53-
fixture
54-
(`docs <https://faker.readthedocs.io/en/master/pytest-fixtures.html#seeding-configuration>`__).
55-
56-
* If
57-
`Model Bakery <https://model-bakery.readthedocs.io/en/latest/>`_
58-
is installed, its random state is reset at the start of every test. This
59-
allows for repeatable use of its random fixture field values.
60-
61-
* If `numpy <http://www.numpy.org/>`_ is installed, its legacy global random state in |numpy.random|__ is reset at the start of every test.
62-
63-
.. |numpy.random| replace:: ``numpy.random``
64-
__ https://numpy.org/doc/stable/reference/random/index.html
39+
* Generates a base random seed or accepts one for reproduction with ``--randomly-seed``.
40+
The base random seed is printed at the start of the test run, and can be passed in to repeat a failure caused by test ordering or random data.
41+
42+
* At the start of the test run, and before each test setup, run, and teardown, it resets Python’s global random seed to a fixed value, using |random.seed()|__.
43+
The fixed value is derived from the base random seed, the pytest test ID, and an offset for setup or teardown.
44+
This ensures each test gets a different but repeatable random seed.
45+
46+
.. |random.seed()| replace:: ``random.seed()``
47+
__ https://docs.python.org/3/library/random.html#random.seed
48+
49+
* pytest-randomly also resets several libraries’ random states at the start of
50+
every test, if they are installed:
51+
52+
* `factory boy <https://factoryboy.readthedocs.io/en/latest/reference.html>`__
53+
54+
* `Faker <https://pypi.org/project/faker>`__
55+
56+
The ``faker`` pytest fixture is also affected, as pytest-randomly defines |the faker_seed fixture|__.
57+
58+
.. |the faker_seed fixture| replace:: the ``faker_seed`` fixture
59+
__ https://faker.readthedocs.io/en/master/pytest-fixtures.html#seeding-configuration
60+
61+
* `Model Bakery <https://model-bakery.readthedocs.io/en/latest/>`__
62+
63+
* `NumPy <https://www.numpy.org/>`_
64+
65+
Only its `legacy random state <https://numpy.org/doc/stable/reference/random/legacy.html>`__ is affected.
6566

6667
* If additional random generators are used, they can be registered under the
6768
``pytest_randomly.random_seeder``

src/pytest_randomly/__init__.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
import hashlib
55
import random
66
import sys
7+
from functools import lru_cache
78
from itertools import groupby
89
from types import ModuleType
910
from typing import Any, Callable, TypeVar
1011

1112
from _pytest.config import Config
1213
from _pytest.config.argparsing import Parser
14+
from _pytest.fixtures import SubRequest
1315
from _pytest.nodes import Item
1416
from pytest import Collector, fixture, hookimpl
1517

@@ -196,17 +198,17 @@ def pytest_report_header(config: Config) -> str:
196198

197199
def pytest_runtest_setup(item: Item) -> None:
198200
if item.config.getoption("randomly_reset_seed"):
199-
_reseed(item.config, -1)
201+
_reseed(item.config, int.from_bytes(_md5(item.nodeid), "big") - 1)
200202

201203

202204
def pytest_runtest_call(item: Item) -> None:
203205
if item.config.getoption("randomly_reset_seed"):
204-
_reseed(item.config)
206+
_reseed(item.config, int.from_bytes(_md5(item.nodeid), "big"))
205207

206208

207209
def pytest_runtest_teardown(item: Item) -> None:
208210
if item.config.getoption("randomly_reset_seed"):
209-
_reseed(item.config, 1)
211+
_reseed(item.config, int.from_bytes(_md5(item.nodeid), "big") + 1)
210212

211213

212214
@hookimpl(tryfirst=True)
@@ -279,6 +281,7 @@ def reduce_list_of_lists(lists: list[list[T]]) -> list[T]:
279281
return new_list
280282

281283

284+
@lru_cache
282285
def _md5(string: str) -> bytes:
283286
hasher = hashlib.md5(usedforsecurity=False)
284287
hasher.update(string.encode())
@@ -288,6 +291,10 @@ def _md5(string: str) -> bytes:
288291
if have_faker: # pragma: no branch
289292

290293
@fixture(autouse=True)
291-
def faker_seed(pytestconfig: Config) -> int:
292-
result: int = pytestconfig.getoption("randomly_seed")
294+
def faker_seed(pytestconfig: Config, request: SubRequest) -> int:
295+
print(type(request))
296+
result: int = pytestconfig.getoption("randomly_seed") + int.from_bytes(
297+
_md5(request.node.nodeid),
298+
"big",
299+
)
293300
return result

tests/test_pytest_randomly.py

Lines changed: 34 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def test_it_reports_a_header_when_set(simpletester):
5858
assert lines == ["Using --randomly-seed=10"]
5959

6060

61-
def test_it_reuses_the_same_random_seed_per_test(ourtester):
61+
def test_it_uses_different_random_seeds_per_test(ourtester):
6262
"""
6363
Run a pair of tests that generate the a number and then assert they got
6464
what the other did.
@@ -67,18 +67,16 @@ def test_it_reuses_the_same_random_seed_per_test(ourtester):
6767
test_one="""
6868
import random
6969
70+
7071
def test_a():
71-
test_a.num = random.random()
72-
if hasattr(test_b, 'num'):
73-
assert test_a.num == test_b.num
72+
global num
73+
num = random.random()
7474
7575
def test_b():
76-
test_b.num = random.random()
77-
if hasattr(test_a, 'num'):
78-
assert test_b.num == test_a.num
76+
assert random.random() != num
7977
"""
8078
)
81-
out = ourtester.runpytest("--randomly-dont-reorganize")
79+
out = ourtester.runpytest("--randomly-dont-reorganize", "--randomly-seed=1")
8280
out.assert_outcomes(passed=2, failed=0)
8381

8482

@@ -157,9 +155,8 @@ class A(TestCase):
157155
158156
@classmethod
159157
def setUpClass(cls):
160-
super(A, cls).setUpClass()
158+
super().setUpClass()
161159
cls.suc_num = random.random()
162-
assert cls.suc_num == getattr(B, 'suc_num', cls.suc_num)
163160
164161
def test_fake(self):
165162
assert True
@@ -169,15 +166,15 @@ class B(TestCase):
169166
170167
@classmethod
171168
def setUpClass(cls):
172-
super(B, cls).setUpClass()
169+
super().setUpClass()
173170
cls.suc_num = random.random()
174-
assert cls.suc_num == getattr(A, 'suc_num', cls.suc_num)
171+
assert cls.suc_num != A.suc_num
175172
176173
def test_fake(self):
177174
assert True
178175
"""
179176
)
180-
out = ourtester.runpytest()
177+
out = ourtester.runpytest("--randomly-seed=1")
181178
out.assert_outcomes(passed=2, failed=0)
182179

183180

@@ -195,9 +192,8 @@ def test_fake(self):
195192
196193
@classmethod
197194
def tearDownClass(cls):
198-
super(A, cls).tearDownClass()
195+
super().tearDownClass()
199196
cls.suc_num = random.random()
200-
assert cls.suc_num == getattr(B, 'suc_num', cls.suc_num)
201197
202198
203199
class B(TestCase):
@@ -207,12 +203,12 @@ def test_fake(self):
207203
208204
@classmethod
209205
def tearDownClass(cls):
210-
super(B, cls).tearDownClass()
206+
super().tearDownClass()
211207
cls.suc_num = random.random()
212-
assert cls.suc_num == getattr(A, 'suc_num', cls.suc_num)
208+
assert cls.suc_num != A.suc_num
213209
"""
214210
)
215-
out = ourtester.runpytest()
211+
out = ourtester.runpytest("--randomly-seed=1")
216212
out.assert_outcomes(passed=2, failed=0)
217213

218214

@@ -574,62 +570,20 @@ def test_one(myfixture):
574570
out.assert_outcomes(passed=1)
575571

576572

577-
def test_fixtures_dont_interfere_with_tests_getting_same_random_state(ourtester):
578-
ourtester.makepyfile(
579-
test_one="""
580-
import random
581-
582-
import pytest
583-
584-
585-
random.seed(2)
586-
state_at_seed_two = random.getstate()
587-
588-
589-
@pytest.fixture(scope='module')
590-
def myfixture():
591-
return random.random()
592-
593-
594-
@pytest.mark.one()
595-
def test_one(myfixture):
596-
assert random.getstate() == state_at_seed_two
597-
598-
599-
@pytest.mark.two()
600-
def test_two(myfixture):
601-
assert random.getstate() == state_at_seed_two
602-
"""
603-
)
604-
args = ["--randomly-seed=2"]
605-
606-
out = ourtester.runpytest(*args)
607-
out.assert_outcomes(passed=2)
608-
609-
out = ourtester.runpytest("-m", "one", *args)
610-
out.assert_outcomes(passed=1)
611-
out = ourtester.runpytest("-m", "two", *args)
612-
out.assert_outcomes(passed=1)
613-
614-
615573
def test_factory_boy(ourtester):
616574
"""
617-
Rather than set up factories etc., just check the random generator it uses
618-
is set between two tests to output the same number.
575+
Check that the random generator factory boy uses is different between two tests.
619576
"""
620577
ourtester.makepyfile(
621578
test_one="""
622579
from factory.random import randgen
623580
624581
def test_a():
625-
test_a.num = randgen.random()
626-
if hasattr(test_b, 'num'):
627-
assert test_a.num == test_b.num
582+
assert randgen.random() == 0.9988532989147809
583+
628584
629585
def test_b():
630-
test_b.num = randgen.random()
631-
if hasattr(test_a, 'num'):
632-
assert test_b.num == test_a.num
586+
assert randgen.random() == 0.18032546798434612
633587
"""
634588
)
635589

@@ -645,10 +599,10 @@ def test_faker(ourtester):
645599
fake = Faker()
646600
647601
def test_one():
648-
assert fake.name() == 'Ryan Gallagher'
602+
assert fake.name() == 'Mrs. Lisa Ryan'
649603
650604
def test_two():
651-
assert fake.name() == 'Ryan Gallagher'
605+
assert fake.name() == 'Kaitlyn Mitchell'
652606
"""
653607
)
654608

@@ -660,10 +614,10 @@ def test_faker_fixture(ourtester):
660614
ourtester.makepyfile(
661615
test_one="""
662616
def test_one(faker):
663-
assert faker.name() == 'Ryan Gallagher'
617+
assert faker.name() == 'Mrs. Lisa Ryan'
664618
665619
def test_two(faker):
666-
assert faker.name() == 'Ryan Gallagher'
620+
assert faker.name() == 'Kaitlyn Mitchell'
667621
"""
668622
)
669623

@@ -673,22 +627,17 @@ def test_two(faker):
673627

674628
def test_model_bakery(ourtester):
675629
"""
676-
Rather than set up models, just check the random generator it uses is set
677-
between two tests to output the same number.
630+
Check the Model Bakery random generator is reset between tests.
678631
"""
679632
ourtester.makepyfile(
680633
test_one="""
681-
from model_bakery.random_gen import baker_random
634+
from model_bakery.random_gen import gen_slug
682635
683636
def test_a():
684-
test_a.num = baker_random.random()
685-
if hasattr(test_b, 'num'):
686-
assert test_a.num == test_b.num
637+
assert gen_slug(10) == 'XjpU5br7ej'
687638
688639
def test_b():
689-
test_b.num = baker_random.random()
690-
if hasattr(test_a, 'num'):
691-
assert test_b.num == test_a.num
640+
assert gen_slug(10) == 'xJHS-PD_WT'
692641
"""
693642
)
694643

@@ -702,10 +651,10 @@ def test_numpy(ourtester):
702651
import numpy as np
703652
704653
def test_one():
705-
assert np.random.rand() == 0.417022004702574
654+
assert np.random.rand() == 0.36687834264514585
706655
707656
def test_two():
708-
assert np.random.rand() == 0.417022004702574
657+
assert np.random.rand() == 0.7050715833365834
709658
"""
710659
)
711660

@@ -769,19 +718,19 @@ def fake_entry_points(*, group):
769718
assert reseed.mock_calls == [
770719
mock.call(1),
771720
mock.call(1),
772-
mock.call(0),
773-
mock.call(1),
774-
mock.call(2),
721+
mock.call(116362448262735926321257785636175308268),
722+
mock.call(116362448262735926321257785636175308269),
723+
mock.call(116362448262735926321257785636175308270),
775724
]
776725

777726
reseed.mock_calls[:] = []
778727
pytester.runpytest_inprocess("--randomly-seed=424242")
779728
assert reseed.mock_calls == [
780729
mock.call(424242),
781730
mock.call(424242),
782-
mock.call(424241),
783-
mock.call(424242),
784-
mock.call(424243),
731+
mock.call(116362448262735926321257785636175732509),
732+
mock.call(116362448262735926321257785636175732510),
733+
mock.call(116362448262735926321257785636175732511),
785734
]
786735

787736

0 commit comments

Comments
 (0)