Skip to content

Commit 3a74210

Browse files
committed
Add fast path for replaying already shrunk test cases
1 parent d16b183 commit 3a74210

File tree

8 files changed

+256
-6
lines changed

8 files changed

+256
-6
lines changed

hypothesis-python/RELEASE.rst

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
RELEASE_TYPE: patch
2+
3+
When Hypothesis replays examples from its test database that it knows were previously fully shrunk it will no longer try to shrink them again.
4+
5+
This should significantly speed up development workflows for slow tests, as the shrinking could contribute a significant delay when rerunning the tests.
6+
7+
In some rare cases this may cause minor reductions in example quality. This was considered an acceptable tradeoff for the improved test runtime.

hypothesis-python/src/hypothesis/core.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1732,7 +1732,7 @@ def _get_fuzz_target() -> (
17321732
state = StateForActualGivenExecution(
17331733
stuff, test, settings, random, wrapped_test
17341734
)
1735-
digest = function_digest(test)
1735+
database_key = function_digest(test) + b".secondary"
17361736
# We track the minimal-so-far example for each distinct origin, so
17371737
# that we track log-n instead of n examples for long runs. In particular
17381738
# it means that we saturate for common errors in long runs instead of
@@ -1758,7 +1758,7 @@ def fuzz_one_input(
17581758
if settings.database is not None and (
17591759
known is None or sort_key(buffer) <= sort_key(known)
17601760
):
1761-
settings.database.save(digest, buffer)
1761+
settings.database.save(database_key, buffer)
17621762
minimal_failures[data.interesting_origin] = buffer
17631763
raise
17641764
return bytes(data.buffer)

hypothesis-python/src/hypothesis/internal/conjecture/engine.py

+27-1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,8 @@ def __init__(
266266
self.__data_cache = LRUReusedCache(CACHE_SIZE)
267267
self.__data_cache_ir = LRUReusedCache(CACHE_SIZE)
268268

269+
self.reused_previously_shrunk_test_case = False
270+
269271
self.__pending_call_explanation: Optional[str] = None
270272
self._switch_to_hypothesis_provider: bool = False
271273

@@ -825,6 +827,7 @@ def reuse_existing_examples(self) -> None:
825827
)
826828
factor = 0.1 if (Phase.generate in self.settings.phases) else 1
827829
desired_size = max(2, ceil(factor * self.settings.max_examples))
830+
primary_corpus_size = len(corpus)
828831

829832
if len(corpus) < desired_size:
830833
extra_corpus = list(self.settings.database.fetch(self.secondary_key))
@@ -838,11 +841,29 @@ def reuse_existing_examples(self) -> None:
838841
extra.sort(key=sort_key)
839842
corpus.extend(extra)
840843

841-
for existing in corpus:
844+
# We want a fast path where every primary entry in the database was
845+
# interesting.
846+
found_interesting_in_primary = False
847+
all_interesting_in_primary_were_exact = True
848+
849+
for i, existing in enumerate(corpus):
850+
if i >= primary_corpus_size and found_interesting_in_primary:
851+
break
842852
data = self.cached_test_function(existing, extend=BUFFER_SIZE)
843853
if data.status != Status.INTERESTING:
844854
self.settings.database.delete(self.database_key, existing)
845855
self.settings.database.delete(self.secondary_key, existing)
856+
else:
857+
if i < primary_corpus_size:
858+
found_interesting_in_primary = True
859+
assert not isinstance(data, _Overrun)
860+
if existing != data.buffer:
861+
all_interesting_in_primary_were_exact = False
862+
if not self.settings.report_multiple_bugs:
863+
break
864+
if found_interesting_in_primary:
865+
if all_interesting_in_primary_were_exact:
866+
self.reused_previously_shrunk_test_case = True
846867

847868
# Because self.database is not None (because self.has_existing_examples())
848869
# and self.database_key is not None (because we fetched using it above),
@@ -1241,6 +1262,11 @@ def _run(self) -> None:
12411262
self._switch_to_hypothesis_provider = True
12421263
with self._log_phase_statistics("reuse"):
12431264
self.reuse_existing_examples()
1265+
# Fast path for development: If the database gave us interesting
1266+
# examples from the previously stored primary key, don't try
1267+
# shrinking it again as it's unlikely to work.
1268+
if self.reused_previously_shrunk_test_case:
1269+
self.exit_with(ExitReason.finished)
12441270
# ...but we should use the supplied provider when generating...
12451271
self._switch_to_hypothesis_provider = False
12461272
with self._log_phase_statistics("generate"):

hypothesis-python/tests/conjecture/test_engine.py

+69
Original file line numberDiff line numberDiff line change
@@ -1631,3 +1631,72 @@ def test_mildly_complicated_strategies(strategy, condition):
16311631
# covered by shrinking any mildly compliated strategy and aren't worth
16321632
# testing explicitly for. This covers those.
16331633
minimal(strategy, condition)
1634+
1635+
1636+
def test_does_not_shrink_if_replaying_from_database():
1637+
db = InMemoryExampleDatabase()
1638+
key = b"foo"
1639+
1640+
def f(data):
1641+
if data.draw_integer(0, 255) == 123:
1642+
data.mark_interesting()
1643+
1644+
runner = ConjectureRunner(f, settings=settings(database=db), database_key=key)
1645+
b = bytes([123])
1646+
runner.save_buffer(b)
1647+
runner.shrink_interesting_examples = None
1648+
runner.run()
1649+
(last_data,) = runner.interesting_examples.values()
1650+
assert last_data.buffer == b
1651+
1652+
1653+
def test_does_shrink_if_replaying_inexact_from_database():
1654+
db = InMemoryExampleDatabase()
1655+
key = b"foo"
1656+
1657+
def f(data):
1658+
data.draw_integer(0, 255)
1659+
data.mark_interesting()
1660+
1661+
runner = ConjectureRunner(f, settings=settings(database=db), database_key=key)
1662+
b = bytes([123, 2])
1663+
runner.save_buffer(b)
1664+
runner.run()
1665+
(last_data,) = runner.interesting_examples.values()
1666+
assert last_data.buffer == bytes([0])
1667+
1668+
1669+
def test_stops_if_hits_interesting_early_and_only_want_one_bug():
1670+
db = InMemoryExampleDatabase()
1671+
key = b"foo"
1672+
1673+
def f(data):
1674+
data.draw_integer(0, 255)
1675+
data.mark_interesting()
1676+
1677+
runner = ConjectureRunner(
1678+
f, settings=settings(database=db, report_multiple_bugs=False), database_key=key
1679+
)
1680+
for i in range(256):
1681+
runner.save_buffer(bytes([i]))
1682+
runner.run()
1683+
assert runner.call_count == 1
1684+
1685+
1686+
def test_skips_secondary_if_interesting_is_found():
1687+
db = InMemoryExampleDatabase()
1688+
key = b"foo"
1689+
1690+
def f(data):
1691+
data.draw_integer(0, 255)
1692+
data.mark_interesting()
1693+
1694+
runner = ConjectureRunner(
1695+
f,
1696+
settings=settings(max_examples=1000, database=db, report_multiple_bugs=True),
1697+
database_key=key,
1698+
)
1699+
for i in range(256):
1700+
db.save(runner.database_key if i < 10 else runner.secondary_key, bytes([i]))
1701+
runner.reuse_existing_examples()
1702+
assert runner.call_count == 10

hypothesis-python/tests/cover/test_debug_information.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,16 @@
1313
import pytest
1414

1515
from hypothesis import Verbosity, given, settings, strategies as st
16+
from hypothesis.database import InMemoryExampleDatabase
1617

1718
from tests.common.utils import capture_out
1819

1920

2021
def test_reports_passes():
2122
@given(st.integers())
22-
@settings(verbosity=Verbosity.debug, max_examples=1000)
23+
@settings(
24+
verbosity=Verbosity.debug, max_examples=1000, database=InMemoryExampleDatabase()
25+
)
2326
def test(i):
2427
assert i < 10
2528

hypothesis-python/tests/cover/test_flakiness.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def test_gives_flaky_error_if_assumption_is_flaky():
4444
seen = set()
4545

4646
@given(integers())
47-
@settings(verbosity=Verbosity.quiet)
47+
@settings(verbosity=Verbosity.quiet, database=None)
4848
def oops(s):
4949
assume(s not in seen)
5050
seen.add(s)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# This file is part of Hypothesis, which may be found at
2+
# https://github.com/HypothesisWorks/hypothesis/
3+
#
4+
# Copyright the Hypothesis Authors.
5+
# Individual contributors are listed in AUTHORS.rst and the git log.
6+
#
7+
# This Source Code Form is subject to the terms of the Mozilla Public License,
8+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
9+
# obtain one at https://mozilla.org/MPL/2.0/.
10+
11+
import pytest
12+
13+
from hypothesis import given, settings, strategies as st
14+
from hypothesis.database import InMemoryExampleDatabase
15+
from hypothesis.internal.compat import ExceptionGroup
16+
17+
18+
def test_does_not_shrink_on_replay():
19+
database = InMemoryExampleDatabase()
20+
21+
call_count = 0
22+
23+
is_first = True
24+
last = None
25+
26+
@settings(
27+
database=database,
28+
report_multiple_bugs=False,
29+
derandomize=False,
30+
max_examples=1000,
31+
)
32+
@given(st.lists(st.integers(), unique=True, min_size=3))
33+
def test(ls):
34+
nonlocal call_count, is_first, last
35+
if is_first and last is not None:
36+
assert ls == last
37+
is_first = False
38+
last = ls
39+
call_count += 1
40+
raise AssertionError
41+
42+
with pytest.raises(AssertionError):
43+
test()
44+
45+
assert last is not None
46+
47+
call_count = 0
48+
is_first = True
49+
50+
with pytest.raises(AssertionError):
51+
test()
52+
53+
assert call_count == 2
54+
55+
56+
def test_does_not_shrink_on_replay_with_multiple_bugs():
57+
database = InMemoryExampleDatabase()
58+
59+
call_count = 0
60+
61+
tombstone = 1000093
62+
63+
@settings(
64+
database=database,
65+
report_multiple_bugs=True,
66+
derandomize=False,
67+
max_examples=1000,
68+
)
69+
@given(st.integers())
70+
def test(i):
71+
nonlocal call_count
72+
call_count += 1
73+
if i > tombstone:
74+
raise AssertionError
75+
elif i == tombstone:
76+
raise AssertionError
77+
78+
with pytest.raises(ExceptionGroup):
79+
test()
80+
81+
call_count = 0
82+
83+
with pytest.raises(ExceptionGroup):
84+
test()
85+
86+
assert call_count == 4
87+
88+
89+
def test_will_always_shrink_if_previous_example_does_not_replay():
90+
database = InMemoryExampleDatabase()
91+
92+
good = set()
93+
last = None
94+
95+
@settings(
96+
database=database,
97+
report_multiple_bugs=True,
98+
derandomize=False,
99+
max_examples=1000,
100+
)
101+
@given(st.integers(min_value=0))
102+
def test(i):
103+
nonlocal last
104+
if i not in good:
105+
last = i
106+
raise AssertionError
107+
108+
for i in range(20):
109+
with pytest.raises(AssertionError):
110+
test()
111+
assert last == i
112+
good.add(last)
113+
114+
115+
def test_will_shrink_if_the_previous_example_does_not_look_right():
116+
database = InMemoryExampleDatabase()
117+
118+
last = None
119+
120+
first_test = True
121+
122+
@settings(database=database, report_multiple_bugs=True, derandomize=False)
123+
@given(st.data())
124+
def test(data):
125+
nonlocal last
126+
m = data.draw(st.integers())
127+
last = m
128+
if first_test:
129+
data.draw(st.integers())
130+
assert m < 10000
131+
else:
132+
raise AssertionError
133+
134+
with pytest.raises(AssertionError):
135+
test()
136+
137+
assert last is not None
138+
assert last > 0
139+
140+
first_test = False
141+
142+
with pytest.raises(AssertionError):
143+
test()
144+
145+
assert last == 0

hypothesis-python/tests/cover/test_statistical_events.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def do(self, item):
237237

238238

239239
def test_statistics_for_threshold_problem():
240-
@settings(max_examples=100)
240+
@settings(max_examples=100, database=None)
241241
@given(st.floats(min_value=0, allow_infinity=False))
242242
def threshold(error):
243243
target(error, label="error")

0 commit comments

Comments
 (0)