Skip to content

Commit fa47b8d

Browse files
Zac-HDTomAugspurger
authored andcommitted
Add initial property-based tests using Hypothesis (#22280)
* BLD: Add Hypothesis to build system * TST: Add Hypothesis tests for ticks, offsets These tests are derived from GH18761, by jbrockmendel Co-authored-by: jbrockmendel <[email protected]> * DOC: Explain Hypothesis in contributing guide * TST: remove pointless loop * TST: Improve integration of Hypothesis Responding to review from jreback on GH22280. * Final review fixes
1 parent 55d176d commit fa47b8d

25 files changed

+253
-22
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ dist
6262
coverage.xml
6363
coverage_html_report
6464
*.pytest_cache
65+
# hypothesis test database
66+
.hypothesis/
6567

6668
# OS generated files #
6769
######################

ci/appveyor-27.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ dependencies:
2828
- pytest
2929
- pytest-xdist
3030
- moto
31+
- hypothesis>=3.58.0

ci/appveyor-36.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ dependencies:
2525
- cython>=0.28.2
2626
- pytest
2727
- pytest-xdist
28+
- hypothesis>=3.58.0

ci/check_imports.py

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
'html5lib',
1010
'ipython',
1111
'jinja2'
12+
'hypothesis',
1213
'lxml',
1314
'numexpr',
1415
'openpyxl',

ci/circle-27-compat.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ dependencies:
2626
- html5lib==1.0b2
2727
- beautifulsoup4==4.2.1
2828
- pymysql==0.6.0
29+
- hypothesis>=3.58.0

ci/circle-35-ascii.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ dependencies:
1111
# universal
1212
- pytest
1313
- pytest-xdist
14+
- pip:
15+
- hypothesis>=3.58.0

ci/circle-36-locale.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ dependencies:
3131
- pytest
3232
- pytest-xdist
3333
- moto
34+
- pip:
35+
- hypothesis>=3.58.0

ci/circle-36-locale_slow.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ dependencies:
3232
- pytest
3333
- pytest-xdist
3434
- moto
35+
- pip:
36+
- hypothesis>=3.58.0

ci/environment-dev.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ dependencies:
77
- NumPy
88
- flake8
99
- flake8-comprehensions
10+
- hypothesis>=3.58.0
1011
- moto
1112
- pytest>=3.6
1213
- python-dateutil>=2.5.0

ci/requirements_dev.txt

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Cython>=0.28.2
44
NumPy
55
flake8
66
flake8-comprehensions
7+
hypothesis>=3.58.0
78
moto
89
pytest>=3.6
910
python-dateutil>=2.5.0

ci/travis-27-locale.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies:
2222
# universal
2323
- pytest
2424
- pytest-xdist
25+
- hypothesis>=3.58.0
2526
- pip:
2627
- html5lib==1.0b2
2728
- beautifulsoup4==4.2.1

ci/travis-27.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies:
4545
- pytest
4646
- pytest-xdist
4747
- moto
48+
- hypothesis>=3.58.0
4849
- pip:
4950
- backports.lzma
5051
- cpplint

ci/travis-35-osx.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ dependencies:
2525
- pytest-xdist
2626
- pip:
2727
- python-dateutil==2.5.3
28+
- hypothesis>=3.58.0

ci/travis-36-doc.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies:
1010
- fastparquet
1111
- feather-format
1212
- html5lib
13+
- hypothesis>=3.58.0
1314
- ipykernel
1415
- ipython
1516
- ipywidgets

ci/travis-36-numpydev.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ dependencies:
88
# universal
99
- pytest
1010
- pytest-xdist
11+
- hypothesis>=3.58.0
1112
- pip:
1213
- "git+git://github.com/dateutil/dateutil.git"
1314
- "-f https://7933911d6844c6c53a7d-47bd50c35cd79bd838daf386af554a83.ssl.cf2.rackcdn.com"

ci/travis-36-slow.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ dependencies:
2828
- pytest
2929
- pytest-xdist
3030
- moto
31+
- hypothesis>=3.58.0

ci/travis-36.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ dependencies:
4141
- pytest-xdist
4242
- pytest-cov
4343
- moto
44+
- hypothesis>=3.58.0
4445
- pip:
4546
- brotlipy
4647
- coverage

ci/travis-37.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ dependencies:
1212
- pytz
1313
- pytest
1414
- pytest-xdist
15+
- hypothesis>=3.58.0

doc/source/contributing.rst

+40
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,46 @@ Tests that we have ``parametrized`` are now accessible via the test name, for ex
820820
test_cool_feature.py::test_series[int8] PASSED
821821
822822
823+
.. _using-hypothesis:
824+
825+
Using ``hypothesis``
826+
~~~~~~~~~~~~~~~~~~~~
827+
828+
Hypothesis is a library for property-based testing. Instead of explicitly
829+
parametrizing a test, you can describe *all* valid inputs and let Hypothesis
830+
try to find a failing input. Even better, no matter how many random examples
831+
it tries, Hypothesis always reports a single minimal counterexample to your
832+
assertions - often an example that you would never have thought to test.
833+
834+
See `Getting Started with Hypothesis <https://hypothesis.works/articles/getting-started-with-hypothesis/>`_
835+
for more of an introduction, then `refer to the Hypothesis documentation
836+
for details <https://hypothesis.readthedocs.io/en/latest/index.html>`_.
837+
838+
.. code-block:: python
839+
840+
import json
841+
from hypothesis import given, strategies as st
842+
843+
any_json_value = st.deferred(lambda: st.one_of(
844+
st.none(), st.booleans(), st.floats(allow_nan=False), st.text(),
845+
st.lists(any_json_value), st.dictionaries(st.text(), any_json_value)
846+
))
847+
848+
@given(value=any_json_value)
849+
def test_json_roundtrip(value):
850+
result = json.loads(json.dumps(value))
851+
assert value == result
852+
853+
This test shows off several useful features of Hypothesis, as well as
854+
demonstrating a good use-case: checking properties that should hold over
855+
a large or complicated domain of inputs.
856+
857+
To keep the Pandas test suite running quickly, parametrized tests are
858+
preferred if the inputs or logic are simple, with Hypothesis tests reserved
859+
for cases with complex logic or where there are too many combinations of
860+
options or subtle interactions to test (or think of!) all of them.
861+
862+
823863
Running the test suite
824864
----------------------
825865

doc/source/install.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ pandas is equipped with an exhaustive set of unit tests, covering about 97% of
202202
the code base as of this writing. To run it on your machine to verify that
203203
everything is working (and that you have all of the dependencies, soft and hard,
204204
installed), make sure you have `pytest
205-
<http://docs.pytest.org/en/latest/>`__ >= 3.6 and run:
205+
<http://docs.pytest.org/en/latest/>`__ >= 3.6 and `Hypothesis
206+
<https://hypothesis.readthedocs.io/>`__ >= 3.58, then run:
206207

207208
::
208209

doc/source/whatsnew/v0.24.0.txt

+1
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,7 @@ Build Changes
729729
^^^^^^^^^^^^^
730730

731731
- Building pandas for development now requires ``cython >= 0.28.2`` (:issue:`21688`)
732+
- Testing pandas now requires ``hypothesis>=3.58`` (:issue:22280). You can find `the Hypothesis docs here <https://hypothesis.readthedocs.io/en/latest/index.html>`_, and a pandas-specific introduction :ref:`in the contributing guide <using-hypothesis>` .
732733
-
733734

734735
Other

pandas/conftest.py

+34
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,37 @@ def mock():
450450
return importlib.import_module("unittest.mock")
451451
else:
452452
return pytest.importorskip("mock")
453+
454+
455+
# ----------------------------------------------------------------
456+
# Global setup for tests using Hypothesis
457+
458+
from hypothesis import strategies as st
459+
460+
# Registering these strategies makes them globally available via st.from_type,
461+
# which is use for offsets in tests/tseries/offsets/test_offsets_properties.py
462+
for name in 'MonthBegin MonthEnd BMonthBegin BMonthEnd'.split():
463+
cls = getattr(pd.tseries.offsets, name)
464+
st.register_type_strategy(cls, st.builds(
465+
cls,
466+
n=st.integers(-99, 99),
467+
normalize=st.booleans(),
468+
))
469+
470+
for name in 'YearBegin YearEnd BYearBegin BYearEnd'.split():
471+
cls = getattr(pd.tseries.offsets, name)
472+
st.register_type_strategy(cls, st.builds(
473+
cls,
474+
n=st.integers(-5, 5),
475+
normalize=st.booleans(),
476+
month=st.integers(min_value=1, max_value=12),
477+
))
478+
479+
for name in 'QuarterBegin QuarterEnd BQuarterBegin BQuarterEnd'.split():
480+
cls = getattr(pd.tseries.offsets, name)
481+
st.register_type_strategy(cls, st.builds(
482+
cls,
483+
n=st.integers(-24, 24),
484+
normalize=st.booleans(),
485+
startingMonth=st.integers(min_value=1, max_value=12)
486+
))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# -*- coding: utf-8 -*-
2+
"""
3+
Behavioral based tests for offsets and date_range.
4+
5+
This file is adapted from https://github.com/pandas-dev/pandas/pull/18761 -
6+
which was more ambitious but less idiomatic in its use of Hypothesis.
7+
8+
You may wish to consult the previous version for inspiration on further
9+
tests, or when trying to pin down the bugs exposed by the tests below.
10+
"""
11+
12+
import pytest
13+
from hypothesis import given, assume, strategies as st
14+
from hypothesis.extra.pytz import timezones as pytz_timezones
15+
from hypothesis.extra.dateutil import timezones as dateutil_timezones
16+
17+
import pandas as pd
18+
19+
from pandas.tseries.offsets import (
20+
MonthEnd, MonthBegin, BMonthEnd, BMonthBegin,
21+
QuarterEnd, QuarterBegin, BQuarterEnd, BQuarterBegin,
22+
YearEnd, YearBegin, BYearEnd, BYearBegin,
23+
)
24+
25+
# ----------------------------------------------------------------
26+
# Helpers for generating random data
27+
28+
gen_date_range = st.builds(
29+
pd.date_range,
30+
start=st.datetimes(
31+
# TODO: Choose the min/max values more systematically
32+
min_value=pd.Timestamp(1900, 1, 1).to_pydatetime(),
33+
max_value=pd.Timestamp(2100, 1, 1).to_pydatetime()
34+
),
35+
periods=st.integers(min_value=2, max_value=100),
36+
freq=st.sampled_from('Y Q M D H T s ms us ns'.split()),
37+
tz=st.one_of(st.none(), dateutil_timezones(), pytz_timezones()),
38+
)
39+
40+
gen_random_datetime = st.datetimes(
41+
min_value=pd.Timestamp.min.to_pydatetime(),
42+
max_value=pd.Timestamp.max.to_pydatetime(),
43+
timezones=st.one_of(st.none(), dateutil_timezones(), pytz_timezones())
44+
)
45+
46+
# The strategy for each type is registered in conftest.py, as they don't carry
47+
# enough runtime information (e.g. type hints) to infer how to build them.
48+
gen_yqm_offset = st.one_of(*map(st.from_type, [
49+
MonthBegin, MonthEnd, BMonthBegin, BMonthEnd,
50+
QuarterBegin, QuarterEnd, BQuarterBegin, BQuarterEnd,
51+
YearBegin, YearEnd, BYearBegin, BYearEnd
52+
]))
53+
54+
55+
# ----------------------------------------------------------------
56+
# Offset-specific behaviour tests
57+
58+
59+
# Based on CI runs: Always passes on OSX, fails on Linux, sometimes on Windows
60+
@pytest.mark.xfail(strict=False, reason='inconsistent between OSs, Pythons')
61+
@given(gen_random_datetime, gen_yqm_offset)
62+
def test_on_offset_implementations(dt, offset):
63+
assume(not offset.normalize)
64+
# check that the class-specific implementations of onOffset match
65+
# the general case definition:
66+
# (dt + offset) - offset == dt
67+
compare = (dt + offset) - offset
68+
assert offset.onOffset(dt) == (compare == dt)
69+
70+
71+
@pytest.mark.xfail(strict=True)
72+
@given(gen_yqm_offset, gen_date_range)
73+
def test_apply_index_implementations(offset, rng):
74+
# offset.apply_index(dti)[i] should match dti[i] + offset
75+
assume(offset.n != 0) # TODO: test for that case separately
76+
77+
# rng = pd.date_range(start='1/1/2000', periods=100000, freq='T')
78+
ser = pd.Series(rng)
79+
80+
res = rng + offset
81+
res_v2 = offset.apply_index(rng)
82+
assert (res == res_v2).all()
83+
84+
assert res[0] == rng[0] + offset
85+
assert res[-1] == rng[-1] + offset
86+
res2 = ser + offset
87+
# apply_index is only for indexes, not series, so no res2_v2
88+
assert res2.iloc[0] == ser.iloc[0] + offset
89+
assert res2.iloc[-1] == ser.iloc[-1] + offset
90+
# TODO: Check randomly assorted entries, not just first/last
91+
92+
93+
@pytest.mark.xfail(strict=True)
94+
@given(gen_yqm_offset)
95+
def test_shift_across_dst(offset):
96+
# GH#18319 check that 1) timezone is correctly normalized and
97+
# 2) that hour is not incorrectly changed by this normalization
98+
# Note that dti includes a transition across DST boundary
99+
dti = pd.date_range(start='2017-10-30 12:00:00', end='2017-11-06',
100+
freq='D', tz='US/Eastern')
101+
assert (dti.hour == 12).all() # we haven't screwed up yet
102+
103+
res = dti + offset
104+
assert (res.hour == 12).all()

0 commit comments

Comments
 (0)