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


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


Проблема


Если вы когда-либо имели дело с тестами (не важно — юнит-тестами, интеграционными или функциональными), скорее всего они были написаны в виде набора последовательных инструкций. К примеру:


// Данные тесты описывают простой магазин. В зависимости от совокупности
// своей роли, количества бонусов и общей стоимости заказа, пользователь
// может получить скидку разного размера.
"Если роль покупателя = 'customer'" - {
  import TestHelper._
  "И общая сумма покупки < 250 после вычета бонусов - нет скидки" in {
    val db: Database = Database.forURL(TestConfig.generateNewUrl())
    migrateDb(db)
    insertUser(db, id = 1, name = "test", role = "customer")
    insertPackage(db, id = 1, name = "test", userId = 1, status = "new")
    insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30)
    insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20)
    insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)

    val svc = new SomeProductionLogic(db)
    val result = svc.calculatePrice(packageId = 1)
    result shouldBe 90
  }
  "И общая сумма покупки >= 250 после вычета бонусов - скидка 10%" in {
    val db: Database = Database.forURL(TestConfig.generateNewUrl())
    migrateDb(db)
    insertUser(db, id = 1, name = "test", role = "customer")
    insertPackage(db, id = 1, name = "test", userId = 1, status = "new")
    insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 100)
    insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 120)
    insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 130)
    insertBonus(db, id = 1, packageId = 1, bonusAmount = 40)

    val svc = new SomeProductionLogic(db)
    val result = svc.calculatePrice(packageId = 1)
    result shouldBe 279
  }
}
"Если роль покупателя = 'vip'" - {/*...*/}

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


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


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


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


  1. Содержимое теста допускается в слишком свободной форме. Каждый тест уникален, как снежинка. Необходимость вчитываться в детали теста занимает массу времени и демотивирует. Не важные подробности отвлекают от главного — требования, проверяемого тестом. Копипаста становится основным способом написания новых тест-кейсов.
  2. Тесты не помогают разработчику локализовать баги, а только сигнализируют о какой-то проблеме. Чтобы понять состояние, на котором выполняется тест, нужно восстанавливать его в голове или подключаться дебаггером.

Моделирование


Можем ли мы сделать лучше? (Спойлер: можем.) Давайте рассмотрим, из чего состоит этот тест.


val db: Database = Database.forURL(TestConfig.generateNewUrl())
migrateDb(db)
insertUser(db, id = 1, name = "test", role = "customer")
insertPackage(db, id = 1, name = "test", userId = 1, status = "new")
insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30)
insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20)
insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)

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


Этой фикстурой мы подготовим зависимость (dependency) — наполним базу данных (очередь, внешний сервис и т. п.). Подготовленной зависимостью мы инициализируем тестируемый класс (сервисы, модули, репозитории, т. п.).


val svc = new SomeProductionLogic(db)
val result = svc.calculatePrice(packageId = 1)

Исполнив тестируемый код на некоторых входных параметрах, мы получим бизнес-значимый результат (output) — как явный (возвращенный методом), так и неявный — изменение пресловутого состояния: базы данных, внешнего сервиса и т. д.


result shouldBe 90

Наконец, мы проверим, что результаты оказались ровно такими, какими ожидали, подводя итог теста одной или несколькими проверками (assertion).



Можно прийти к выводу, что в целом тест состоит из одних и тех же стадий: подготовки входных параметров, выполнения на них тестируемого кода и сравнения результатов с ожидаемыми. Мы можем использовать этот факт, чтобы избавиться от первой проблемы в тесте — слишком свободной формы, явно разделив тест на стадии. Эта идея не нова и уже давно применяется в тестах в BDD-стиле (behavior-driven development).


Что насчет расширяемости? Любой из шагов процесса тестирования может содержать в себе сколько угодно промежуточных. Забегая вперед, мы могли бы формировать фикстуру, создавая сначала некую человекочитаемую структуру, а потом конвертировать ее в объекты, которыми наполняется БД. Процесс тестирования бесконечно расширяем, но, в конечном счете, всегда сводится к основным стадиям.



Запуск тестов


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


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


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



