Хабрахабр

Выбираемся из дебрей тестов: строим короткий путь от фикстуры к проверке

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

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

Проблема

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

// Данные тесты описывают простой магазин. В зависимости от совокупности
// своей роли, количества бонусов и общей стоимости заказа, пользователь
// может получить скидку разного размера. "Если роль покупателя = 'customer'" - "И общая сумма покупки >= 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. Но на этом этапе данные предельно дешево дедуплицировать — поскольку ключи содержат только идентификаторы, мы можем сложить их в коллекцию, обеспечивающую дедупликацию, например в Set. Может случиться так, что две разные сущности сгенерируют зависимость с одинаковым идентификатором, что может привести к нарушению ограничения по первичному ключу БД (primary key). Если этого окажется недостаточно, мы всегда можем сделать более умную дедупликацию в виде дополнительной функции, скомпозированной в функцию жизненного цикла.

Пример ключей

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 в каждом классе было бы трудозатратно, да и не всегда возможно — например, у библиотечных или стандартных классов. А для всех остальных типов создать инстанс для типа Any и использовать стандартный метод toString, чтобы бесплатно логировать любые объекты в своем внутреннем представлении. Мы можем создать инстанс тайпкласса Logged, например, для фикстуры, и вывести ее в удобочитаемом виде.

Пример тайкласса 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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть