Skip to content

Add initial property-based tests using Hypothesis #22280

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Aug 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ dist
coverage.xml
coverage_html_report
*.pytest_cache
# hypothesis test database
.hypothesis/

# OS generated files #
######################
Expand Down
1 change: 1 addition & 0 deletions ci/appveyor-27.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ dependencies:
- pytest
- pytest-xdist
- moto
- hypothesis>=3.58.0
1 change: 1 addition & 0 deletions ci/appveyor-36.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ dependencies:
- cython>=0.28.2
- pytest
- pytest-xdist
- hypothesis>=3.58.0
1 change: 1 addition & 0 deletions ci/check_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
'html5lib',
'ipython',
'jinja2'
'hypothesis',
'lxml',
'numexpr',
'openpyxl',
Expand Down
1 change: 1 addition & 0 deletions ci/circle-27-compat.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ dependencies:
- html5lib==1.0b2
- beautifulsoup4==4.2.1
- pymysql==0.6.0
- hypothesis>=3.58.0
2 changes: 2 additions & 0 deletions ci/circle-35-ascii.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ dependencies:
# universal
- pytest
- pytest-xdist
- pip:
- hypothesis>=3.58.0
2 changes: 2 additions & 0 deletions ci/circle-36-locale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ dependencies:
- pytest
- pytest-xdist
- moto
- pip:
- hypothesis>=3.58.0
2 changes: 2 additions & 0 deletions ci/circle-36-locale_slow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ dependencies:
- pytest
- pytest-xdist
- moto
- pip:
- hypothesis>=3.58.0
1 change: 1 addition & 0 deletions ci/environment-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dependencies:
- NumPy
- flake8
- flake8-comprehensions
- hypothesis>=3.58.0
- moto
- pytest>=3.6
- python-dateutil>=2.5.0
Expand Down
1 change: 1 addition & 0 deletions ci/requirements_dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Cython>=0.28.2
NumPy
flake8
flake8-comprehensions
hypothesis>=3.58.0
moto
pytest>=3.6
python-dateutil>=2.5.0
Expand Down
1 change: 1 addition & 0 deletions ci/travis-27-locale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ dependencies:
# universal
- pytest
- pytest-xdist
- hypothesis>=3.58.0
- pip:
- html5lib==1.0b2
- beautifulsoup4==4.2.1
1 change: 1 addition & 0 deletions ci/travis-27.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies:
- pytest
- pytest-xdist
- moto
- hypothesis>=3.58.0
- pip:
- backports.lzma
- cpplint
Expand Down
1 change: 1 addition & 0 deletions ci/travis-35-osx.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ dependencies:
- pytest-xdist
- pip:
- python-dateutil==2.5.3
- hypothesis>=3.58.0
1 change: 1 addition & 0 deletions ci/travis-36-doc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies:
- fastparquet
- feather-format
- html5lib
- hypothesis>=3.58.0
- ipykernel
- ipython
- ipywidgets
Expand Down
1 change: 1 addition & 0 deletions ci/travis-36-numpydev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies:
# universal
- pytest
- pytest-xdist
- hypothesis>=3.58.0
- pip:
- "git+git://github.com/dateutil/dateutil.git"
- "-f https://7933911d6844c6c53a7d-47bd50c35cd79bd838daf386af554a83.ssl.cf2.rackcdn.com"
Expand Down
1 change: 1 addition & 0 deletions ci/travis-36-slow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ dependencies:
- pytest
- pytest-xdist
- moto
- hypothesis>=3.58.0
1 change: 1 addition & 0 deletions ci/travis-36.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies:
- pytest-xdist
- pytest-cov
- moto
- hypothesis>=3.58.0
- pip:
- brotlipy
- coverage
Expand Down
1 change: 1 addition & 0 deletions ci/travis-37.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ dependencies:
- pytz
- pytest
- pytest-xdist
- hypothesis>=3.58.0
40 changes: 40 additions & 0 deletions doc/source/contributing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,46 @@ Tests that we have ``parametrized`` are now accessible via the test name, for ex
test_cool_feature.py::test_series[int8] PASSED


.. _using-hypothesis:

Using ``hypothesis``
~~~~~~~~~~~~~~~~~~~~

Hypothesis is a library for property-based testing. Instead of explicitly
parametrizing a test, you can describe *all* valid inputs and let Hypothesis
try to find a failing input. Even better, no matter how many random examples
it tries, Hypothesis always reports a single minimal counterexample to your
assertions - often an example that you would never have thought to test.

