Skip to content

Commit ba6bb90

Browse files
committed
New @example(...).xfail() method
1 parent 45993b0 commit ba6bb90

File tree

5 files changed

+201
-7
lines changed

5 files changed

+201
-7
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
RELEASE_TYPE: minor
2+
3+
A classic error when testing is to write a test function that can never fail,
4+
even on inputs that aren't allowed or manually provided. By analogy to the
5+
design pattern of::
6+
7+
@pytest.mark.parametrize("arg", [
8+
..., # passing examples
9+
pytest.param(..., marks=[pytest.mark.xfail]) # expected-failing input
10+
])
11+
12+
we now support :obj:`@example(...).xfail() <hypothesis.example.xfail>`, with
13+
the same (optional) ``condition``, ``reason``, and ``raises`` arguments as
14+
``pytest.mark.xfail()``.
15+
16+
Naturally you can also write ``.via(...).xfail(...)``, or ``.xfail(...).via(...)``,
17+
if you wish to note the provenance of expected-failing examples.

hypothesis-python/docs/reproducing.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ Either are fine, and you can use one in one example and the other in another
7676
example if for some reason you really want to, but a single example must be
7777
consistent.
7878

79+
.. automethod:: hypothesis.example.xfail
80+
7981
.. automethod:: hypothesis.example.via
8082

8183
.. _reproducing-with-seed:

hypothesis-python/src/hypothesis/core.py

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
Hashable,
3535
List,
3636
Optional,
37+
Tuple,
38+
Type,
3739
TypeVar,
3840
Union,
3941
overload,
@@ -92,10 +94,12 @@
9294
get_signature,
9395
impersonate,
9496
is_mock,
97+
nicerepr,
9598
proxies,
9699
repr_call,
97100
)
98101
from hypothesis.internal.scrutineer import Tracer, explanatory_lines
102+
from hypothesis.internal.validation import check_type
99103
from hypothesis.reporting import (
100104
current_verbosity,
101105
report,
@@ -134,6 +138,9 @@
134138
class Example:
135139
args = attr.ib()
136140
kwargs = attr.ib()
141+
# Plus two optional arguments for .xfail()
142+
raises = attr.ib(default=None)
143+
reason = attr.ib(default=None)
137144

138145

139146
class example:
@@ -156,6 +163,49 @@ def __call__(self, test: TestFunc) -> TestFunc:
156163
test.hypothesis_explicit_examples.append(self._this_example) # type: ignore
157164
return test
158165

166+
def xfail(
167+
self,
168+
condition: bool = True,
169+
*,
170+
reason: str = "",
171+
raises: Union[Type[BaseException], Tuple[BaseException, ...]] = BaseException,
172+
) -> "example":
173+
"""Mark this example as an expected failure, like pytest.mark.xfail().
174+
175+
Expected-failing examples allow you to check that your test does fail on
176+
some examples, and therefore build confidence that *passing* tests are
177+
because your code is working, not because the test is missing something.
178+
179+
.. code-block:: python
180+
181+
@example(...).xfail()
182+
@example(...).xfail(reason="Prices must be non-negative")
183+
@example(...).xfail(raises=(KeyError, ValueError))
184+
@example(...).xfail(sys.version_info[:2] >= (3, 9), reason="needs py39+")
185+
@example(...).xfail(condition=sys.platform != "linux", raises=OSError)
186+
def test(x):
187+
pass
188+
"""
189+
check_type(bool, condition, "condition")
190+
check_type(str, reason, "reason")
191+
if not (
192+
isinstance(raises, type) and issubclass(raises, BaseException)
193+
) and not (
194+
isinstance(raises, tuple)
195+
and raises # () -> expected to fail with no error, which is impossible
196+
and all(
197+
isinstance(r, type) and issubclass(r, BaseException) for r in raises
198+
)
199+
):
200+
raise InvalidArgument(
201+
f"raises={raises!r} must be an exception type or tuple of exception types"
202+
)
203+
if condition:
204+
self._this_example = attr.evolve(
205+
self._this_example, raises=raises, reason=reason
206+
)
207+
return self
208+
159209
def via(self, *whence: str) -> "example":
160210
"""Attach a machine-readable label noting whence this example came.
161211
@@ -454,12 +504,47 @@ def execute_explicit_examples(state, wrapped_test, arguments, kwargs, original_s
454504
with local_settings(state.settings):
455505
fragments_reported = []
456506
try:
507+
adata = ArtificialDataForExample(arguments, example_kwargs)
508+
bits = ", ".join(nicerepr(x) for x in arguments) + ", ".join(
509+
f"{k}={nicerepr(v)}" for k, v in example_kwargs.items()
510+
)
457511
with with_reporter(fragments_reported.append):
458-
state.execute_once(
459-
ArtificialDataForExample(arguments, example_kwargs),
460-
is_final=True,
461-
print_example=True,
462-
)
512+
if example.raises is None:
513+
state.execute_once(adata, is_final=True, print_example=True)
514+
else:
515+
# @example(...).xfail(...)
516+
try:
517+
state.execute_once(adata, is_final=True, print_example=True)
518+
except failure_exceptions_to_catch() as err:
519+
if not isinstance(err, example.raises):
520+
raise
521+
except example.raises as err:
522+
# We'd usually check this as early as possible, but it's
523+
# possible for failure_exceptions_to_catch() to grow when
524+
# e.g. pytest is imported between import- and test-time.
525+
raise InvalidArgument(
526+
f"@example({bits}) raised an expected {err!r}, "
527+
"but Hypothesis does not treat this as a test failure"
528+
) from err
529+
else:
530+
# Unexpectedly passing; always raise an error in this case.
531+
reason = f" because {example.reason}" * bool(example.reason)
532+
if example.raises is BaseException:
533+
name = "exception" # special-case no raises= arg
534+
elif not isinstance(example.raises, tuple):
535+
name = example.raises.__name__
536+
elif len(example.raises) == 1:
537+
name = example.raises[0].__name__
538+
else:
539+
name = (
540+
", ".join(ex.__name__ for ex in example.raises[:-1])
541+
+ f", or {example.raises[-1].__name__}"
542+
)
543+
vowel = name.upper()[0] in "AEIOU"
544+
raise AssertionError(
545+
f"Expected a{'n' * vowel} {name} from @example({bits})"
546+
f"{reason}, but no exception was raised."
547+
)
463548
except UnsatisfiedAssumption:
464549
# Odd though it seems, we deliberately support explicit examples that
465550
# are then rejected by a call to `assume()`. As well as iterative

hypothesis-python/tests/common/utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class ExcInfo:
8989
pass
9090

9191

92-
def fails_with(e):
92+
def fails_with(e, *, match=None):
9393
def accepts(f):
9494
@proxies(f)
9595
def inverted_test(*arguments, **kwargs):
@@ -98,7 +98,7 @@ def inverted_test(*arguments, **kwargs):
9898
# the `raises` context manager so that any problems in rigging the
9999
# PRNG don't accidentally count as the expected failure.
100100
with deterministic_PRNG():
101-
with raises(e):
101+
with raises(e, match=match):
102102
f(*arguments, **kwargs)
103103

104104
return inverted_test

hypothesis-python/tests/cover/test_example.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,93 @@ def test_invalid_example_via():
112112
example(x=False).via(100) # not a string!
113113
with pytest.raises(TypeError):
114114
example(x=False).via("abc", "def") # too many args
115+
116+
117+
@pytest.mark.parametrize(
118+
"kw",
119+
[
120+
{"condition": None}, # must be a bool
121+
{"reason": None}, # must be a string
122+
{"raises": None}, # not a BaseException (or even a type)
123+
{"raises": int}, # not a BaseException
124+
{"raises": [Exception]}, # not a tuple
125+
{"raises": (None,)}, # tuple containing a non-BaseException
126+
{"raises": ()}, # empty tuple doesn't make sense here
127+
# raising non-failure exceptions, eg KeyboardInterrupt, is tested below
128+
],
129+
ids=repr,
130+
)
131+
def test_invalid_example_xfail_arguments(kw):
132+
with pytest.raises(InvalidArgument):
133+
example(x=False).xfail(**kw)
134+
135+
136+
@identity(example(True).xfail())
137+
@identity(example(True).xfail(reason="ignored for passing tests"))
138+
@identity(example(True).xfail(raises=KeyError))
139+
@identity(example(True).xfail(raises=(KeyError, ValueError)))
140+
@identity(example(True).xfail(True, reason="..."))
141+
@identity(example(False).xfail(condition=False))
142+
@given(st.none())
143+
def test_many_xfail_example_decorators(fails):
144+
if fails:
145+
raise KeyError
146+
147+
148+
@fails_with(AssertionError)
149+
@identity(example(x=True).xfail(raises=KeyError))
150+
@given(st.none())
151+
def test_xfail_reraises_non_specified_exception(x):
152+
assert not x
153+
154+
155+
@fails_with(
156+
InvalidArgument,
157+
match=r"@example\(x=True\) raised an expected BaseException\('msg'\), "
158+
r"but Hypothesis does not treat this as a test failure",
159+
)
160+
@identity(example(True).xfail())
161+
@given(st.none())
162+
def test_must_raise_a_failure_exception(x):
163+
if x:
164+
raise BaseException("msg")
165+
166+
167+
@fails_with(
168+
AssertionError,
169+
match=r"Expected an exception from @example\(x=None\), but no exception was raised.",
170+
)
171+
@identity(example(None).xfail())
172+
@given(st.none())
173+
def test_error_on_unexpected_pass_base(x):
174+
pass
175+
176+
177+
@fails_with(
178+
AssertionError,
179+
match=r"Expected an AssertionError from @example\(x=None\), but no exception was raised.",
180+
)
181+
@identity(example(None).xfail(raises=AssertionError))
182+
@given(st.none())
183+
def test_error_on_unexpected_pass_single(x):
184+
pass
185+
186+
187+
@fails_with(
188+
AssertionError,
189+
match=r"Expected an AssertionError from @example\(x=None\), but no exception was raised.",
190+
)
191+
@identity(example(None).xfail(raises=(AssertionError,)))
192+
@given(st.none())
193+
def test_error_on_unexpected_pass_single_elem_tuple(x):
194+
pass
195+
196+
197+
@fails_with(
198+
AssertionError,
199+
match=r"Expected a KeyError, or ValueError from @example\(x=None\), but no exception was raised.",
200+
)
201+
@identity(example(None).xfail(raises=(KeyError, ValueError)))
202+
@given(st.none())
203+
def test_error_on_unexpected_pass_multi(x):
204+
pass

0 commit comments

Comments
 (0)