Допустим, у нас есть система, в которой пользователи хранятся вместе с их предпочтениями и адресами электронной почты. Помимо GDPR, мы хотим идентифицировать этих пользователей по их электронной почте, установить некоторые свойства и т. Д. Итак, с помощью без тегов, мы создали два репозитория с абстрактным монадическим контекстом:

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

Детали реализации не имеют особого значения. Достаточно сказать, что он отображает абстрактные операции в «реальные». Важно то, что его нужно протестировать в какой-то момент, чтобы гарантировать правильность сопоставления, обработку нарушения ограничений и т. Д.

Типичное интеграционное тестирование

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

А затем, используя всю написанную вами тестовую инфраструктуру, вы можете писать интеграционные тесты:

К сожалению, написание этих тестов очень утомительно, и возникает соблазн срезать некоторые углы, пропустив некоторые важные случаи. Например, вы бы проверили, что произошло, когда вы сохранили строку со всеми пробелами? Или различные комбинации, когда части UserProfile отсутствуют? Или все VARCHAR ограничения?

Более того, вам нужно протестировать множество неявных взаимодействий между различными методами. Что должен делать двойной save? Что делать find после успешного create? Если find что-то возвращает, то каково его отношение к getEmails? Все эти факты проверяются путем проверки поведения метода относительно некоторого неявного состояния базы данных. Это достигается за счет подготовки огромного количества приспособлений, тщательно воссоздающих желаемое состояние перед тестом.

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

Принесите некоторую дисциплину

Итак, мы стремимся получить следующее:

  • Пишите меньше
  • Проверить больше
  • Четко говорите о том, как должны вести себя методы алгебры
  • Быть универсальным

Эти вещи кажутся немного противоречивыми. При типичном подходе единственный способ проверить больше - написать больше тестов и фикстур! И как вы можете быть более явным, но при этом более общим, если явное выражение означает написание любых приспособлений, кроме общих? Оказывается, что эти свойства не могут быть получены с помощью типичного подхода, поэтому единственный способ продолжить - изменить подход.

Вместо написания тестов давайте сформулируем законы, которые должны соблюдаться при реализации алгебры. Затем давайте воспользуемся библиотекой автоматической проверки законов Discipline, которая сгенерирует большое количество случайных тестовых примеров с помощью ScalaCheck. Это позволит нам проверить с достаточной уверенностью, что любая реализация соответствует нашим законам.

Таким образом, мы получаем следующие преимущества:

  • Нам не нужно писать тесты - только законы и некоторый код инфраструктуры (генераторы данных, определения равенства).
  • В тестах могут использоваться случаи, которые трудно найти при написании их вручную (например, очень большие строки, пустые значения).
  • Тесты работают независимо от реализации.
  • Законы служат явной документацией поведения.

Посмотрим подробности!

Написание законов

Когда вы смотрите на Emails алгебру, на ум приходят следующие законы:

  1. Для каждого сохраненного электронного письма e find(e) возвращает e.
  2. Для каждого сохраненного электронного письма e known(e) возвращает true.
  3. find соответствует known, т.е. find(e) определяется IFF known(e) is true.
  4. Если дважды сохранить одно и то же электронное письмо, всегда возвращается EmailAlreadyExists ошибка.

Переводя эти законы в операции с использованием этой алгебры, вы получаете (в псевдокоде):

  1. save(e) >> findEmail(e) <-> pure(Some(e))
  2. save(e) >> known(e) <-> pure(true)
  3. findEmail(e).fmap(_.isDefined) <-> known(e)
  4. save(e) *> save(e) <-> pure(Left(EmailAlreadyExists))

В приведенном выше примере мы используем стандартный синтаксис cats, где: a >> b означает a flatMap (_ => b); a *> b означает product(a, b).map(_._2). Мы также используем символ <-> для выражения отношения эквивалента.

ОБНОВЛЕНИЕ: Олег Пыжцов прокомментировал Reddit:

Вы должны быть осторожны, чтобы зафиксировать эффекты в законах, а не только результат. Хорошая лакмусовая бумажка - увидеть, предусматривает ли закон возможный рефакторинг, который ничего не сломает.

Например, я не смог бы слепо заменить этим законом:

сохранить (e) ›› известное (e) ‹-› чистое (истинное)

потому что он полностью убирает эффект сохранения. Правильный закон был бы

сохранить (e) ›› известное (e) ‹-› сохранить (e) ›› pure (true)

Я согласен с его мнением. Во-первых, это делает правильными рассуждения о более длинных выражениях. Таким образом, save(e) *> save(e) <-> pure(Left(EmailAlreadyExists)) следует аналогичным образом переписать как: save(e) *> save(e) <-> save(e) >> pure(Left(EmailAlreadyExists))
Помните об этом совете при работе над своими законами.
Спасибо, Олег!