See `Getting Started with Hypothesis <https://hypothesis.works/articles/getting-started-with-hypothesis/>`_
for more of an introduction, then `refer to the Hypothesis documentation
for details <https://hypothesis.readthedocs.io/en/latest/index.html>`_.

.. code-block:: python

import json
from hypothesis import given, strategies as st

any_json_value = st.deferred(lambda: st.one_of(
st.none(), st.booleans(), st.floats(allow_nan=False), st.text(),
st.lists(any_json_value), st.dictionaries(st.text(), any_json_value)
))

@given(value=any_json_value)
def test_json_roundtrip(value):
result = json.loads(json.dumps(value))
assert value == result

This test shows off several useful features of Hypothesis, as well as
demonstrating a good use-case: checking properties that should hold over
a large or complicated domain of inputs.

To keep the Pandas test suite running quickly, parametrized tests are
preferred if the inputs or logic are simple, with Hypothesis tests reserved
for cases with complex logic or where there are too many combinations of
options or subtle interactions to test (or think of!) all of them.


Running the test suite
----------------------

Expand Down
3 changes: 2 additions & 1 deletion doc/source/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,8 @@ pandas is equipped with an exhaustive set of unit tests, covering about 97% of
the code base as of this writing. To run it on your machine to verify that
everything is working (and that you have all of the dependencies, soft and hard,
installed), make sure you have `pytest
<http://docs.pytest.org/en/latest/>`__ >= 3.6 and run:
<http://docs.pytest.org/en/latest/>`__ >= 3.6 and `Hypothesis
<https://hypothesis.readthedocs.io/>`__ >= 3.58, then run:

::

Expand Down
1 change: 1 addition & 0 deletions doc/source/whatsnew/v0.24.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,7 @@ Build Changes
^^^^^^^^^^^^^

