Сегодня я собираюсь обсудить абсолютно новую для многих пользователей (особенно для питонистов) идею: интеграцию тестов в ваше приложение.
Итак, давайте начнем.
Текущий статус
На сегодняшний день проблема взаимосвязи исходного кода и тестов стоит таким образом, что вы отправляете исходный код пользователям своей библиотеки и чаще всего вообще не включаете в нее ваши тесты.
Иногда люди еще прикрепляют к релизу папку с тестами, так сказать, на всякий случай. В большинстве случаев они бесполезны для конечного пользователя.
Однако наиболее критично то, что наши пользователи часто оказываются в ситуации, когда им приходится заново переписывать некоторые тесты связанного с библиотекой функционала.
Приведу пример: у вас есть Django View, предназначенное только для авторизованных пользователей.
from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
@login_required
def my_view(request: HttpRequest) -> HttpRespose:
...
Итак, нам потребовалось бы написать как минимум два теста:
Для варианта успешной авторизации и нашей бизнес-логики
Для варианта неудачной авторизации
Разве не лучше было бы, если бы мы могли просто пропустить второй вариант и использовать доступную тестовую логику, которую можно повторно использовать непосредственно из библиотеки?
Представьте себе API наподобие:
# tests/test_views/test_my_view.py
from myapp.views import my_view
def test_authed_successfully(user):
"""Test case for our own logic."""
# Not authed case:
my_view.test_not_authed()
А затем – вуаля – мы получаем второй сценарий использования, который тестируем с помощью всего одной строчки кода!
И это еще не все. Например, в Django может быть несколько декораторов функции для выполнения нескольких задач. Представьте себе такую ситуацию:
from django.views.decorators.cache import never_cache
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
@require_http_methods(['GET', 'POST'])
@login_required
@never_cache
def my_view(request: HttpRequest) -> HttpRespose:
...
Итак, API можно немного усовершенствовать посредством включения тестов для всех возможных случаев:
# tests/test_views/test_my_view.py
from myapp.views import my_view
my_view.run_tests()
Потенциально он может выполнять:
Тесты для неразрешенных методов HTTP
Тесты для разрешенных методов HTTP
Тестирование на наличие заголовка
Cache-Control
с соответствующим верным значениемТестирование на запрет доступа для неавторизованных пользователей
Другие варианты тестирования
Все, что вам нужно сделать, — это протестировать созданный вами «зеленый путь» с возможностью настройки отдельных сгенерированных сценариев тестирования, например, возврата настраиваемого HTTP-кода для неавторизованных пользователей.
Плохая новость заключается в том, что обсуждаемого API не существует. И, вероятно, он никогда и не появится в Django.
Однако есть и другие менее известные проекты (которые я помогаю поддерживать), в которых уже реализованы эти функции. Давайте рассмотрим их возможности!
deal
deal — это библиотека для контрактного программирования.
Иными словами, она позволяет добавлять в функции и классы определенные проверки, которые невозможно представить в типах (по крайней мере, в языке Python).
Допустим, у вас есть функция для деления двух положительных целых чисел (которые в Python являются просто int
):
import deal
@deal.pre(lambda a, b: a >= 0 and b >= 0)
@deal.raises(ZeroDivisionError) # this function can raise if `b=0`, it is ok
def div(a: int, b: int) -> float:
return a / b
Вся контрактная информация содержится здесь в определении функции:
@deal.pre(lambda a, b: a >= 0 and b >= 0)
проверяет, что переданные аргументы являются положительными@deal.raises(ZeroDivisionError)
позволяет этой функции в прямой форме вызватьZeroDivisionError
без нарушения контракта, при этом по-умолчанию функции не могут вызывать никакие исключения
Примечание. Аннотации типов, такие как (a: int, b: int) -> float
, все еще не проверяются во время выполнения кода: следует использовать mypy
для выявления ошибок типов.
Использование (помните, что это все еще просто функция!):
div(1, 2) # ok
div(1, 0) # ok, runtime ZeroDivisionError
div(-1, 1) # not ok
# deal.PreContractError: expected a >= 0 and b >= 0 (where a=-1, b=1)
Хорошо, с простым сценарием использования все ясно. А теперь давайте специально добавим ошибку в эту функцию:
import deal
@deal.pre(lambda a, b: a >= 0 and b >= 0)
@deal.raises(ZeroDivisionError) # this function can raise if `b=0`, it is ok
def div(a: int, b: int) -> float:
if a > 50: # Custom, in real life this would be a bug in our logic:
raise Exception('Oh no! Bug happened!')
return a / b
К счастью, deal
соответствует основной идее этой статьи и включает в себя тесты. Все, что нам нужно для их запуска, — написать всего один тестовый сценарий:
import deal
from my_lib import div
@deal.cases(div) # That's all we have to do to test deal-based functions!
def test_div(case: deal.TestCase) -> None:
case()
Мы получим вот такой результат:
» pytest test_deal.py
============================= test session starts ==============================
collected 1 item
test_deal.py F [100%]
=================================== FAILURES ===================================
___________________________________ test_div ___________________________________
a = 51, b = 0
@deal.raises(ZeroDivisionError)
@deal.pre(lambda a, b: a >= 0 and b >= 0)
def div(a: int, b: int) -> float:
if a > 50:
> raise Exception('Oh no! Bug happened!')
E Exception: Oh no! Bug happened!
test_deal.py:8: Exception
============================== 1 failed in 0.35s ===============================
Как видите, наши тесты обнаружили ошибку! Но как?
Тут возникает множество вопросов:
Откуда мы получили данные для теста? Они были взяты из другой замечательной библиотеки под названием hypothesis. Она позволяет генерировать множество различных тестовых данных в соответствии с определенными правилами, которые мы определяем.
В рассматриваемом случае у нас есть два правила. Первое правило генерирует два аргумента типа int, как определено в def div(a: int, b: int)
. Второе правило заключается в том, что эти целые числа должны быть >= 0
, как определено в @deal.pre(lambda a, b: a >= 0 and b >= 0)
.
Мы можем определять количество генерируемых примеров, а также выполнять другие небольшие настройки. Подробнее см. в документации.
Почему ошибка
ZeroDivisionError
не привела к падению теста, в отличие от необработанногоException
? Потому что именно так работает контрактное программирование: вы четко определяете все возможные случаи. Если происходит что-то странное — контракт нарушен. В нашем примереZeroDivisionError
является условием контракта через декораторdeal.raises
. Итак, мы знаем, что это может произойти (и произойдет). Вот почему мы не рассматриваем это как падение теста, но необработанный Exception не является частью нашего контракта, и мы рассматриваем его как явное падение.Найдет ли он все ошибки в моем коде? Это самый интересный вопрос. И ответ на него — нет. Печально, но факт. В вашем коде может быть бесконечное количество сценариев использования, логики, комбинаций и ошибок. И я точно знаю, что невозможно выявить их все.
В действительности, он обнаружит много ошибок. На мой взгляд, это того стоит.
Мы можем пойти еще дальше и представить наши контракты как Теоремы, которые нужно доказать. Например, у deal есть сопутствующий исследовательский проект — deal-solver, который может в этом помочь. Но это тема для отдельной статьи, так что давайте продолжим.
dry-python/returns
dry-python/returns — это библиотека с примитивами, которая упрощает типизированное функциональное программирование на Python.
В ней имеется ряд интерфейсов, которые наши пользователи могут расширять для создания собственных примитивов/объектов. В недавней статье о Типах высших порядков я показал, как это можно сделать типо-безопасным способом.
Сейчас я собираюсь продемонстрировать, что одного этого недостаточно. И, скорее всего, вам понадобятся дополнительные правила в отношении того, как должны себя вести ваши объекты.
Мы называем это свойство «Законы как значения».
Законы тождества
Давайте начнем с самого простого из имеющихся у нас интерфейсов высшего порядка: Equable. Этот интерфейс позволяет выполнять типобезопасные проверки эквивалентности. Потому что в Python вы можете использовать ==
для всего. Однако наш метод .equals()
позволит проверить только объект того же типа, внутри которого есть реальные значения.
Например:
from returns.io import IO
IO(1) == 1 # type-checks, but pointless, always false
IO(1).equals(1) # does not type-check at all
# error: Argument 1 has incompatible type "int";
# expected "KindN[IO[Any], Any, Any, Any]"
other: IO[int]
IO(1).equals(other) # ok, might be true or false
Вот как это выглядит сейчас:
_EqualType = TypeVar('_EqualType', bound='Equable')
class Equable(object):
@abstractmethod
def equals(self: _EqualType, other: _EqualType) -> bool:
"""Type-safe equality check for values of the same type."""
Допустим, мы хотим создать плохую реализацию для данного интерфейса (из научных соображений):
from returns.interfaces.equable import Equable
class Example(Equable):
def __init__(self, inner_value: int) -> None:
self._inner_value = inner_value
def equals(self, other: 'Example') -> bool:
return False # it breaks how `.equals` is supposed to be used!
Это явно неверно, потому что он всегда возвращает значение False
без фактической проверки inner_value
объекта. При этом он по-прежнему удовлетворяет определению интерфейса: он будет выполнять проверку соответствия типа. Таким образом мы можем сказать, что одного лишь интерфейса недостаточно. Нам также нужно протестировать реализацию.
Однако, равенство имеет математические законы, чтобы отслеживать такие сценарии:
Закон рефлексивности: значение должно быть равно самому себе
Закон симметрии:
a.equals(b) == b.equals(a)
Закон транзитивности: если
a
равноb
, аb
равноc
, тоa
равноc
Конечно, при написании кода для реализации данного интерфейса можем заодно написать и тест, позволяющий установить соответствие нашей реализации данным законам. Или мы можем забыть об этом. Или допустить ошибку в нашей тестовой логике.
Вот почему авторам библиотек важно думать о своих пользователях и предусматривать тесты в своих приложениях.
Например, мы объявляем законы непосредственно в определении интерфейса:
from abc import abstractmethod
from typing import ClassVar, Sequence, TypeVar
from typing_extensions import final
from returns.primitives.laws import (
Law,
Law1,
Law2,
Law3,
Lawful,
LawSpecDef,
law_definition,
)
_EqualType = TypeVar('_EqualType', bound='Equable')
@final
class _LawSpec(LawSpecDef): # LOOKATME: our laws def!
@law_definition
def reflexive_law(
first: _EqualType,
) -> None:
"""Value should be equal to itself."""
assert first.equals(first)
@law_definition
def symmetry_law(
first: _EqualType,
second: _EqualType,
) -> None:
"""If ``A == B`` then ``B == A``."""
assert first.equals(second) == second.equals(first)
@law_definition
def transitivity_law(
first: _EqualType,
second: _EqualType,
third: _EqualType,
) -> None:
"""If ``A == B`` and ``B == C`` then ``A == C``."""
if first.equals(second) and second.equals(third):
assert first.equals(third)
class Equable(Lawful['Equable']):
_laws: ClassVar[Sequence[Law]] = (
Law1(_LawSpec.reflexive_law),
Law2(_LawSpec.symmetry_law),
Law3(_LawSpec.transitivity_law),
)
@abstractmethod
def equals(self: _EqualType, other: _EqualType) -> bool:
"""Type-safe equality check for values of the same type."""
Это то, что я называю «сделать тесты частью приложения»!
Теперь, когда у нас есть законы, остается обеспечить их соблюдение. Но для этого нам нужны некоторые данные. К счастью, у нас есть библиотека hypothesis
, которая позволяет нам генерировать множество случайных данных.
Итак, вот что мы собираемся делать:
Мы передадим определение класса, в котором определено свойство _laws
hypothesis получит все наши законы
Для каждого закона мы сгенерируем уникальный тестовый сценарий
Для каждого тестового сценария мы сгенерируем множество входных данных, чтобы удостовериться, что закон выполняется в отношении всех возможных входных данных
Исходный код для тех, кому интересны детали реализации.
Мы должны предоставить простой API, чтобы конечный пользователь мог сделать все это за один лишь вызов функции! Вот что мы придумали:
# test_example.py
from returns.contrib.hypothesis.laws import check_all_laws
from your_app import Example
check_all_laws(Example, use_init=True)
И вот результат:
» pytest test_example.py
============================ test session starts ===============================
collected 3 items
test_example.py .F. [100%]
=================================== FAILURES ===================================
____________________ test_Example_equable_reflexive_law _____________________
first =
@law_definition
def reflexive_law(
first: _EqualType,
) -> None:
"""Value should be equal to itself."""
> assert first.equals(first)
E AssertionError
returns/interfaces/equable.py:32: AssertionError
========================= 1 failed, 2 passed in 0.22s ==========================
Как мы видим, test_Example_equable_reflexive_law
падает, так как equals
в нашем классе Example
всегда возвращает значение False
, а reflexive_law
, который указывает, что (a == a) is True
не выполняется.
Мы можем отрефакторить Example
чтобы использовать правильную логику с фактической проверкой inner_value
:
class Example(Equable):
def __init__(self, inner_value: int) -> None:
self._inner_value = inner_value
def equals(self, other: 'Example') -> bool:
return self._inner_value == other._inner_value # now we are talking!
И снова запускаем наши тесты:
» pytest test_example.py
============================= test session starts ==============================
collected 3 items
test_example.py ... [100%]
============================== 3 passed in 1.57s ===============================
Однако по факту мы не написали ни одного теста для Example
. Вместо этого мы один раз написали законы для всех будущих реализаций! Вот как выглядит забота о пользователях.
И снова нам помогает потрясающая библиотека hypothesis
посредством генерирования случайных данных, которые мы будем использовать в наших тестах (поэтому пакет и называется returns.contrib.hypothesis.laws
.
Другие функциональные законы
Конечно, Equable
— не единственный интерфейс, который у нас есть в dry-python/returns
у нас много таких, которые охватывают большинство традиционных функциональных инстансов; прочтите нашу документацию, если вам интересно.
Эти интерфейсы помогут людям, если им любопытно, что такое Monad на самом деле, и какие у нее законы.
К определению большинства интерфейсов предусмотрены законы. Благодаря чему, они за минимальное число шагов могут удостовериться в том, что их реализации верны.
Заключение
В некоторых сценариях использования дополнение приложений тестами и специальными API может быть крайне полезной возможностью.
При этом сценарии использования действительно крайне разнообразны! Как я продемонстрировал, они могут варьироваться от платформ веб-приложений до инструментов архитектуры и (около-)математических библиотек.
В будущем мне хотелось бы видеть больше таких инструментов! Надеюсь, мне удалось рассказать о возможных преимуществах для нынешних и будущих авторов библиотек.