Вернемся к модели устройства теста. Технически каждую точку на этом графике может представлять тип данных, а переходы из одной в другую — функции. Прийти от начального типа данных к конечному можно, поочередно применяя последующую функцию к результату предыдущей. Иными словами, используя композицию функций: подготовки данных (назовем ее prepare), исполнения тестируемого кода (execute) и проверки ожидаемого результата (check). На вход этой композиции мы передадим первую точку графика — фикстуру. Получившуюся функцию высшего порядка назовем функцией жизненного цикла теста.


Функция жизненного цикла
def runTestCycle[FX, DEP, OUT, F[_]](
  fixture: FX,
  prepare: FX => DEP,
  execute: DEP => OUT, 
  check: OUT => F[Assertion]
): F[Assertion] =
  // В Scala вместо того, чтобы писать check(execute(prepare(fixture)))
  // можно использовать более читабельный вариант с функцией andThen:
  (prepare andThen execute andThen check) (fixture)

Встает вопрос, откуда возьмутся внутренние функции? Готовить данные мы будем ограниченным количеством способов — наполнять базу, мокать, т. п. — поэтому варианты функции prepare будут общие для всех тестов. Как следствие, проще будет сделать специализированные функции жизненного цикла, скрывающие в себе конкретные реализации подготовки данных. Поскольку способы вызова проверяемого кода и проверки — относительно уникальные для каждого теста, execute и check будут подаваться явно.


Адаптированная под интеграционные тесты на БД функция жизненного цикла
// Наполнить базу фикстурой — имплементируется отдельно под приложение
def prepareDatabase[DB](db: Database): DbFixture => DB

def testInDb[DB, OUT](
  fixture: DbFixture,
  execute: DB => OUT,
  check: OUT => Future[Assertion],
  db: Database = getDatabaseHandleFromSomewhere(),
): Future[Assertion] =
  runTestCycle(fixture, prepareDatabase(db), execute, check)

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


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


Функция жизненного цикла с логированием
def logged[T](implicit loggedT: Logged[T]): T => T = 
  (that: T) => {
    // Передавая в качестве аргумента инстанс тайпкласса Logged для T,
    // мы получаем возможность “добавить” абстрактному that поведение log().
    // Подробнее о тайпклассах - дальше.

    loggedT.log(that) // Существует продвинутый вариант записи: that.log()
    that              // объект возвращается неизменным
  }

def runTestCycle[FX, DEP, OUT, F[_]](
  fixture: FX, 
  prepare: FX => DEP,
  execute: DEP => OUT, 
  check: OUT => F[Assertion]
)(implicit loggedOut: Logged[OUT]): F[Assertion] =
  // Внедряем logged сразу после получения результата - после execute
  (prepare andThen execute andThen logged andThen check) (fixture)

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



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


val fixture: SomeMagicalFixture = ???  // Объявлен где-то в другом месте
def runProductionCode(id: Int): Database => Double =
  (db: Database) => new SomeProductionLogic(db).calculatePrice(id)
def checkResult(expected: Double): Double => Future[Assertion] =
  (result: Double) => result shouldBe expected

// Создание и наполнение Database скрыто в testInDb
"Если роль покупателя = 'customer'" in testInDb(
  state = fixture,
  execute = runProductionCode(id = 1),
  check = checkResult(90)
)

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


Подготовка фикстур


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


Предположим, у нашего тестируемого магазина есть типичная БД среднего размера (для простоты примера с 4 таблицами, но в реальности их могут быть сотни). Часть содержит справочную информацию, часть —непосредственно бизнесовую, и все вместе это можно связать в несколько полноценных логических сущностей. Таблицы связаны между собой ключами (foreign keys) — чтобы создать сущность Bonus, потребуется сущность Package, а ей в свою очередь — User. И так далее.



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


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


Наполнение базы в изначальном тесте
insertUser(db, id = 1, name = "test", role = "customer")
insertPackage(db, id = 1, name = "test", userId = 1, status = "new")
insertPackageItems(db, id = 1, packageId = 1, name = "test", price = 30)
insertPackageItems(db, id = 2, packageId = 1, name = "test", price = 20)
insertPackageItems(db, id = 3, packageId = 1, name = "test", price = 40)

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


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


