Повышение ремонтопригодности тестов и укрепление доверия
Эта статья была первоначально опубликована на InfoQ 21 февраля 2023 г.
Ключевые выводы
- Модульные тесты должны повысить уверенность в том, что наш код работает правильно, позволить нам документировать, как наш код должен функционировать, и помочь в разработке слабосвязанного, высоко связанного программного обеспечения.
- Модульные тесты изолированы от остальной части кодовой базы, что помогает им быть быстрыми в выполнении, простыми в написании, а также простыми в понимании и сопровождении.
- Двойники тестов или объекты, которые заменяют соавторов, могут помочь облегчить изоляцию модульных тестов.
- Интенсивное использование фиктивных объектов в модульных тестах обеспечивает меньшую степень уверенности в том, что тестируемое поведение работает правильно.
- Поддельный объект может изолировать модульный тест, повышая при этом уверенность в том, что он проверяет желаемое поведение.
Разработчики пишут тесты, чтобы повысить уверенность в том, что производственный код работает правильно, задокументировать намерения и помочь в разработке приложений. В последнее время мы наблюдаем тенденцию разработчиков к интенсивному использованию тестовых двойников, особенно моков, в модульных тестах. Это делается для повышения скорости тестов, уменьшения зависимости тестов от инфраструктуры или уменьшения количества объектов, от которых зависит тест. Однако это часто обходится неприемлемо высокой ценой низкой уверенности, нечеткой документации и сильной связи между реализацией и тестовым кодом.
Чтобы избежать этих недостатков, разработчикам следует рассмотреть возможность использования поддельных объектов вместо фиктивных объектов, поскольку поддельные объекты предлагают аналогичные преимущества изоляции, обеспечивая при этом высокую достоверность, четкую документацию и слабую связь между реализацией и тестовым кодом.
Фон
Мы называем тесты более низкого уровня модульными тестами, чтобы описать, что эти тесты каким-то образом изолированы от окружающего их кода. Из-за этой изоляции модульные тесты должны быть быстрыми для запуска, простыми в написании и легкими для понимания и обслуживания.
Разработчики часто используют тестовых двойников как способ облегчить эту изоляцию. Тестовый двойник — это любой объект, используемый в тесте, который заменяет соавтора. В своей книге Тестовые шаблоны xUnit Жерар Месарош выделяет несколько категорий тестовых двойников: манекены, шпионы, заглушки, подделки и макеты. Для наших целей мы сосредоточимся на последних двух:
- Мок-объекты предварительно запрограммированы со спецификациями вызовов, которые они ожидают получить, и их ответом на эти вызовы. У них есть механизм для проверки того, что они получают правильные вызовы во время теста и не проходят тесты, если вызовы не соответствуют их ожиданиям. Люди часто используют такие фреймворки, как Mockito, Mockk или GoMock, для создания фиктивных объектов.
- Поддельные объекты — это функционирующие реализации соавторов, которые используют какой-то ярлык, чтобы сделать их более подходящими для запуска в тестовой среде. Например, разработчик может создать хранилище данных в памяти вместо объекта, который сохраняет данные в S3 для локального запуска тестов.
Обычно в наборе тестов современной кодовой базы имитируется практически все, и набор работает без каких-либо вспомогательных служб. В таком случае набор тестов обеспечивает высокую степень уверенности в том, что каждая часть системы работает правильно по отдельности, но мало уверенности в том, что они работают правильно вместе. Позже мы обсудим, когда использование моков неуместно.
Например, многие наборы тестов имитируют уровень базы данных во время тестирования. Тесты проверяют правильность обращений к базе данных и используют предварительно запрограммированные ответы. Для такого набора тестов трудно дать нам уверенность в том, что код будет вести себя правильно в рабочей среде, поскольку база данных никогда не проверяется, и ожидаемые вызовы, запрограммированные в макетах, могут быть неверными, не говоря уже о том, что с подходом только для макетов Операторы SQL остаются непроверенными.
Изоляция
Существует общее мнение, что слово «единица» в модульном тестировании относится к изолированным единицам. То есть модульные тесты на каком-то уровне изолированы от остальной кодовой базы. Однако мнения расходятся при определении того, что представляет собой изолируемая единица.
Это определение является существенным. Единица изоляции определяет объем каждого теста, взаимосвязь между тестовым кодом и производственным кодом и, в конечном счете, архитектуру приложения. Исторически существовали общепринятые определения единицы, о которых мы поговорим ниже.
Тестовая изоляция
Кент Бек, представляющий классический подход к тестированию, утверждает, что
Модульные тесты полностью изолированы друг от друга, каждый раз создавая свои тестовые фикстуры с нуля.
В этом подходе слово модуль в модульном тестировании относится к самому тесту: модульные тесты изолированы от других тестов. Бек утверждает, что «тесты должны быть связаны с поведением кода и отделены от его структуры».
Тесты, написанные с использованием этого подхода, как правило, содержат мало моков. Вместо этого они используют экземпляры взаимодействующих объектов и даже реальную вспомогательную инфраструктуру (например, базы данных) для поддержки каждого запуска теста.
Например, классический тест, в котором испытуемый обращается к базе данных, будет использовать реальную базу данных во время тестов. Тесты должны убедиться, что база данных находится в правильном состоянии перед запуском, и подтвердить, что результирующее состояние базы данных соответствует ожиданиям.
Классический тест с субъектом, который делает внешние HTTP-вызовы, будет делать HTTP-вызовы при выполнении теста. Поскольку внешние вызовы часто снижают надежность теста, автор может запустить локально HTTP-сервер, который ведет себя аналогично внешней службе.
Тесты классического стиля обеспечивают высокую степень уверенности в правильности поведения тестируемого кода. При рефакторинге кода тесты, как правило, не меняются, так как мало знают о внешнем интерфейсе соавторов.
Изоляция субъекта
Стив Фриман и Нэт Прайс, представляющие фиктивный подход к тестированию, утверждают, что
[модульные тесты] тестируют объекты или небольшие кластеры объектов изолированно.
Фриман утверждает, что модульные тесты «важны, чтобы помочь нам в разработке классов и дать нам уверенность в том, что они работают, но они ничего не говорят о том, работают ли они вместе с остальной частью системы». В этом подходе слово «единица» в модульном тестировании относится к тестируемому субъекту.
Тесты, написанные с использованием этого подхода, должны использовать тестовые двойники, чтобы заменять соавторов, и, как правило, иметь много имитаций. Они редко используют настоящую вспомогательную инфраструктуру, предпочитая макеты или подделки. Идея состоит в том, что мы должны изолировать испытуемых от поведения их коллег во время испытаний; изменение поведения одного объекта не должно влиять на тесты другого объекта. Разработчики также используют макеты для повышения скорости и надежности своих тестов, используя макеты, чтобы заменить медленных или ненадежных сотрудников.
Например, фиктивный тест, субъект которого обращается к базе данных, будет имитировать уровень базы данных при выполнении теста. Субъект будет взаимодействовать с фиктивным объектом базы данных, записывая вызовы по мере того, как он возвращает готовые ответы во время теста, и проверяя ожидания в конце.
Имитационный тест с субъектом, который совершает внешние HTTP-вызовы, будет использовать фиктивный HTTP-клиент при выполнении теста. Этот клиент будет возвращать предварительно запрограммированные ответы на вызовы HTTP во время теста. После теста автор теста использовал макет, чтобы проверить, были ли сделаны правильные вызовы HTTP.
Эти тесты, как правило, выполняются быстро и надежно, но они обеспечивают меньшую степень уверенности в том, что тестируемое поведение работает правильно. Когда код изменяется или реорганизуется, тесты, как правило, требуют значительных изменений, поскольку они хорошо знакомы с внешним интерфейсом сотрудников.
Кроме того, использование макетов увеличивает объем тестового кода. Во многих языках, таких как Go, mockist должен написать или сгенерировать все макеты и сохранить код в своей кодовой базе. Это эффективно удваивает размер набора тестов. Даже в таких языках, как Kotlin и Java, где макеты генерируются во время выполнения, макеты должны быть запрограммированы перед каждым тестом и проверены после каждого теста, что приводит к необходимости поддерживать больше тестового кода.
На практике
Чтобы определить подход для использования на практике, мы должны сначала перечислить цели, которых мы пытаемся достичь, написав тесты. Мы хотим:
- Повысить уверенность в том, что наш код работает правильно.
- Задокументируйте, как должен работать наш код.
- Помощь в разработке слабосвязанного, высоко связанного программного обеспечения.
Помня об этих целях, я бы посоветовал начать с изолированного подхода к модульным тестам. Если каждый тест надежно работает изолированно, используя как можно больше реальных соавторов, мы достигаем следующего.
Уверенность, поскольку наши тесты выполняются в среде, близкой к производственной. Мы можем быть уверены, что наши испытуемые функционируют правильно как изолированно, так и согласованно. Наши тесты также дают нам уверенность в том, что наши испытуемые ведут себя правильно в согласии со своими внешними сотрудниками. При использовании фиктивного подхода у нас нет такой же уверенности в том, что наши испытуемые хорошо работают вместе.
Четкая документация, так как читатели могут видеть, как наш код следует использовать в производственной среде. Разработчики, читающие наши тесты, могут просто, например, изучить ожидаемое состояние базы данных, которое должно произойти в результате данной операции, чтобы увидеть, что произойдет в производственной среде. Разработчики, читающие фиктивные тесты, должны преобразовывать ответы и ожидания каждой фиктивной модели в операции с реальным соавтором, что значительно снижает ясность и удобочитаемость.
Продуманный дизайн. Рефакторинг происходит независимо от тестового кода, поэтому его относительно легко выполнить, и поэтому он происходит часто. При использовании фиктивного подхода рефакторинг объекта путем изменения его внешнего интерфейса, например, требует перезаписи или повторного создания всех макетов для этого объекта. При использовании классического подхода нет необходимости переписывать макеты, поэтому рефакторинг требует меньшего количества изменений в тестовом коде. Это упрощает выполнение рефакторинга, что означает, что он происходит чаще, а структура кодовой базы со временем улучшается.
Быть гибким
На практике я рекомендую подход к изоляции тестов, который начинается с классического стиля и при необходимости возвращается к мокистскому стилю. Мартин Фаулер говорит: «Я не рассматриваю использование двойников для внешних ресурсов как абсолютное правило. Если общение с ресурсом для вас стабильно и достаточно быстро, то нет причин не делать это в ваших юнит-тестах. […] Действительно, когда в 90-х годах началось тестирование xunit, мы не пытались работать в одиночку, если только общение с сотрудниками не было неудобным (например, система удаленной проверки кредитной карты)».
Пока мы используем быстрых и надежных соавторов (что в любом случае должно быть нашей целью), тестирование с реальными соавторами не оказывает негативного влияния на скорость и надежность наших тестов. Когда это не так (например, при сотрудничестве с внешней службой через HTTP), дублирование тестов — отличный способ повысить скорость и надежность теста, жертвуя при этом уверенностью, ясностью и гибкостью.
При рассмотрении типа двойного теста отдавайте предпочтение подделкам, а не имитациям. Подделки имеют несколько ключевых преимуществ перед подделками:
- Подделка больше похожа на настоящего сотрудника, чем на макет, что придает нам больше уверенности.
- Мы взаимодействуем с подделкой так же, как и с реальным соавтором, поэтому документация лучше.
- Подделка должна обновляться всякий раз, когда обновляется настоящий соавтор, как и макет. Однако с подделками не нужно обновлять ожидания или проверки, поэтому рефакторинг кодовой базы с подделками, как правило, проще, чем рефакторинг кодовой базы с моками.
Заключение
Определяя свой подход к тестированию, тщательно обдумайте свой подход к изоляции модулей, чтобы вы знали о преимуществах и недостатках начала с классического или фиктивного подхода. Всегда будьте готовы адаптировать свой подход в зависимости от характера ваших сотрудников. В конечном счете, всем нам нужен быстрый и надежный набор тестов, который дает нам уверенность при выпуске продукта, четко документирует наши намерения и помогает разработать расширяемую систему.