Или как писать долгоживущие тесты

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

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

Воображаемое приложение

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

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

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

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

Явно укажите только то, что влияет на результат теста

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

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

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

Первый и третий недостатки можно легко преодолеть, создав производный класс от PrintOrder с конструктором, который предопределяет значения по умолчанию для своих параметров. К сожалению, для устранения всех трех недостатков необходимо использовать более сложный подход. Основная идея состоит в том, чтобы создать фабрику, которая может производить объекты с параметрами, настроенными с помощью гибкого синтаксиса. Значения явно указываются только для используемых параметров объекта, остальные параметры используют предопределенные значения по умолчанию.

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

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

Избегайте множественных утверждений во время проверки объектов

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

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

Построение эталонных объектов с помощью генераторов делает их многоразовыми, а тест можно легко параметризовать, как было показано во второй версии ShouldVerifyPrintOrderProcessingResultsMapping теста. Более того, использование компаратора внешних объектов может сократить процедуру проверки правильности объекта до нескольких строк кода.

Тестируйте только то, что действительно нужно тестировать

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

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

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

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

В приведенных примерах в тестах было зафиксировано поведение, которое было вызвано не требованиями предметной области, а особенностями их реализации и при изменении требований тесты стали падать. Именно поэтому лучше писать тесты с точки зрения предметной области приложения, ведь тогда в ней невозможно протестировать избыточное или бессмысленное поведение. Я думаю, что для этого подходит термин Domain Driven Ttesting (DDT).

Иерархическое тестирование вместо насмешек

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

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

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

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

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

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

Вывод

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