val dataTable: Seq[DataRow] = Table(
  ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price")
  , (1, "customer", Vector(40, 20, 30)           , Vector.empty      ,  90.0)
  , (2, "customer", Vector(250)                  , Vector.empty      , 225.0)
  , (3, "customer", Vector(100, 120, 30)         , Vector(40)        , 210.0)
  , (4, "customer", Vector(100, 120, 30, 100)    , Vector(20, 20)    , 279.0)
  , (5, "vip"     , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0)
)


Из нашей таблицы, мы сформируем ключи — связи сущностей по ID. При этом, если сущность зависит от другой, сформируется ключ и для зависимости. Может случиться так, что две разные сущности сгенерируют зависимость с одинаковым идентификатором, что может привести к нарушению ограничения по первичному ключу БД (primary key). Но на этом этапе данные предельно дешево дедуплицировать — поскольку ключи содержат только идентификаторы, мы можем сложить их в коллекцию, обеспечивающую дедупликацию, например в Set. Если этого окажется недостаточно, мы всегда можем сделать более умную дедупликацию в виде дополнительной функции, скомпозированной в функцию жизненного цикла.


Пример ключей
sealed trait Key
case class PackageKey(id: Int, userId: Int) extends Key
case class PackageItemKey(id: Int, packageId: Int) extends Key
case class UserKey(id: Int) extends Key
case class BonusKey(id: Int, packageId: Int) extends Key

Генерацию фейкового наполнения полей (например, имен) мы делегируем отдельному классу. Затем, прибегая к помощи этого класса и правил конвертации ключей, мы получим объекты-строки, предназначенные непосредственно для вставки в базу.


Пример строк
object SampleData {
  def name: String = "test name"
  def role: String = "customer"
  def price: Int = 1000
  def bonusAmount: Int = 0
  def status: String = "new"
}

sealed trait Row
case class PackageRow(id: Int, name: String, userId: Int, status: String) extends Row
case class PackageItemRow(id: Int, packageId: Int, name: String, price: Int) extends Row
case class UserRow(id: Int, name: String, role: String) extends Row
case class BonusRow(id: Int, packageId: Int, bonusAmount: Int) extends Row

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


Пример линзы
def changeUserRole(userId: Int, newRole: String): Set[Row] => Set[Row] =
  (rows: Set[Row]) => 
    rows.modifyAll(_.each.when[UserRow])
      .using(r => if (r.id == userId) r.modify(_.role).setTo(newRole) else r)

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


Функция формирования фикстуры
def makeFixture[STATE, FX, ROW, F[_]](
  state: STATE,
  applyOverrides: F[ROW] => F[ROW] = x => x
): FX =
  (extractKeys andThen
    deduplicateKeys andThen
    enrichWithSampleData andThen
    applyOverrides andThen
    logged andThen
    buildFixture) (state)

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



Наш тест-сьют теперь будет выглядеть следующим образом:


val dataTable: Seq[DataRow] = Table(
  ("Package ID", "Customer's role", "Item prices", "Bonus value", "Expected final price")
  , (1, "customer", Vector(40, 20, 30)           , Vector.empty      ,  90.0)
  , (2, "customer", Vector(250)                  , Vector.empty      , 225.0)
  , (3, "customer", Vector(100, 120, 30)         , Vector(40)        , 210.0)
  , (4, "customer", Vector(100, 120, 30, 100)    , Vector(20, 20)    , 279.0)
  , (5, "vip"     , Vector(100, 120, 30, 100, 50), Vector(10, 20, 10), 252.0)
)

"Если роль покупателя -" - {
  "'customer'" - {
    "И общая сумма покупки" - {
      "< 250 после вычета бонусов - нет скидки" - {
        "(кейс: нет бонусов)" in calculatePriceFor(dataTable, 1)
        "(кейс: есть бонусы)" in calculatePriceFor(dataTable, 3)
      }
      ">= 250 после вычета бонусов" - {
        "Если нет бонусов - скидка 10% от суммы" in
          calculatePriceFor(dataTable, 2)
        "Если есть бонусы - скидка 10% от суммы после вычета бонусов" in
          calculatePriceFor(dataTable, 4)
      }
    }
  }
  "'vip' - то применяется скидка 20% к сумме покупки до вычета бонусов, а потом применяются все остальные скидочные правила" in
    calculatePriceFor(dataTable, 5)
}

