QuickCheck в Python

Проверка гипотез и поиск ошибок

Шорин Александр / @kxepal

О чем пойдет речь:

  • Почему наши тесты не находят баги
  • Что такое QuickCheck...
  • ...и как его неправильно портировать
  • Знакомство с Hypothesis
  • Время офигенных историй

В чем проблема наших тестов?

Низкое качество кода


def test_fetch_doc_open_revs(self):
    docid = 'docid'
    docsatts = [({'_id': docid, '_rev': '1-ABC'}, None),
                ({'_id': docid, '_rev': '1-CDE'}, None),
                ({'_id': docid, '_rev': '1-EDC'}, None)]
    revs = [doc['_rev'] for doc, _ in docsatts]

    batch = yield from self.worker.fetch_doc_open_revs(...)
    self.assertTrue(self.source.open_doc_revs.called)

    args, kwargs = self.source.open_doc_revs.call_args
    self.assertEqual((docid, revs), args[:2])
    self.assertEqual({'atts_since': [], 'latest': True, 'revs': True},
                     kwargs)
    self.assertEqual(batch, list(list(zip(*docsatts))[0]))  # oh my...
                

Километры копипасты шаблонных кейсов


def test_attfncontlz(self):
    params = {'filename*0': 'foo', 'filename*01': 'bar'}
    self.assertEqual('foo', content_disposition_filename(params))

def test_attfncontnc(self):
    params = {'filename*0': 'foo', 'filename*2': 'bar'}
    self.assertEqual('foo', content_disposition_filename(params))

def test_attfnconts1(self):
    params = {'filename*1': 'foo', 'filename*2': 'bar'}
    self.assertEqual(None, content_disposition_filename(params))

def test_attfnboth(self):
    params = {'filename': 'foo-ae.html', 'filename*': 'foo-ä.html'}
    self.assertEqual('foo-ä.html',content_disposition_filename(params))
                

Мы можем решить проблему копипасты...


@pytest.mark.parametrize(('params', 'result'), [
    ({'filename*0': 'foo', 'filename*01': 'bar'}, 'foo'),
    ({'filename*0': 'foo', 'filename*2': 'bar'}, 'foo'),
    ({'filename*1': 'foo', 'filename*2': 'bar'}, None),
    ({'filename': 'foo-ae.html', 'filename*': 'foo-ä.html'},
     'foo-ä.html'),
])
def test_content_disposition_filename(params, result):
    assert result == content_disposition_filename(params)
                

Можем ли мы проверить все допустимые случаи?

+----------------------------------------------------------------------+
| ВЕЛИКОЕ ВСЁ                                                          |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                                      |
|                                                +---------------------+
|                                                | Приемлемые данные   |
|                                                |                   Х |
+------------------------------------------------+-------------------^-+
                                                                     |
                                                   Наши тесты -------+
                

Bug Driven Development

Bug. Test. Fix. Repeat.

Резюмируем

  • Мы не тестируем наш код, мы закрепляем его поведение
  • Мы не способны описать все пограничные случаи
  • Баги всегда впереди нас

Решение все время было у нас перед глазами


@asyncio.coroutine
def exists(self, rev=None, *, auth=None):
    """Checks if `document exists`_ in the database. Assumes success
    on receiving response with `200 OK` status.

    :param str rev: Document revision
    :param :class:`aiocouchdb.authn.AuthProvider` auth:

    :rtype: bool
    """
    ...
                

QuickCheck

  • Написан на Haskell в 1999г.
  • Основывается на научно-исследовательских работах
  • Позиционируется как библиотека для автоматического тестирование функций на основе спецификаций и свойств данных
  • Портирован на Scala, Erlang, Clojure, JavaScript...
  • Продвигается компанией Quviq
  • http://www.cse.chalmers.se/~rjmh/QuickCheck/

Как оно работает (примерно)


               passed
  +-----------------------+
  v                       |