Точно так же вы можете разработать набор законов для Users алгебры:

  1. Для каждого созданного пользователя u identifyUser(primaryEmail(u)) возвращает u.
  2. Для каждого созданного пользователя u, identifyUser(e) возвращает u IFF e был прикреплен к пользователю u.
  3. Для каждого пользователя u с профилем p создание пользователя и последующее обновление его профиля эквивалентно созданию пользователя с уже обновленным профилем, т. Е. createUser(e, p) >>= (u => updateUserProfile(uid(u), f(p))) <-> createUser(e, f(p)).
  4. Прикрепление n писем через n вызовов к attachEmail эквивалентно вызову attachEmails один раз со сбором всех n-писем.

Чтобы быть полными, мы должны были написать законы, регулирующие поведение остальных методов - find, getEmails и т. Д. Я взял на себя смелость пропустить его для краткости.

Давайте посмотрим, как мы реализуем эти законы в дисциплине «Дисциплина».

Правоприменительные законы

Реализация проверки закона должна быть адаптирована к ScalaCheck для обеспечения автоматизированного тестирования. То есть закон должен быть допустимым свойством ScalaCheck. Для этого мы будем использовать cats-kernel-laws предоставленный IsEq тип. У этого типа двоякая цель. Во-первых, IsEq(lhs, rhs) указывает, что левая часть выражения IsEq эквивалентна его правой части. Во-вторых, он может быть преобразован в ScalaCheck Prop на Discipline. Мы формируем IsEq экземпляров с помощью удобного оператора <->.

Итак, реализация первого закона для алгебры Emails может выглядеть так:

Мы читаем метод следующим образом:

Для любого email: Email выражение algebra.save(email) >> algebra.findEmail(email) должно быть эквивалентно M.pure(Some(email)). И это то, что мы хотим, чтобы каждая реализация алгебры электронной почты уважала.

Мы также подготовим общий набор тестов (называемый Laws в терминологии Discipline):

Теперь мы должны указать ScalaCheck, как создавать электронные письма. Я рекомендую сначала прочитать руководство по ScalaCheck, но по сути оно очень простое. В неявном объеме тестов должен быть экземпляр Arbitrary[Email]:

Наконец, нам нужно указать, как проверить эквивалентность для данной монады M. Для контекста DBIO нам нужно будет запустить обе стороны действия, которое мы проверяем против тестовой базы данных в откатной транзакции (чтобы дать каждому тесту чистое состояние базы данных), а затем сравнить выходные данные. (Это адаптация фрагмента из slick-cats.)

Учитывая все эти части, мы наконец можем протестировать нашу реализацию:

Теперь у нас есть набор тестов, который проверяет, можно ли сохранить кучу случайных писем в db и, впоследствии, выполнить поиск. Проверяется не только логика этих операций, но и выявляются ошибки, возникающие из-за неправильного сопоставления схемы БД. Посмотрим, как выглядит весь комплекс:

Вы можете видеть, что обычно вам нужно писать дополнительные генераторы по мере добавления тестов. Но поскольку они являются компонуемыми, написать их довольно просто и зависит от размера вашего домена. (Вам нужно только написать новые генераторы для предметно-ориентированных вещей, которые ScalaCheck не умеет имитировать.) Еще одна идея, которую, возможно, стоит изучить, - это автоматическое создание Arbitrary экземпляров для обычных типов продукта / суммы с помощью Magnolia.

Вам может быть интересно, почему мы заинтересованы в создании функции? Иногда можно составить компактные законы, заявив, что: закон выполняется при любом преобразовании f. Так же, как мы сделали в findKnownConsistency тесте def findKnownConsistency(email: Email, f: Email => Email).

Его можно читать так: для любого сохраненного электронного письма e и произвольного преобразования f результат find определяется для f(e), если known(f(e)) истинно. Это более сильное утверждение, чем утверждение, что закон действует для любого сохраненного электронного письма e, что позволяет пропустить отдельную проверку случая, когда find возвращает None.

Чтобы понять, почему, давайте рассмотрим, что f - это функция, которая добавляет xyz к части почтового ящика адреса электронной почты. Эквивалентность должна соблюдаться для выбора f, и действительно, save(email) >> find(Email(s"xyz$email")).map(_.isDefined) это None, а save(email) >> known(Email(s"xyz$email")) это false. В качестве альтернативы, когда f является функцией идентификации, мы ожидаем, что find вернет Some и known вернет true. Таким образом, мы объединили два дела в один закон.

Заключение

Я надеюсь, что моя статья убедила вас в том, что тестирование в без тегов должно основываться на абстрактном законе, а не на специальных тестовых примерах. Можете ли вы вспомнить случаи, когда этот подход уступал бы написанию тестов вручную? Конечно, писать законы сложнее, чем придумывать кучу тестовых примеров, и к этому нужно привыкнуть.

По сути, все классы типов имеют законы. Эти законы ограничивают реализации для данного типа и могут быть использованы для рассуждений об общем коде. typelevel.org