А вспомогательный код:


Код
// Переиспользованное тело теста
def calculatePriceFor(table: Seq[DataRow], idx: Int) =
  testInDb(
    state = makeState(table.row(idx)),
    execute = runProductionCode(table.row(idx)._1),
    check = checkResult(table.row(idx)._5)
  )
def makeState(row: DataRow): Logger => DbFixture = {
  val items: Map[Int, Int] = ((1 to row._3.length) zip row._3).toMap
  val bonuses: Map[Int, Int] = ((1 to row._4.length) zip row._4).toMap
  MyFixtures.makeFixture(
    state = PackageRelationships
      .minimal(id = row._1, userId = 1)
      .withItems(items.keys)
      .withBonuses(bonuses.keys),
    overrides = changeRole(userId = 1, newRole = row._2) andThen
      items.map { case (id, newPrice) => changePrice(id, newPrice) }.foldPls andThen
      bonuses.map { case (id, newBonus) => changeBonus(id, newBonus) }.foldPls
  )
}
def runProductionCode(id: Int): Database => Double =
  (db: Database) => new SomeProductionLogic(db).calculatePrice(id)
def checkResult(expected: Double): Double => Future[Assertion] =
  (result: Double) => result shouldBe expected

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


Переиспользование кода подготовки фикстур на других проектах


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


Мы можем абстрагировать подготовку фикстур от конкретной доменной модели. В мире ФП существует концепция тайпкласса (typeclass). Если коротко, тайпклассы — это не классы из ООП, а нечто вроде интерфейсов, они определяют некое поведение группы типов. Принципиальное различие в том, что эта группа типов определяется не наследованием классами, а инстанцированием, как обычные переменные. Так же, как с наследованием, резолвинг инстансов тайпклассов (через имплиситы) происходит статически, на этапе компиляции. Для простоты в наших целях, тайпклассы можно воспринимать как расширения (extensions) из Kotlin и C#.


Чтобы залогировать объект, нам не нужно знать, что у этого объекта внутри, какие у него поля и методы. Нам важно лишь, чтобы для него было определено поведение log с определенной сигнатурой. Имплементировать некий интерфейс Logged в каждом классе было бы трудозатратно, да и не всегда возможно — например, у библиотечных или стандартных классов. В случае с тайпклассами все намного проще. Мы можем создать инстанс тайпкласса Logged, например, для фикстуры, и вывести ее в удобочитаемом виде. А для всех остальных типов создать инстанс для типа Any и использовать стандартный метод toString, чтобы бесплатно логировать любые объекты в своем внутреннем представлении.


Пример тайкласса Logged и инстансов к нему
trait Logged[A] {
  def log(a: A)(implicit logger: Logger): A
}

// Для всех Future
implicit def futureLogged[T]: Logged[Future[T]] = new Logged[Future[T]] {
  override def log(futureT: Future[T])(implicit logger: Logger): Future[T] = {
    futureT.map { t =>
      // map на Future позволяет вмешаться в ее результат после того, как она
      // выполнится
      logger.info(t.toString())
      t
    }
  }
}

// Фоллбэк, если в скоупе не нашлись другие имплиситы
implicit def anyNoLogged[T]: Logged[T] = new Logged[T] {
  override def log(t: T)(implicit logger: Logger): T = {
    logger.info(t.toString())
    t
  }
}

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


// Функция формирования фикстуры
def makeFixture[STATE, FX, ROW, F[_]](
  state: STATE,
  applyOverrides: F[ROW] => F[ROW] = x => x
): FX =
  (extractKeys andThen
    deduplicateKeys andThen
    enrichWithSampleData andThen
    applyOverrides andThen
    logged andThen
    buildFixture) (state)

override def extractKeys(implicit toKeys: ToKeys[DbState]): DbState => Set[Key] =
  (db: DbState) => db.toKeys()

override def enrichWithSampleData(implicit enrich: Enrich[Key]): Key => Set[Row] =
  (key: Key) => key.enrich()

override def buildFixture(implicit insert: Insertable[Set[Row]]): Set[Row] => DbFixture =
  (rows: Set[Row]) => rows.insert()

// Тайпклассы, описывающие разбиение чего-то (например, датасета) на ключи
trait ToKeys[A] {
  def toKeys(a: A): Set[Key]    // Something => Set[Key]
}
// ...конвертацию ключей в строки
trait Enrich[A] {
  def enrich(a: A): Set[Row]    // Set[Key] => Set[Row]
}
// ...и вставку строк в базу
trait Insertable[A] {
  def insert(a: A): DbFixture   // Set[Row] => DbFixture
}

// Имплементируем инстансы в конкретном проекте (см. в примере в конце статьи)
implicit val toKeys: ToKeys[DbState] = ???
implicit val enrich: Enrich[Key] = ???
implicit val insert: Insertable[Set[Row]] = ???

При проектировании генератора фикстур, я ориентировался на выполнение принципов программирования и проектирования SOLID как на индикатор его устойчивости и адаптируемости к разным системам:


  • Принцип единственной ответственности (The Single Responsibility Principle): каждый тайпкласс описывает ровно один аспект поведения типа.
  • Принцип открытости/закрытости (The Open Closed Principle): мы не модифицируем существующий боевой тип для тестов, мы расширяем его инстансами тайпклассов.
  • Принцип подстановки Лисков (The Liskov Substitution Principle) в данном случае не имеет значения, поскольку мы не используем наследование.
  • Принцип разделения интерфейса (The Interface Segregation Principle): мы используем много специализированных тайпклассов вместо одного глобального.
  • Принцип инверсии зависимостей (The Dependency Inversion Principle): реализация генератора фикстур зависит не от конкретных боевых типов, а от абстрактных тайпклассов.

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


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


Итоги


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


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


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




Ссылка на решение и пример: Github

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


  1. MonkAlex
    15.08.2019 08:54

    О, ровно по этой же проблеме вопрос, может чего интересного подскажете.
    Данные очень часто нужны прям 1в1 на нужный кейс, когда зависимости между сущностями из разряда A->B->C->D и такие данные никто кроме нашего конкретного теста переиспользовать не может.
    В итоге, на сотню другую тестов у нас оказывается порядка 30-40 наборов данных для тестов и они внезапно всё равно создаются копипастой (или дурацкими переусложнёнными билдерами, которые зато спасают от копипасты местами).
    Так вот, к чему я всё пишу — при внезапном изменении модели и\или бизнес логики все эти сотни тестов и десятки наборов данных всё равно становятся неактуальными или битыми, проверять почему посыпались тесты всё равно приходится руками. Или у вас другой опыт?


    1. zloddey
      15.08.2019 11:00

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


      1. Тесты становятся гораздо более наглядными — становится ясно, какие именно данные приводят к требуемому изменению поведения.
      2. Новые тесты становится писать гораздо проще.
      3. При изменении требований к базовому набору нужно делать правки только в одном месте, а не во всех.


      1. ThatAnnoyingCatAt4am Автор
        15.08.2019 16:38

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


    1. ThatAnnoyingCatAt4am Автор
      15.08.2019 16:29

      У меня был как раз такой опыт. Меняем контракты — ничего не компилится и нужно править тесты в 20 местах. Или еще хуже, компилится, но тесты вываливаются с какой-нибудь общей проблемой, типа HTTP 500 или неинформативным исключением.


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


      В моем примере, это PackageEntry, который содержит только связи других сущностей с Package. В теле теста мы описываем только табличку значений, из которых делаем PackageEntry — по сути, только говорим сколько и каких дефолтных объектов нам нужно — и оверрайды полей, а все остальные трансформации происходят за пределами теста. Этот пример будет хорошо работать с неплотными данными, если бОльшая часть полей в бОльшей части объектов не имеет значение в конкретном тесте (справочные данные, а не какая-нибудь хардкорная статистика).


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


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


      1. MonkAlex
        15.08.2019 17:03

        Примерно так же стараемся делать, но это дофига накладных на всё вокруг, что печалит.

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