+-----------+  sample   +------+  failed   +----------+     +--------+
| Generator | --------> | Test | --------> | Shrinker | --> | Report |
+-----------+           +------+           +----------+     +--------+
  |                       ^       sample     |                ^
  |                       +------------------+                |
  |                                                           |
  +-----------------------------------------------------------+
                                success
                

Выглядит просто?

НЕЛЬЗЯ ПРОСТО ВЗЯТЬ

И НАПИСАТЬ СВОЙ QUICKCHECK

Тысячи их!

  • https://github.com/agrif/pyquickcheck
  • https://github.com/Cue/qc
  • https://github.com/dbravender/qc
  • https://github.com/futoase/PyQCheck
  • https://github.com/JesseBuesking/pythoncheck
  • https://github.com/markchadwick/paycheck
  • https://github.com/msoedov/quick.py
  • https://github.com/npryce/python-factcheck
  • https://github.com/Xion/pyqcy
  • https://github.com/zombiecalypse/qcc
  • https://pypi.python.org/pypi/pytest-quickcheck
  • ...

Типичные ошибки

  • Слепое копирование Haskell реализации
  • Использование типов в качестве генераторов
  • Отсутствующий или же "глупый" shrinking
  • Заброшенные или же в зачаточном состоянии

PyQCheck


from pyqcheck import PyQCheck, Arbitrary

def eq(x,y):
  return x * y == y * x and x + y == y + x

PyQCheck(verbose=True).add(
  Arbitrary('boolean', 'boolean').property(
    '!(x || y) == !x && !y',
    lambda x, y: (not(x or y)) == ((not x) and (not y))
  )
).add(
  Arbitrary('integer', 'integer').property(
    'x * y == y * x and x + y == y + x', eq
  )
).run(10).result() # run(10) is test count == 10
                

Hypothesis [haɪˈpɒθɪsɪs]

Автор: David R. MacIver

https://github.com/DRMacIver/hypothesis

Что умеет

  • Генерация всех основных типов данных
  • Рекурсивные типы
  • State Machine тесты
  • Интегрирован с Fake Factory, NumPy, Django, pytest
  • Позволяет конструировать свои стратегии без погружения в детали


http://hypothesis.readthedocs.org/en/master/data.html

Генерация данных


import math
from hypothesis import strategies as st

def nulls(): return st.none()
def booleans(): return st.booleans()
def numbers(): return st.integers() | st.floats()
def strings(): return st.text()
def arrays(elements): return st.lists(elements)
def objects(elements): return st.dictionaries(strings(), elements)
def values():
    simple_values = nulls() | booleans() | numbers() | strings()
    return (simple_values
            | st.recursive(simple_values,
                           lambda children: arrays(children)
                                            | objects(children)))

Генерация данных


>>> doc = json_st.objects(json_st.values())
>>> doc.example()
{'G 〙G\u202fn〙G𝞠\u202f_n( n(nn_ n': 9.943339378805967e-309}
>>> doc.example()
{'': None, '\x85': '', '\U00014481': None,
'\u3000': -2.45410883359415e-309, '𒈏\x85': 1.5564453946197205e-308,
'I': '⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃',
'\u3000\u2029': -9.05230966734913e-309, '\U00014481\U00014481〯ⁱ': None,
'Nj': -1.80149788818e-311, '々〯_': -1.414261190585428e+202,
 '\u2029ⁱNj': '⸃⸃⸃⸃⸃⸃⸃⸃⸃', '〯\u2029': inf, '々': '⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃',
'\u3000_': -1.0065151140507456e+206, 'Nj々': None, '々ⁱ': None,
'I\U00014481': -1.2296585975031088e+145, '\x80': '⸃⸃⸃⸃⸃',
'\x85ⁱ𒈏\x80\x80Nj': -6.438869672267061e+116, '𒈏〯': None,
'\u3000\x80': None, '\u2029\x80Nj': -698356955173.6532, '〯': '⸃⸃',
'々\x85': None, '\x85ⁱ\U00014481': None, '_〯': None, 'ⁱ': None,
'_\u3000_': '⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃⸃'}
                

