Как узнать, что компоненты взаимодействуют правильно?
Большинство программных систем состоят из графа компонентов. Поясню: я использую слово «компонент» в широком смысле, имея в виду набор функциональных возможностей — это может быть объект, модуль, функция, тип данных или что-то иное. Некоторые компоненты работают с более общей картиной и, как правило, координируют работу других компонентов, выполняющих более специфические задачи. Если представить граф компонентов в виде дерева, то некоторые компоненты будут листьями.
![Пример компонентного графа с четырьмя листьями. Пример компонентного графа с четырьмя листьями.](https://habrastorage.org/getpro/habr/upload_files/7ba/098/a28/7ba098a28760b71070e220f0cf104411.png)
Листовые компоненты, будучи самодостаточными и не имея зависимостей, обычно легче всего поддаются тестированию. Большинство задач по разработке через тестирование (TDD) сосредоточены именно на таких компонентах: теннис, боулинг, ромб, римские цифры, «водители-собиратели сплетен» и так далее. Даже задача c устаревшим менеджером безопасности проста и вполне самодостаточна. В этом нет ничего плохого, и есть веские причины, чтобы такие упражнения оставались простыми. В конце концов, вы хотите завершить задачу за несколько часов. Вряд ли это будет возможно, если задание предполагает разработку целого веб-сайта с пользовательским интерфейсом, постоянным хранением данных, безопасностью, валидацией данных, продуманной бизнес-логикой, интеграцией с третьими сторонами, email-рассылками, логированием и так далее.
Это означает, что даже если вы хорошо овладеете TDD для «листовой» функциональности, у вас могут возникнуть трудности при работе с компонентами более высокого уровня. Как писать юнит-тесты для кода, который имеет зависимости?
Тестирование на основе взаимодействия
Распространённым решением является принцип инверсии зависимостей. Например, вы можете использовать внедрение зависимостей (Dependency Injection), чтобы внедрить тестовые дублёры в тестируемую систему (SUT). Это позволит вам контролировать поведение зависимостей и проверять, что тестируемая система ведёт себя так, как ожидается. Кроме того, вы можете убедиться, что SUT взаимодействует с зависимостями так, как предполагается. Это называется тестированием на основе взаимодействия. Это, пожалуй, самая распространённая форма юнит-тестирования в индустрии, прекрасно описанная в книге «Разработка объектно-ориентированного программного обеспечения, управляемая тестами».
Наиболее полезными типами тестовых дублёров для тестирования, основанного на взаимодействии, являются стабы и моки. Однако с ними возникают проблемы, поскольку они нарушают инкапсуляцию. А инкапсуляция, как известно, является важным аспектом в функциональном программировании.
Я уже описывал, как перейти от тестирования на основе взаимодействия к тестированию на основе состояния, и почему функциональное программирование по своей сути более тестируемо.
Как протестировать композицию чистых функций?
Когда вы переходите к функциональному программированию, вам рано или поздно понадобится составлять или оркестрировать чистые функции. Как проверить, что композиция чистых функций работает правильно? Это можно проверить с помощью моков или шпионов.
Вы разработали компонент A, возможно, в виде функции высшего порядка, которая зависит от другого компонента B. Вы хотите проверить, что A правильно взаимодействует с B, но если тестирование на основе взаимодействия больше «не допускается» (потому что оно нарушает инкапсуляцию), то что же делать?
Долгое время я сам размышлял над этим вопросом, пока наслаждался тем, как функциональное программирование облегчает большинство задач. Мне потребовалось некоторое время, чтобы понять, что ответ, как это часто бывает — Му. Я вернусь к этому позже.
«У меня есть компонент A, который, честно говоря, выполняет роль контроллера, проводя проверки и обработку вокруг довольно сложного состояния. Этот процесс может иметь несколько исходов, назовём их Success, Fail и Missing (фактические состояния не важны, но я хотел бы иметь больше двух). Также у нас есть компонент B, который отвечает за отображение результата. Конечно, три разных состояния приводят к трём разным отображениям, но отображения также зависят от состояния (допустим, у нас есть браузерный, мобильный и нативный клиенты, и нам нужно получить разные отображения). Изначально компоненты являются объектами, причём у B есть три отдельных метода, но я могу выразить их в виде чистых функций, по крайней мере, для целей этого обсуждения — A, а затем BSuccess, BFail и BMissing. Я могу легко протестировать каждую часть B в отдельности; проблема возникает, когда мне нужно протестировать A, который вызывает разные части B. Если я использую моки, решение простое — я внедряю мок B в A, а затем проверяю, что A вызывает соответствующие части в соответствии с результатом процесса. Это требует знания внутренностей A, но в остальном это хорошо известный и понятный подход. Но если я хочу избежать использования моков, что мне делать? Я не могу протестировать A, не опираясь на какой-то путь выполнения кода в B, и это для меня означает, что я теряю преимущества юнит-тестирования и перехожу в область интеграционного тестирования».
В своем письме Сергей Роговцев недвусмысленно разрешил мне процитировать его и поднять этот вопрос. Как я уже говорил, я сам занимался этим вопросом, поэтому считаю его заслуживающим внимания. Однако я не могу работать с ним, не подвергая сомнению его предпосылки. Это не критика Сергея Роговцева; в конце концов, я сам задавался этим вопросом, так что любая моя критика направлена в такой же степени и на меня самого.
Аксиоматическое vs научное знание
Возможно, будет полезно поднять дискуссию. Откуда мы знаем, что программное обеспечение (или его подсистема) работает? Можно сказать, что один из ответов на этот вопрос — прохождение тестов. Если все тесты пройдены, мы можем с высокой степенью уверенности утверждать, что система работает.
Выражаясь языком Сергея Роговцева, мы можем легко протестировать компонент B, потому что он состоит из чистых функций.
Как же нам протестировать компонент A? С помощью моков и стабов можно доказать, что взаимодействие работает так, как задумано. Ключевое слово здесь — доказать. Если вы предполагаете, что компонент B работает правильно, «всё», что вам нужно сделать, это продемонстрировать, что компонент A правильно взаимодействует с компонентом B. Раньше я часто этим занимался и называл это верификацией потока данных или структурной проверкой. Идея заключалась в том, что если вы можете продемонстрировать, что компонент A правильно взаимодействует с любой LSP-совместимой реализацией компонента B, а затем также продемонстрировать, что в реальности (при компоновке в корне композиции) компонент A компонуется с компонентом B, который также был продемонстрирован как правильно работающий, то (под)система работает правильно.
Это почти как математическое доказательство. Сначала докажите лемму B, затем докажите теорему A, используя лемму B. Наконец, сформулируйте следствие C: b — это частный случай, рассматриваемый леммой B, следовательно, a охватывается теоремой A. Что и требовалось доказать.
Это логический и дедуктивный подход к проблеме проверки композиции целого из проверяемых частей. Он почти математический в том смысле, что пытается построить аксиоматическую систему.
Это также фундаментальный недостаток.
Я не понимал этого десять лет назад, и на практике этот метод работал довольно хорошо — если не считать проблем, возникающих из-за плохой инкапсуляции. Проблема с этим подходом заключается в том, что аксиоматическая система сильна только настолько, насколько сильны её аксиомы. Что такое аксиомы в этой системе? Аксиомы, или предпосылки, заключаются в том, что каждый из компонентов (A и B) уже корректен. Основываясь на этих предпосылках, данный подход к тестированию доказывает, что композиция также корректна.
Как мы узнаем, что компоненты работают правильно?
В данном контексте ответ заключается в том, что они проходят все тесты. Однако это не является доказательством. Скорее, это экспериментальное знание, больше напоминающее науку, чем математику.
Зачем тогда мы пытаемся доказать, что композиция работает правильно? Почему бы просто не проверить это?
Это замечание затрагивает суть эпистемологии тестирования. Как мы узнаем, что программное обеспечение работает? Как правило, не доказывая его правильность, а подвергая экспериментам. Как я уже рассказывал в книге Code That Fits in Your Head, автотесты можно рассматривать как научные эксперименты, которые мы повторяем снова и снова.
Интеграционное тестирование
Вкратце опишем аргументы, которые мы приводили до сих пор: Хотя для проверки корректности взаимодействия компонента с другим компонентом вы можете использовать моки и шпионы, это может быть излишним. По сути, вы пытаетесь доказать предположение, основанное на сомнительных доказательствах.
Действительно ли важно, чтобы два компонента взаимодействовали правильно? Разве компоненты не являются деталями реализации? Волнует ли это пользователей?
Пользователей и других заинтересованных лиц волнует поведение программной системы. Почему бы не протестировать это?
К сожалению, это легче сказать, чем сделать. Сергей Роговцев недвусмысленно намекает на то, что он не в восторге от интеграционного тестирования. Хотя он и не объясняет прямо, почему, есть веские причины опасаться интеграционного тестирования. Как красноречиво объяснил Дж. Б. Рейнсбергер, основная проблема интеграционного тестирования — это комбинаторный взрыв тест-кейсов. Если вы должны написать 53 000 тест-кейсов, чтобы охватить все комбинации путей через интегрированные компоненты, какие тест-кейсы вы напишете? Конечно, не все 53 000.
Аргумент Дж. Б. Рейнсбергера заключается в том, что если вы собираетесь написать не более десятка юнит-тестов, то вряд ли сможете охватить достаточное их количество, чтобы быть уверенным в работоспособности системы.
А что если, однако, вы можете написать сотни или тысячи тест-кейсов?
Тестирование на основе свойств
Возможно, вы помните, что в основе этой статьи лежит концепция функционального программирования, где тестирование на основе свойств является распространённой техникой тестирования. Хотя вы можете в некоторой степени использовать эту технику и в объектно-ориентированном программировании, это часто бывает сложно из-за побочных эффектов и недетерминированного поведения.
Когда вы пишете тест на основе свойств, вы пишете один кусок кода, который оценивает свойство тестируемой системы. Свойство похоже на параметризованный юнит-тест; разница в том, что входные данные генерируются случайным образом, но вы можете ими управлять. Это позволяет вам написать сотни или тысячи тест-кейсов без необходимости писать их явно.
Таким образом, эпистемологически вы можете использовать тестирование на основе свойств с интегрированными компонентами, чтобы убедиться в том, что (под)система работает. На практике я обнаружил, что эта техника даёт мне такое же чувство уверенности, как и юнит-тестирование с помощью стабов и шпионов.
Примеры
Я понимаю, что всё это звучит абстрактно и теоретически. Пример был бы как раз кстати. Однако такие примеры достаточно сложны, а потому заслуживают отдельной статьи:
Ресторанный пример рефакторинга от тестирования на основе примеров к тестированию на основе свойств
Рефакторинг чистой композиции функций без разрушения существующих тестов
Сергей Роговцев любезно предоставил довольно абстрактный, но минимальный и самодостаточный пример. Я сначала разберу его, а затем рассмотрю более реалистичные примеры.
Заключение
Как узнать, что программная система работает правильно? В конечном итоге, если она ведёт себя так, как ожидается, значит, она работает правильно. Однако тестирование всей системы извне редко бывает целесообразным. Количество возможных тест-кейсов просто слишком велико.
Частично решить эту проблему можно, разложив систему на компоненты. Затем можно тестировать компоненты по отдельности и проверять правильность их взаимодействия. Эта последняя часть и является темой данной статьи. Обычный способ решения этой проблемы — использовать моки и шпионы для доказательства корректности взаимодействий. Это решает проблему корректности достаточно аккуратно, но имеет нежелательный побочный эффект — делает тесты хрупкими.
Альтернативой является использование тестирования на основе свойств для проверки того, что компоненты правильно интегрируются. Это не похоже на доказательство, это вопрос цифр. Набросайте на систему достаточно случайных тест-кейсов, и вы будете уверены, что она работает. Сколько? Достаточно.
Больше практических навыков по тестированию вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.