Делаем тесты частью приложения

Автор оригинала: Nikita Sobolev
  • Перевод

Сегодня я собираюсь обсудить абсолютно новую для многих пользователей (особенно для питонистов) идею: интеграцию тестов в ваше приложение.

Итак, давайте начнем.

Текущий статус

На сегодняшний день проблема взаимосвязи исходного кода и тестов стоит таким образом, что вы отправляете исходный код пользователям своей библиотеки и чаще всего вообще не включаете в нее ваши тесты.

Иногда люди еще прикрепляют к релизу папку с тестами, так сказать, на всякий случай. В большинстве случаев они бесполезны для конечного пользователя.

Однако наиболее критично то, что наши пользователи часто оказываются в ситуации, когда им приходится заново переписывать некоторые тесты связанного с библиотекой функционала.

Приведу пример: у вас есть Django View, предназначенное только для авторизованных пользователей.

from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse

@login_required
def my_view(request: HttpRequest) -> HttpRespose:
    ...

Итак, нам потребовалось бы написать как минимум два теста:

  1. Для варианта успешной авторизации и нашей бизнес-логики

  2. Для варианта неудачной авторизации

Разве не лучше было бы, если бы мы могли просто пропустить второй вариант и использовать доступную тестовую логику, которую можно повторно использовать непосредственно из библиотеки?

Представьте себе 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()

Потенциально он может выполнять:

  1. Тесты для неразрешенных методов HTTP

  2. Тесты для разрешенных методов HTTP

  3. Тестирование на наличие заголовка Cache-Controlс соответствующим верным значением

  4. Тестирование на запрет доступа для неавторизованных пользователей

  5. Другие варианты тестирования

Все, что вам нужно сделать, — это протестировать созданный вами «зеленый путь» с возможностью настройки отдельных сгенерированных сценариев тестирования, например, возврата настраиваемого 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, которая позволяет нам генерировать множество случайных данных.

Итак, вот что мы собираемся делать:

  1. Мы передадим определение класса, в котором определено свойство _laws

  2. hypothesis получит все наши законы

  3. Для каждого закона мы сгенерируем уникальный тестовый сценарий

  4. Для каждого тестового сценария мы сгенерируем множество входных данных, чтобы удостовериться, что закон выполняется в отношении всех возможных входных данных

Исходный код для тех, кому интересны детали реализации.

Мы должны предоставить простой 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 может быть крайне полезной возможностью.

При этом сценарии использования действительно крайне разнообразны! Как я продемонстрировал, они могут варьироваться от платформ веб-приложений до инструментов архитектуры и (около-)математических библиотек.

В будущем мне хотелось бы видеть больше таких инструментов! Надеюсь, мне удалось рассказать о возможных преимуществах для нынешних и будущих авторов библиотек.

Комментарии 2

    +2
    Сегодня я собираюсь обсудить абсолютно новую для многих пользователей (особенно для питонистов) идею: интеграцию тестов в ваше приложение.

    Вот сейчас обидно было.
      +1
      Ну для многих — не значит, что для всех.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое