В первой части мы рассмотрели примеры тестов, из которых не все одинаково полезны. Затем попытались определиться, что же такое качество ПО, и предложили "распрямлять" код. Рассмотрели классификацию ошибок.


Теперь рассмотрим, как использовать типы, чтобы реализуемая программа сразу гарантированно соответствовала предъявляемым требованиям.





Задание функции таблицей и алгоритмом


С математической точки зрения функция представляет собой отображение A ⇒ B. Одним из способов представления отображения является табличный — в строках таблицы указывается конечный набор пар входных и выходных значений {(a,b)} ⊆ A⨯B. При этом множество входных данных конечно и его мощность равна количеству примеров.


Другим способом является алгоритмический — описывается алгоритм/программа, позволяющая получить выходное значение из входного f: A ⇒ B. Мощность множества входных данных равна мощности типа A.


Табличное представление функции используется в юнит-тестах. Алгоритмическое — в коде. Требования к программному обеспечению могут быть выражены в форме алгоритма, сформулированного на высоком уровне абстракции. Например, "при нажатии на кнопку А данные формы Б сохраняются в базу данных". В коде тот же алгоритм может быть выражен на более низком уровне абстракции и более формально.


Эквивалентность функций


Можно ли убедиться, что две отдельные функции эквивалентны (f ≡ g), то есть для всех входных значений возвращают одинаковые результаты (или производят одинаковые действия)?


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


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


В типичном случае тестовые данные готовятся на основе функциональных требований (функции, заданной на высоком уровне абстракции) и вручную проверяется (либо предполагается), что табличные данные соответствуют требуемой функции (тест ⊆ требования). Затем эти табличные данные используются в юнит-тестах для проверки того, что тестовые данные также не противоречат реализованной функции (тест ⊆ код). Можно ли на основе этих двух свойств сделать вывод о том, что код соответствует требованиям (код ≡ требования)? В общем случае — нет.


Какие дополнительные предположения необходимо сделать, чтобы можно было с определённой степенью уверенности говорить о том, что код соответствует требованиям?


Доказательство эквивалентности


Доказать эквивалентность двух функций, рассматриваемых как чёрные ящики, в общем случае невозможно. Зачастую пространство аргументов имеет больше элементов, чем можно перебрать за разумное время. (В обратную сторону ситуация несколько лучше. Чтобы опровергнуть эквивалентность, достаточно привести единственный пример, на котором функции дадут разный результат. Отсюда, по-видимому, следует рекомендация TDD писать "красный" тест.)


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


Рассмотрим пример.


def f(a: Int): Int =
  CD(B(a))

def g(a: Int): Int =
  D(BC(a))

(здесь имена функций BC и CD соответствуют их реализациям)


Подстановка тела чистой функции (CD или BC) является эквивалентным преобразованием. Аналогично и абстрагирование чистой функции (извлечение выражения в отдельную функцию с добавлением нового имени) тоже является эквивалентным преобразованием.


Выполняя одно из этих эквивалентных преобразований, мы получим промежуточную форму:


def h(a: Int): Int =
  D(C(B(a)))

Тем самым, получается цепочка эквивалентных преобразований f ≡ h ≡ g. ∎


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


Обоснованно утверждать, что какие-либо функции эквивалентны, мы можем только предъявив математическое доказательство их эквивалентности.

Для нас важно рассмотреть, каким образом мы могли бы доказать эквивалентность алгоритма, заданного в требованиях к ПО, и алгоритма, реализованного разработчиками.


Разработчики уже имеют замечательный инструмент, обеспечивающий точное доказательство эквивалентности — компилятор. Если программа собирается, значит имя функции эквивалентно её реализации. Это даёт возможность полностью автоматического доказательства эквивалентности между высокоуровневым описанием алгоритма и конкретной реализацией. Если требования к программе записаны формальным языком и существует цепочка эквивалентных преобразований в работающую программу, то мы автоматически получаем программу, достигающую требуемого результата.


В процессе доказательства эквивалентности мы можем использовать некоторые приёмы, основанные на типах данных.


Гарантии корректности "по построению"


В некоторых доказательствах в математике используется подход "по построению". Т.е. строится мысленная конструкция определённым образом с соблюдением заданных ограничений. Затем оказывается, что эта конструкция обладает требуемыми свойствами в силу того, что на каждом шаге соблюдались ограничения.


Доказательство сделанной работы с помощью типов. 1


Типы данных могут выступать/рассматриваться в качестве логических утверждений, а работающая программа, преобразующая один тип в другой, может служить конструктивным доказательством результирующего утверждения (изоморфизм Карри — Ховарда).


def solve(t: Task): Solution =
  ???

Если функция solve является чистой тотальной функцией без побочных эффектов, то для каждой задачи Task она сможет алгоритмически предоставить решение Solution.


Вот как можно было бы отразить алгоритм решения квадратного уравнения:


def `решение квадратного уравнения`(a: Double, b: Double, c: Double): () | Double | (Double, Double) =
  ???

Глядя на сигнатуру этой функции, мы можем заключить, что у произвольного квадратного уравнения может быть 0, 1 или 2 вещественных корня.


Доказательство сделанной работы с помощью типов. 2


Попробуем смоделировать ситуацию, при которой мы сможем гарантировать, что экземпляр сущности был вставлен в базу данных. (То есть реализовать обработчик HTTP-запроса POST /form.) Сформулируем требование к программе:


TODO: Сервис должен сохранить полученную форму или вернуть ошибку.

В такой формулировке действие "сохранить" не отражено в результате (является побочным эффектом). Для проверки корректности работы потребуются моки и другая магия.


Какой результат обеспечит уверенность в том, что форма сохранена? Например, идентификатор, сгенерированный базой данных. В таком случае, требование к программе будет выглядеть так:


TODO: Простой JSON RPC сервис должен, получив форму в виде JSON, вернуть идентификатор формы в базе данных или ошибку.

Уже само требование, сформулированное в такой форме, фокусирует наше внимание не на процессе ("сохранить"), а на предъявляемом результате этого процесса ("идентификатор формы в базе данных").


Воспользуемся opaque типами на уровне библиотеки доступа к БД. (Вопрос написания такой библиотеки мы оставим за скобками.)


Пусть библиотека предоставляет следующий интерфейс:


opaque type Идентификатор[E] = Int

/** Возвращает идентификатор вставленной строки, сгенерированный базой. */
def вставить[A](a: A): ОперацияБД[Идентификатор[A]] = ???

/** Возвращает сущность A по идентификатору.
 * Тип идентификатора - обычный, т.к. поступает извне.
 */
def найти[A](id: Int): ОперацияБД[Option[A]] = ???

/** Выполнить операцию и вернуть IO (результат или ошибку). */
def выполнитьВБазеIO[A](op: ОперацияБД[A])(using db: DB): IO[A] = ???

def значениеИлиОшибка[A](ao: A | ошибка): ОперацияБД[A] =
  ao match
    case о: ошибка => 
      ОперацияБД.ошибка(о)
    case a =>
      ОперацияБД.значение(a)

(Кстати сказать, функция значениеИлиОшибка является конструктивным доказательством простой теоремы — "любое значение или ошибка представимы в виде ОперацияБД".)
Далее мы исходим из того, что библиотека протестирована и работает корректно.


Также нам потребуется библиотека http4s для реализации HTTP-сервиса и такая вспомогательная функция:


def `простой JSON RPC сервис`[A, B](f: A => IO[B]): Request[IO] => IO[Response[IO]] = запрос =>
  for 
    форма         <- запрос.as[Форма]
    идентификатор <- `сохранить в базе`(форма)
    ответ         <- Ok(идентификатор.asJson)
  yield
    ответ

Естественно, для записи в базу, необходимо получить соединение:


val пулСоединений: IO[DB] = ???

Пусть наша форма уже представлена в виде case-class'а с поддержкой сериализации в JSON:


case class Форма(data: Int)

given formDecoder: Decoder[Форма] = deriveDecoder
given formEncoder: Encoder[Форма] = deriveEncoder

Запишем требование и попробуем его конкретизировать с использованием имеющихся инструментов.


TODO: Простой JSON RPC сервис должен, получив форму в виде JSON, вернуть идентификатор формы в базе данных или ошибку.

Часть "Простой JSON RPC сервис должен, получив форму в виде JSON, вернуть" соответствует и заменяется на вышеприведённую функцию простой JSON RPC сервис:


`простой JSON RPC сервис`(TODO: вернуть идентификатор формы в базе данных или ошибку)

Часть требования "... или ошибку" уже предусмотрена в типе IO.


Остаётся реализовать вставку формы в базу. Т.е. сконструировать задание для БД вставить, и выполнить это задание в базе:


выполнитьВБазеIO(вставить(форма))

Чтобы что-то можно было выполнить в базе, необходимо получить соединение из пула. Объединение двух последовательных действий IO в одно действие осуществляется либо через flatMap, либо через for:


def `сохранить в базе`[A](a: A): IO[Идентификатор[A]] =
  for 
    соединениеСБД <- пулСоединений
    идентификатор <- выполнитьВБазеIO(вставить(a))(using соединениеСБД)
  yield
    идентификатор

Для удобства добавим конкретную версию функции для нашего типа формы:


def `сохранить форму в базе и вернуть идентификатор формы в базе данных`(форма: Форма): IO[Идентификатор[Форма]] =
  `сохранить в базе`(форма)

Продолжая эквивалентные преобразования требований, получаем:


`простой JSON RPC сервис`(`сохранить форму в базе и вернуть идентификатор формы в базе данных`)

Остаётся только завернуть в "церемонию", предлагаемую библиотекой http4s:


val routes =
  HttpRoutes.of[IO] {
    case запрос @ POST -> Root / "form" =>
      `простой JSON RPC сервис`(`сохранить форму в базе и вернуть идентификатор формы в базе данных`)(запрос)
  }

Что можно заметить? Во-первых, требования к программе, сформулированные с акцентом на результат, приводят к программе, корректность которой отражена в типах. Во-вторых, программа распадается на несколько независимых функций, вопрос корректности которых может быть рассмотрен индивидуально. В-третьих, собственно корректность сохранения в базе данных "доказывается" путём предоставления конструктивного доказательства в виде программы, сводящей задачу сохранения к вызову библиотечных функций (доказательство корректности которых мы оставляем за скобками, и считаем априори корректными).


И самое главное, требования к программе эквивалентными и уточняющими преобразованиями преобразованы в действующую программу. Сам процесс преобразования/конкретизации может служить доказательством эквивалентности (при условии, что термины понимаются сторонами корректно и в процессе конкретизации выбрана правильная реализация терминов).


Нужны ли для этой программы тесты?


Увеличение сложности программ


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


Рассмотрим, например, взаимодействие таких требований:


  • простой JSON RPC сервис должен, получив форму в виде JSON, вернуть идентификатор формы в базе данных или ошибку
  • все изменения в БД должны производиться транзакционно;
  • все изменения в БД должны логироваться с указанием имени пользователя и момента выполнения изменений.

Реализация первого требования выглядит относительно несложно и рассмотрена выше.


Чтобы поддержать второе требование на уровне типов, мы можем исключить доступ к БД без явного указания транзакции. Например, можно объявить функцию верхнего уровня простой транзакционный JSON RPC сервис, которая будет управлять транзакциями. Все сервисы окажутся транзакционными.


Реализация третьего требования зависит от выбранного способа аутентификации на уровне базы. Если для каждого пользователя приложения создаётся пользователь базы, то возможна реализация на уровне самой базы с помощью триггера. Если же используется только аутентификация на уровне сервиса, то потребуется передавать идентификатор пользователя из запроса на уровень базы данных. В этом случае, логирование можно произвести с помощью следующей функции:


case class СтрокаЖурнала(типФормы: ИдентификаторТипаФормы, userId: UserId, time: LocalDateTime)

def logUserId[A: ТипФормы](dbid: Идентификатор[A], userId: UserId, момент: IO[LocalDateTime] = now()): ОперацияБД[Идентификатор[СтрокаЖурнала]] = 
  for
    time <- ОперацияБД.lift(момент)
    строкаЖурнала = СтрокаЖурнала(типФормы, userId, time)
    идентификатор <- вставить(строкаЖурнала)
  yield
    идентификатор

Для использования этой функции потребуется изменить операцию сохранения:


def `сохранить форму в базе, залогировать операцию и вернуть идентификаторы`(форма: Форма)(using UserId): IO[(Идентификатор[Форма], Идентификатор[СтрокаЖурнала])] =
  for 
    бд <- пулСоединений
    given DB = бд
    идентификатор <- выполнитьВБазеIO(вставить(форма))
    идентификаторСтрокиЖурнала <- выполнитьВБазеIO(logUserId(идентификатор, summon[UserId]))
  yield
    (идентификатор, идентификаторСтрокиЖурнала)

Пример цепочки действий


Пусть "простая продажа" подразумевает выполнение следующих действий:


  • чистое вычисление (например, полной стоимости заказа с учётом скидок, уведомления пользователя, содержащего сгенерированный номер заказа);
  • транзакция в БД;
  • отправка SMS.

Традиционно для тестирования такой составной операции, задействующей внешние зависимости (БД и сервис СМС), используются мок-объекты, позволяющие подменить зависимости и протестировать именно этот сценарий.


На этом примере хочется понять, каким образом можно написать качественную программу и при этом обойтись без моков.


Во-первых, представим результат побочных эффектов с помощью типов данных. Сервис SMS может предоставлять подтверждение отправки с помощью экземпляра trait'а:


sealed trait ПодтверждениеОтправкиSMS:
  val кому: String
  val текст: String

Наличие ключевого слова sealed гарантирует, что экземпляр может быть создан только в модуле, отвечающем за отправку SMS.


case class КвитанцияОбработкиЗаказа(
  идентификатор: Идентификатор[Заказ], 
  подтверждениеОтправкиSMS: ПодтверждениеОтправкиSMS,
)

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


Собственно реализация сервиса простой продажи будет иметь следующий вид:


def `простая продажа`(заказ: Заказ)(using SmsService, DB): IO[КвитанцияОбработкиЗаказа] = 
  for
    id <- выполнитьВБазеIO(вставить(заказ))
    уведомление = `текст уведомления`(заказ, id)
    sms <- уведомить("пользователь", уведомление)
  yield
    КвитанцияОбработкиЗаказа(id, sms)

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


Сервис имеет две внешние зависимости — SmsService и DB. Обычной практикой является реализация мок-объектов и запуск сервиса в рамках теста. В данном случае, на мой взгляд, корректность реализации демонстрируется сигнатурой функции. Само наличие функции, возвращающей КвитанцияОбработкиЗаказа, является конструктивным доказательством того, что такая квитанция будет сформирована в результате исполнения этого алгоритма. А содержимое квитанции демонстрирует результат — объект записан в БД и уведомление отправлено.


Взаимодействие с подсистемами, обладающими состоянием (statful)


Для распределённых систем широкое признание получила идея отказа от "сессии" при взаимодействии между удалёнными системами и использовании stateless-протоколов (без состояния) наподобие REST. Аналогичная идея зачастую может быть использована при организации взаимодействия между подсистемами. Вместо цепочки действий, приводящих подсистему в требуемое состояние, формируется сложный объект, передаваемый подсистеме. Подсистема, обрабатывая этот сложный объект, самостоятельно приходит в конечное состояние.


К примеру, при работе с базой данных может потребоваться произвести согласованные изменения в нескольких таблицах, например: сохранить изменённые свойства объекта в новую строку таблицы версий, записать вложенные элементы в связанную таблицу, сохранить сведения о пользователе, выполняющем операцию, залогировать момент времени. Вместо того, чтобы эти изменения выполнять на уровне приложения, можно сформировать высокоуровневое событие, содержащее все требуемые параметры ("пользователь А изменил объект Б в момент времени Т"), и передать это событие в хранимую процедуру на уровне базы. Хранимая процедура, в свою очередь, выполнит все необходимые изменения в рамках транзакции.


Тестирование приложения, использующего такую подсистему, значительно упрощается. Мок-объект, представляющий подсистему, становится обычной табличной функцией, возвращающей предопределённый результат в ответ на сложное событие.


Пример разделения сценария


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


  1. Сохранить новую версию заказа (бд).
  2. Записать в журнал сведения о пользователе (бд).
  3. Записать заявку на списание/возмещение средств (бд).
  4. Отправить sms-уведомление пользователю (сервис SMS).

Прямолинейная реализация сценария может выглядеть так:


def `изменение заказа`(заказ: Заказ, пользователь: String, момент: LocalDateTime)(using SmsService, DB): IO[Unit] =
  for
    id <- выполнитьВБазеIO(upsert(заказ))
    userId <- выполнитьВБазеIO(найтиПользователя(пользователь))
    _ <- выполнитьВБазеIO(logUserId(id, userId, IO{момент}))
    уведомление = `текст уведомления`(заказ, id)
    sms <- уведомить(пользователь, уведомление)
  yield
    ()

Эта реализация трижды обращается к базе данных для выполнения отдельных шагов алгоритма. При тестировании потребуется реализовать мок-объект, обрабатывающий три обращения и возвращающий содержательные ответы, достаточные для продолжения работа сценария. Причём, т.к. сам сценарий кроме базы данных использует и другие зависимости, то для его тестирования потребуется смоделировать и все остальные сервисы (в данном случае — SMS).


Если часть алгоритма, относящуюся к БД, выделить в отдельную функцию, то получим такой код:


case class Изменение[A](сущность: A, пользователь: String, момент: LocalDateTime)

def `изменение заказа2`(заказ: Заказ, пользователь: String, момент: LocalDateTime)(using SmsService, DB): IO[Unit] =
  val событиеИзмененияЗаказа = Изменение[Заказ](заказ, пользователь, момент)
  for
    квитанция <- выполнитьВБазеIO(обработатьСобытиеИзменения(событиеИзмененияЗаказа))
    уведомление = `текст уведомления`(заказ, квитанция.идентификатор)
    sms <- уведомить(пользователь, уведомление)
  yield
    ()

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


Закономерный вопрос, что происходит с логикой, исключённой из прикладного сценария. Как и раньше, логика может быть реализована в приложении, например, таким образом:


case class КвитанцияОбИзменении[A](идентификатор: Идентификатор[A], userId: UserId, строкаЖурнала: Идентификатор[СтрокаЖурнала])

def обработатьСобытиеИзменения[A: ТипФормы](изменение: Изменение[A]): ОперацияБД[КвитанцияОбИзменении[A]] =
  for 
    id <- upsert(изменение.сущность)
    userId <- найтиПользователя(изменение.пользователь)
    строкаЖурнала <- logUserId(id, userId, IO{изменение.момент})
  yield
    КвитанцияОбИзменении(id, userId, строкаЖурнала)

Либо эта логика может быть перенесена на уровень базы данных в виде хранимой процедуры без изменения сценария.


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


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


  1. За счёт дженериков и наличия квитанции (то есть подтверждения результата в типе функции), корректность кода можно считать доказанной системой типов и код можно исключить из тестирования.
  2. Поскольку код работает исключительно с базой данных, то он может быть протестирован в рамках интеграционного тестирования с тестовой базой без использования реального/поддельного sms-сервиса.
  3. Даже если реализовывать мок-объект в классическом виде, здесь это сделать немного проще, т.к. требуется моделировать только БД, хотя по-прежнему потребуется реализовать несколько ответов мок-объекта.

Заключение


В основе тестирования лежит идея проверки соответствия кода и требований. К сожалению, доказать это соответствие с помощью табличных тестов не представляется возможным.


Требования к программе при определённом усердии могут быть сформулированы как функция от абстрактных входных данных. А сама программа будет также являться некоторой функцией от какого-либо типа данных. Доказательство того, что программа соответствует требованиям, оказывается задачей доказательства эквивалентности этих двух функций, сформулированных на разных уровнях абстракции.


Использование некоторых особенностей функционального программирования делает формальное доказательство эквивалентности более доступным.


Вся серия заметок:


  1. Примеры тестов.
  2. Что делать?
    1. Качество ПО.
    2. "Прямолинейность" кода.
    3. Классификация ошибок.
    4. Эквивалентность функций.
    5. Применимость тестов.
  3. Исправление примеров.

Комментарии (4)


  1. Hokum
    26.08.2023 14:20
    +1

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

    Да, типы позволяют уменьшить количество тестов, но не заменить их полностью. То что вернулся идентификатор из БД, не дает гарантии, что данные были сохранены полностью. То что вернулся нужный тип - не означает, что данные буду сериализованы корректно. И на это нужны тесты. Условно, при сериализации в JSON было поведение, что поля со значением null оставались, а после обновления библиотеки сериализации, такие поля стали по умолчанию убираться Или сериализация енумов изменилась - сериализовывались как UPPER_SNAKE_CASE, а стали CamelCase. Как результат - может сломаться интеграция с внешней системой. Если эта часть кода будет не покрыта тестами, то о проблеме узнаем в момент проверки интеграции, а если будет покрыта тестами, то сразу после обновления библиотеки на машине разработчика.

    Да и работу с БД лучше проверять не на моках, а с реальной БД, к счастью, есть test containers, позволяющий легко поднять требуемое решение в контейнере. Так же в контейнерах можно поднять и брокеры сообщений. А моки оставить для систем, которые существуют только в облаке.

    Причем подход с контейнерами, так же позволяет проще проверить, что при переходе на новую версию БД сервис, вероятно, будет работать как раньше. Достаточно изменить версию БД поднимаемую в контейнере. Да и просто проверить, что сервис работает с нужной версией БД.

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

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

    Будь у меня выбор как проект взять на сопровождение - с хорошими тестами (включая юнит-тесты) или проект с "хорошей" архитектурой и типизацией всего, что только можно, то я предпочту первый, так как его код можно будет переписывать и улучшать в процессе поддержки. А со вторым всё будет хорошо, до тех пор пока не придется вносить серьезных изменений, ну или в процессе "красивая" архитектура будет деградировать, так как существенные изменения будет делаться "сбоку", так как трогать код не покрытый тестами "страшно", поведение изменится и не будешь знать об этом.


    1. primetalk Автор
      26.08.2023 14:20

      1. Некоторые тесты полезны, например, те, что упомянуты в следующей части.

      2. Действительно, property-based тесты (использующие генераторы) - весьма сильная штука. Они позволяют существенно, в сотни раз, увеличить мощность тестового множества. Классический пример - наличие прямой и обратной функции (сериализация/десериализация; запись в БД/чтение). Их композиция должна давать identity, которое легко проверить - достаточно нагенерировать данных с одной стороны, потом прогнать через эту identity и убедиться, что всё ок. К сожалению, в общем случае обнаружить/придумать свойство, которое можно протестировать, не так-то просто. Более того, если такое свойство обнаружено, то, весьма вероятно, оно может быть выражено в типах явным образом.

      3. Я во всех примерах исхожу из того, что используемые библиотеки, компилятор, виртуальные машины и т.д. работают корректно. Если это не так, то мы вступаем на тонкий лёд непредсказуемого окружения и там может быть всё что угодно. В таком окружении тесты, проверяющие корректность работы/использования библиотек - обычное дело. Я лично писал такого рода тест на языке Go при использовании библиотеки google FHIR, потому что эта библиотека обладала неожиданной "фичёй" - при сериализации объект портился и некоторые свойства пропадали. При сериализации! Представить себе такое в Scala - крайне сложно.

      4. Работу с БД я также обычно проверяю тестами на реальной БД в контейнере. Это гораздо полезнее моков и позволяет обнаружить в том числе отличия между версиями СУБД. Другое дело, что мне достаточно написать один тест на одну сущность и убедиться, что весь generic-код для этой сущности работает корректно. Этого одного теста достаточно для того, чтобы быть уверенным, что все остальные сущности также будут обрабатываться этим generic-кодом корректно.

      5. Про логи и метрики. К сожалению, обычно логирование и запись метрик происходит в форме не наблюдаемых побочных эффектов. Поэтому протестировать, что логирование действительно происходит, может быть непросто. Обычно ограничиваются тем, что визуально, при запуске тестов, видны сообщения в логах. Если требуется проверить корректность логирования/измерения метриц, то, по-видимому, необходимо выносить их явным образом на уровень типов. В одном проекте, например, я использовал специальную обёртку, чтобы собирать метрики в явном виде. И в тестах проверялась корректность измерений.

      6. Про мутационное тестирование. Интересно, в реальных проектах его применяют?

      7. Типы vs тесты. Я предполагаю, что 99% читателей согласились бы с Вами и также выбрали бы проект с тестами, вместо того, чтобы разбираться со "сложными типами" :). Вся серия заметок направлена на популяризацию использования типов. Внесение изменений в корректно-типизированную систему, на мой взгляд, приводит к более предсказуемому результату. В частности, после изменения код перестаёт компилироваться. А вот после исправления всех точек, где ругается компилятор, мы можем быть почти на 100% уверены в том, что программа снова корректно работает. Тесты в принципе не могут приблизиться к такому уровню уверенности. Корректно-типизированный код изменять не "страшно". Сложно добиться того, чтобы программа потом компилировалась:) Зато если это получилось, то мы снова имеем гарантированно работающий код.


      1. Hokum
        26.08.2023 14:20

        Я лично писал такого рода тест на языке Go при использовании библиотеки google FHIR, потому что эта библиотека обладала неожиданной "фичёй" - при сериализации объект портился и некоторые свойства пропадали. При сериализации! Представить себе такое в Scala - крайне сложно.

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

        Про мутационное тестирование. Интересно, в реальных проектах его применяют?

        Как минимум одна команда Яндекс использовала их, общался с их лидом, они прогоняли мутации раз один-два раза в месяц, писали они на C++. Ну и скорей всего Авито, зря что ли они выложили фреймворк свой для мутационного тестирования на Go. Ну и для скалы, когда я смотрел на мутационное тестирование, был только один фреймворк, п потом тот фреймворк был заброшен, то появилась парочка новых. Раз пишет, значит кто-то использует. Единственное надо смотреть ограничения, не исключено, что какие-то библиотеки или плагины компилятора могут ломать работу этих фреймворков. Собственно с тем, что я встретил впервые - он ломался с аккой, когда подменялось поведение актора в процессе исполнения. Для скалы пока не встречал тех кто, использовал бы мутационное тестирование.

        Типы vs тесты.

        Я двумя руками "за" использование типов, особенно в скале, где очень много возможностей для этого и при этом типы будут существовать только на этапе компиляции не внося дополнительного оверхеда при исполнении. Это сильно уменьшает поле для ошибок - описав сигнатуру функции или тип переменной уже можно просто перебором подставить нужное, чтобы совпали типы и получить желаемое. Это всё так. Но корректное поведение программы и желаемое поведение - увы, это разные вещи. Ну и опять же, изменение типов, которое может потребоваться в рамках расширения функциональности приложения или рефакторинга, может привести к нежелательным последствия, которые без тестов будет не отловить.

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

        Еще пример, с сериализацией. Если говорить о JSON, то если поле опциональное и представляет собой коллекцию, что есть два варианта задать тип у поля кейс класса - Option[List[DataType]] или просто List[DataType]. Редко когда нужно отличать пустую коллекцию от не заданной, потому есть возможность задать значение по умолчанию (бывает такое и со скалярными значениями полезно), но разные библиотеки могут обрабатывать это по-разному. Одни, по умолчанию, при десериализации вместо отсутствующего поля в JSON подставят требуемое, другим нужно явно указать, чтобы в таком случае использовались значения по умолчанию. В обоих случаях поведение программы корректное, мне не попадалось библиотеки, где разница в поведении будет влиять на тип кодеков. Можно, конечно, один раз запустить и проверить, что всё хорошо, но это знание останется у того, кто это написал. Более того, значение по умолчанию у поля кейс класса не обязательно свидетельствует о том, что в принимаемом JSON это поле может отсутствовать.

        При работе с БД, можно забыть проставить аннотацию у поля описывающее имя столбца, или изменить порядок полей и тоже нарушить запись/чтение в/из БД. И всё это никак не описать в типах. Эти ошибки могут возникнуть как при написании кода впервые и тут можно сказать, что достаточно запустить и посмотреть что всё хорошо, но также они могут возникнуть случайно в процессе поддержки. И если на это будет тест, то он это поймает. А еще, для енумов в БД может быть задан пользовательский тип и при расширении енума в коде, можно забыть добавить миграцию, меняющую пользовательский тип в БД. Если в тестах поднимается контейнер и схема накатывается из миграций, то тесты помогут выловить это.

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


        1. primetalk Автор
          26.08.2023 14:20

          1. Замена библиотеки. Продукт сериализации, к сожалению, не наблюдается на уровне типов. Опции библиотеки сериализации также не находят отражения в типах, по-большому счёту. Поэтому гарантировать определённый результат сериализации, действительно, можно только проверив результат в специальном тесте.

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

          2. Мутационное тестирование. У меня аналогичные впечатления - применяется энтузиастами, широкого признания не получило.

          3. Использование типов. Не все типы одинаковы :) Некоторые типы особенно хороши. Например, алгебраические типы данных делают возможным "make illegal state unrepresentable". Этого очень не хватает, если приходится писать на языках без ADT.

          4. "Но корректное поведение программы и желаемое поведение - увы, это разные вещи." Я как раз исхожу из того, что корректное поведение == соответствие требованиям == желаемое поведение.

          5. Тесты - документация. Нормальный use case для тестов. Я об этом тоже пишу - документирование-api.

          6. "тесты - это еще своего рода песочница в которой можно поиграться с кодом и лучше понять что и как работает". Тоже - Юнит-тесты как упражнение.

          7. "пример с сериализацией". См.п.1. Минимизируем число тестов.

          8. "При работе с БД". По-хорошему, необходимо следовать принципу "единая версия правды". Либо код схемы генерируется по базе, либо схема базы генерируется из кода. Как только мы дублируем сущности в двух исходных файлах, жди беды.

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

          9. Типы помогают уменьшить количество юнит-тестов. Да. Тесты, приведённые в первой части, как раз могут быть ликвидированы/упрощены за счёт использования типов. И я не предлагаю совершенно избавиться от тестов. Надо подходить к ним исходя из рациональных соображений. Если польза перевешивает, то надо писать. Если много стереотипных тестов, то, может, стоит заменить их на один универсальный? Я постарался привести несколько категорий тестов, которые полезны даже если активно использовать типы данных - Применимость юнит-тестов.

          10. "Возможно, отсюда и количество минусов у публикаций." Похоже, Вы правы. Видимо, складывается обманчивое впечатление, что я против всех тестов вообще.