- Building pandas for development now requires ``cython >= 0.28.2`` (:issue:`21688`)
- 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>` .
-

Other
Expand Down
34 changes: 34 additions & 0 deletions pandas/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,37 @@ def mock():
return importlib.import_module("unittest.mock")
else:
return pytest.importorskip("mock")


# ----------------------------------------------------------------
# Global setup for tests using Hypothesis

from hypothesis import strategies as st
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will put a hard dependency on hypothesis for testing. Are we OK with that? After some thought, I think it's fine. It's a well-maintained project, and working around it in the test suite seems silly.

If we're ok with that, then @Zac-HD could you update

  • pandas/util/_tester.py to have a nice message if either pytest or hypothesis is missing?
  • pandas/ci/check_imports.py to ensure hypothesis is not imported with the main import pandas?
  • doc/source/whatsnew/0.24.0.txt with a small subsection saying hypothesis is required for running the tests (with a link to the hypothesis docs :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My read of the reviews so far is that @jreback was in favor of a mandatory dependency (also my recommendation), and you're now in favor too.

I've therefore made the relevant changes and it's all ready to go 🎉

(though one build on Travis has errored out, the tests passed until the timeout)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, I still think we need to a) remove hypothesis from 1 build (the same one we have removed moto from is good). and use pyimportor.skip('hypthoesis'). The reason is not for our CI really, rather so when a user does pd.test() is doesn't fail, rather it will just skip those tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jreback there are two problems with making Hypothesis optional for pd.test():

  1. It makes adding further Hypothesis tests - eg for serialisation round-trips, timedeltas, or reshaping logic - much harder. They'd have to be in separate files, guard any global setup and configuration, handle import-or-skips, etc.
  2. It forces us to choose to either duplicate tests, or skip them at runtime.

That doesn't make it completely unreasonable, I'd prefer to just have the dependency - and I've been using Pandas for much longer than Hypothesis!

TLDR - what's wrong with putting Hypothesis in the same category as pytest?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think moto is a bit different, since it's relatively unimportant to mainline pandas, and so is easy to work around.

IMO, hypothesis should be treated the same as pytest.


# Registering these strategies makes them globally available via st.from_type,
# which is use for offsets in tests/tseries/offsets/test_offsets_properties.py
for name in 'MonthBegin MonthEnd BMonthBegin BMonthEnd'.split():
cls = getattr(pd.tseries.offsets, name)
st.register_type_strategy(cls, st.builds(
cls,
n=st.integers(-99, 99),
normalize=st.booleans(),
))

for name in 'YearBegin YearEnd BYearBegin BYearEnd'.split():
cls = getattr(pd.tseries.offsets, name)
st.register_type_strategy(cls, st.builds(
cls,
n=st.integers(-5, 5),
normalize=st.booleans(),
month=st.integers(min_value=1, max_value=12),
))

for name in 'QuarterBegin QuarterEnd BQuarterBegin BQuarterEnd'.split():
cls = getattr(pd.tseries.offsets, name)
st.register_type_strategy(cls, st.builds(
cls,
n=st.integers(-24, 24),
normalize=st.booleans(),
startingMonth=st.integers(min_value=1, max_value=12)
))
104 changes: 104 additions & 0 deletions pandas/tests/tseries/offsets/test_offsets_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Behavioral based tests for offsets and date_range.

This file is adapted from https://github.com/pandas-dev/pandas/pull/18761 -
which was more ambitious but less idiomatic in its use of Hypothesis.

You may wish to consult the previous version for inspiration on further
tests, or when trying to pin down the bugs exposed by the tests below.
"""

import pytest
from hypothesis import given, assume, strategies as st
from hypothesis.extra.pytz import timezones as pytz_timezones
from hypothesis.extra.dateutil import timezones as dateutil_timezones

import pandas as pd

from pandas.tseries.offsets import (
MonthEnd, MonthBegin, BMonthEnd, BMonthBegin,
QuarterEnd, QuarterBegin, BQuarterEnd, BQuarterBegin,
YearEnd, YearBegin, BYearEnd, BYearBegin,
)

# ----------------------------------------------------------------
# Helpers for generating random data

gen_date_range = st.builds(
pd.date_range,
start=st.datetimes(
# TODO: Choose the min/max values more systematically
min_value=pd.Timestamp(1900, 1, 1).to_pydatetime(),
max_value=pd.Timestamp(2100, 1, 1).to_pydatetime()
),
periods=st.integers(min_value=2, max_value=100),
freq=st.sampled_from('Y Q M D H T s ms us ns'.split()),
tz=st.one_of(st.none(), dateutil_timezones(), pytz_timezones()),
)

gen_random_datetime = st.datetimes(
min_value=pd.Timestamp.min.to_pydatetime(),
max_value=pd.Timestamp.max.to_pydatetime(),
timezones=st.one_of(st.none(), dateutil_timezones(), pytz_timezones())
)

# The strategy for each type is registered in conftest.py, as they don't carry
# enough runtime information (e.g. type hints) to infer how to build them.
gen_yqm_offset = st.one_of(*map(st.from_type, [
MonthBegin, MonthEnd, BMonthBegin, BMonthEnd,
QuarterBegin, QuarterEnd, BQuarterBegin, BQuarterEnd,
YearBegin, YearEnd, BYearBegin, BYearEnd
]))


# ----------------------------------------------------------------
# Offset-specific behaviour tests


# Based on CI runs: Always passes on OSX, fails on Linux, sometimes on Windows
@pytest.mark.xfail(strict=False, reason='inconsistent between OSs, Pythons')
@given(gen_random_datetime, gen_yqm_offset)
def test_on_offset_implementations(dt, offset):
assume(not offset.normalize)
# check that the class-specific implementations of onOffset match
# the general case definition:
# (dt + offset) - offset == dt
compare = (dt + offset) - offset
assert offset.onOffset(dt) == (compare == dt)


@pytest.mark.xfail(strict=True)
@given(gen_yqm_offset, gen_date_range)
def test_apply_index_implementations(offset, rng):
# offset.apply_index(dti)[i] should match dti[i] + offset
assume(offset.n != 0) # TODO: test for that case separately

# rng = pd.date_range(start='1/1/2000', periods=100000, freq='T')
ser = pd.Series(rng)

res = rng + offset
res_v2 = offset.apply_index(rng)
assert (res == res_v2).all()

assert res[0] == rng[0] + offset
assert res[-1] == rng[-1] + offset
res2 = ser + offset
# apply_index is only for indexes, not series, so no res2_v2
assert res2.iloc[0] == ser.iloc[0] + offset
assert res2.iloc[-1] == ser.iloc[-1] + offset
# TODO: Check randomly assorted entries, not just first/last


@pytest.mark.xfail(strict=True)
@given(gen_yqm_offset)
def test_shift_across_dst(offset):
# GH#18319 check that 1) timezone is correctly normalized and
# 2) that hour is not incorrectly changed by this normalization
# Note that dti includes a transition across DST boundary
dti = pd.date_range(start='2017-10-30 12:00:00', end='2017-11-06',
freq='D', tz='US/Eastern')
assert (dti.hour == 12).all() # we haven't screwed up yet

res = dti + offset
assert (res.hour == 12).all()
Loading