Поиск ошибок


>>> @given(json_st.objects(json_st.values()))
... def test_json(doc):
...   assert json.loads(json.dumps(doc)) == doc
>>> test_json()
Falsifying example: test_json(doc={'': nan})
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in test_json
  File "./hypothesis/core.py", line 583, in wrapped_test
    print_example=True, is_final=True
  File "./hypothesis/executors/executors.py", line 25, in default_executor
    return function()
  File "./hypothesis/core.py", line 365, in run
    return test(*args, **kwargs)
  File "<stdin>", line 3, in test_json
AssertionError
                

Поиск ошибок


>>> @given(json_st.objects(json_st.values()),
           settings=Settings(verbosity=Verbosity.verbose))
... def test_json(doc):
...   assert json.loads(json.dumps(doc)) == doc
...
>>> test_json()
                

Поиск ошибок


Trying example: test_json(doc={})
Trying example: test_json(doc={'': True})
Trying example: test_json(doc={'': None})
Trying example: test_json(doc={'': False})
Trying example: test_json(doc={'': -43203256341979896423482879160843})
Trying example: test_json(doc={'': 24})
Trying example: test_json(doc={'': 9})
...
Trying example: test_json(doc={'': 397})
Trying example: test_json(doc={'': 100440})
Trying example: test_json(doc={'': 30323947834323202215971170911015})
Trying example: test_json(doc={'': 0.0})
Trying example: test_json(doc={'': inf})
Trying example: test_json(doc={'': -inf})
Successfully shrunk example 27 times
Falsifying example: test_json(doc={'': nan})
                

Генерация данных


import math
from hypothesis import strategies as st

def nulls(): return st.none()
def booleans(): return st.booleans()
def numbers(): return st.integers() | st.floats().filter(math.isfinite)
def strings(): return st.text()
def arrays(elements): return st.lists(elements)
def objects(elements): return st.dictionaries(strings(), elements)
def values():
    simple_values = nulls() | booleans() | numbers() | strings()
    return (simple_values
            | st.recursive(simple_values,
                           lambda children: arrays(children)
                                            | objects(children)))

База примеров

Hypothesis сохраняет найденные ошибки в SQLite базу для последующего воспроизведения.

База примеров

(альтернативная реализация)

 +-------------+   replication   +-------------+   store   +-----------+
 |  CouchDB A  | <-------------> |  CouchDB B  | <-------> | CI server |
 +-------------+                 +-------------+           +-----------+
   ^                                         ^
   | store                             store |
   v                                         v
 +-------------+                 +-------------+
 | Developer A |                 | Developer B |
 +-------------+                 +-------------+

                
https://github.com/DRMacIver/hypothesis/issues/178

Профили и настройки


>>> from hypothesis import Settings
>>> Settings.register_profile("ci", Settings(max_examples=1000))
>>> Settings.register_profile("dev", Settings(max_examples=10))
>>> Settings.register_profile("debug", Settings(max_examples=10, verbosity=Verbosity.verbose))
>>> Settings.load_profile(os.getenv(u'HYPOTHESIS_PROFILE', 'default'))

Что хорошо тестируется

  • Структуры данных любой сложности
  • Реализации протоколов, парсеров, базы данных
  • Любые чистые функции
  • Детерминированные API

Полезные ссылки

  • Hypothesis
    https://github.com/DRMacIver/hypothesis
  • Hypothesis Talks
    https://github.com/DRMacIver/hypothesis-talks
  • Conjecture
    https://github.com/DRMacIver/conjecture
  • PYCON UK 2015: Finding more bugs with less work
    https://www.youtube.com/watch?v=62ubHXzD8tM
  • How I handled Erlang R18 Maps with QuickCheck
    https://vimeo.com/143849945

Спасибо за внимание!