Skip to content

Commit 44dcc45

Browse files
authored
Merge pull request #4155 from HypothesisWorks/DRMacIver/no-shrink-on-replay
Add fast path for replaying already shrunk test cases
2 parents 67b842e + 3a74210 commit 44dcc45

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
@@ -1721,7 +1721,7 @@ def _get_fuzz_target() -> (
17211721
state = StateForActualGivenExecution(
17221722
stuff, test, settings, random, wrapped_test
17231723
)
1724-
digest = function_digest(test)
1724+
database_key = function_digest(test) + b".secondary"
17251725
# We track the minimal-so-far example for each distinct origin, so
17261726
# that we track log-n instead of n examples for long runs. In particular
17271727
# it means that we saturate for common errors in long runs instead of
@@ -1747,7 +1747,7 @@ def fuzz_one_input(
17471747
if settings.database is not None and (
17481748
known is None or sort_key(buffer) <= sort_key(known)
17491749
):
1750-
settings.database.save(digest, buffer)
1750+
settings.database.save(database_key, buffer)
17511751
minimal_failures[data.interesting_origin] = buffer
17521752
raise
17531753
return bytes(data.buffer)

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

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

267+
self.reused_previously_shrunk_test_case = False
268+
267269
self.__pending_call_explanation: Optional[str] = None
268270
self._switch_to_hypothesis_provider: bool = False
269271

@@ -815,6 +817,7 @@ def reuse_existing_examples(self) -> None:
815817
)
816818
factor = 0.1 if (Phase.generate in self.settings.phases) else 1
817819
desired_size = max(2, ceil(factor * self.settings.max_examples))
820+
primary_corpus_size = len(corpus)
818821

819822
if len(corpus) < desired_size:
820823
extra_corpus = list(self.settings.database.fetch(self.secondary_key))
@@ -828,11 +831,29 @@ def reuse_existing_examples(self) -> None:
828831
extra.sort(key=sort_key)
829832
corpus.extend(extra)
830833

831-
for existing in corpus:
834+
# We want a fast path where every primary entry in the database was
835+
# interesting.
836+
found_interesting_in_primary = False
837+
all_interesting_in_primary_were_exact = True
838+
839+
for i, existing in enumerate(corpus):
840+
if i >= primary_corpus_size and found_interesting_in_primary:
841+
break
832842
data = self.cached_test_function(existing, extend=BUFFER_SIZE)
833843
if data.status != Status.INTERESTING:
834844
self.settings.database.delete(self.database_key, existing)
835845
self.settings.database.delete(self.secondary_key, existing)
846+
else:
847+
if i < primary_corpus_size:
848+
found_interesting_in_primary = True
849+
assert not isinstance(data, _Overrun)
850+
if existing != data.buffer:
851+
all_interesting_in_primary_were_exact = False
852+
if not self.settings.report_multiple_bugs:
853+
break
854+
if found_interesting_in_primary:
855+
if all_interesting_in_primary_were_exact:
856+
self.reused_previously_shrunk_test_case = True
836857

837858
# Because self.database is not None (because self.has_existing_examples())
838859
# and self.database_key is not None (because we fetched using it above),
@@ -1231,6 +1252,11 @@ def _run(self) -> None:
12311252
self._switch_to_hypothesis_provider = True
12321253
with self._log_phase_statistics("reuse"):
12331254
self.reuse_existing_examples()
1255+
# Fast path for development: If the database gave us interesting
1256+
# examples from the previously stored primary key, don't try
1257+
# shrinking it again as it's unlikely to work.
1258+
if self.reused_previously_shrunk_test_case:
1259+
self.exit_with(ExitReason.finished)
12341260
# ...but we should use the supplied provider when generating...
12351261
self._switch_to_hypothesis_provider = False
12361262
with self._log_phase_statistics("generate"):

hypothesis-python/tests/conjecture/test_engine.py

+69
Original file line numberDiff line numberDiff line change
@@ -1691,3 +1691,72 @@ def test_mildly_complicated_strategies(strategy, condition):
16911691
# covered by shrinking any mildly compliated strategy and aren't worth
16921692
# testing explicitly for. This covers those.
16931693
minimal(strategy, condition)
1694+
1695+
1696+
def test_does_not_shrink_if_replaying_from_database():
1697+
db = InMemoryExampleDatabase()
1698+
key = b"foo"
1699+
1700+
def f(data):
1701+
if data.draw_integer(0, 255) == 123:
1702+
data.mark_interesting()
1703+
1704+
runner = ConjectureRunner(f, settings=settings(database=db), database_key=key)
1705+
b = bytes([123])
1706+
runner.save_buffer(b)
1707+
runner.shrink_interesting_examples = None
1708+
runner.run()
1709+
(last_data,) = runner.interesting_examples.values()
1710+
assert last_data.buffer == b
1711+
1712+
1713+
def test_does_shrink_if_replaying_inexact_from_database():
1714+
db = InMemoryExampleDatabase()
1715+
key = b"foo"
1716+
1717+
def f(data):
1718+
data.draw_integer(0, 255)
1719+
data.mark_interesting()
1720+
1721+
runner = ConjectureRunner(f, settings=settings(database=db), database_key=key)
1722+
b = bytes([123, 2])
1723+
runner.save_buffer(b)
1724+
runner.run()
1725+
(last_data,) = runner.interesting_examples.values()
1726+
assert last_data.buffer == bytes([0])
1727+
1728+
1729+
def test_stops_if_hits_interesting_early_and_only_want_one_bug():
1730+
db = InMemoryExampleDatabase()
1731+
key = b"foo"
1732+
1733+
def f(data):
1734+
data.draw_integer(0, 255)
1735+
data.mark_interesting()
1736+
1737+
runner = ConjectureRunner(
1738+
f, settings=settings(database=db, report_multiple_bugs=False), database_key=key
1739+
)
1740+
for i in range(256):
1741+
runner.save_buffer(bytes([i]))
1742+
runner.run()
1743+
assert runner.call_count == 1
1744+
1745+
1746+
def test_skips_secondary_if_interesting_is_found():
1747+
db = InMemoryExampleDatabase()
1748+
key = b"foo"
1749+
1750+
def f(data):
1751+
data.draw_integer(0, 255)
1752+
data.mark_interesting()
1753+
1754+
runner = ConjectureRunner(
1755+
f,
1756+
settings=settings(max_examples=1000, database=db, report_multiple_bugs=True),
1757+
database_key=key,
1758+
)
1759+
for i in range(256):
1760+
db.save(runner.database_key if i < 10 else runner.secondary_key, bytes([i]))
1761+
runner.reuse_existing_examples()
1762+
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
@@ -45,7 +45,7 @@ def test_gives_flaky_error_if_assumption_is_flaky():
4545
seen = set()
4646

4747
@given(integers())
48-
@settings(verbosity=Verbosity.quiet)
48+
@settings(verbosity=Verbosity.quiet, database=None)
4949
def oops(s):
5050
assume(s not in seen)
5151
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
@@ -238,7 +238,7 @@ def do(self, item):
238238

239239

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

0 commit comments

Comments
 (0)