Интеграционное тестирование
Важно иметь возможность проводить некоторые интеграционные тестирования без обязательного развертывания на сервере приложений или подключения к другой корпоративной инфраструктуре. Это позволит тестировать такие вещи, как:
- Правильное подключение контекстов IoC-контейнера Spring.
- Доступ к данным с помощью JDBC или ORM-инструмента. Это может включать, среди прочего, корректность SQL-инструкций, запросов Hibernate, отображений сущностей JPA и так далее.
Spring Framework предоставляет первоклассную поддержку интеграционного тестирования в модуле spring-test . Имя фактического JAR-файла может включать версию выпуска, а также может быть представлено в длинной форме org.springframework.test , в зависимости от того, откуда вы его получаете (обратитесь к разделу об управлении зависимостями за пояснениями). Данная библиотека включает пакет org.springframework.test , который содержит полезные классы для интеграционного тестирования с участием контейнера Spring. Такое тестирование не зависит от сервера приложений или другой среды развертывания. Такие тесты выполняются медленнее, чем модульные, но гораздо быстрее, чем эквивалентные тесты Selenium или удаленные тесты, которые полагаются на развертывание на сервере приложений.
Поддержка модульного и интеграционного тестирования представлена в виде аннотационно-ориентированного фреймворка Spring TestContext Framework. Фреймворк TestContext не зависит от реально используемой инфраструктуры тестирования, что позволяет инструментировать тесты в различных средах, включая JUnit, TestNG и другие.
Цели интеграционного тестирования
Поддержка интеграционного тестирования в Spring преследует следующие основные цели:
- Управление кэшированием IoC-контейнера Spring между тестами.
- Обеспечение внедрения зависимостей для экземпляров тестового стенда (среды).
- Обеспечение, соответствующего интеграционному тестированию.
- Предоставление специфичных для Spring основных классов, которые помогают разработчикам в написании интеграционных тестов.
В следующих нескольких разделах описывается каждая цель и приводятся ссылки на детальные пояснения по реализации и конфигурированию.
Управление контекстом и кэширование
Spring TestContext Framework обеспечивает последовательную загрузку экземпляров ApplicationContext и WebApplicationContext из Spring, а также кэширование этих контекстов. Поддержка кэширования загруженных контекстов важна, поскольку время начального запуска может стать проблемой – не из-за задержки в самом Spring, а из-за того, что объектам, экземпляры которых создаются контейнером Spring, требуется время на создание. Например, в проекте с 50-100 файлами отображения Hibernate может потребоваться от 10 до 20 секунд для загрузки файлов отображения, и эти затраты перед запуском каждого теста в каждом тестовом стенде приводят к замедлению общего процесса тестирования, что снижает производительность разработчиков.
Тестовые классы обычно объявляют либо массив расположения ресурсов для метаданных конфигурации XML или Groovy – часто в пути классов – либо массив компонентных классов, который используется для конфигурирования приложения. Эти расположения или классы являются такими же или похожими на те, которые указаны в web.xml или других конфигурационных файлах для развёртывания в производственной среде .
По умолчанию после загрузки сконфигурированный ApplicationContext используется повторно для каждого теста. Таким образом, ресурсы на настройку тратятся только единожды для каждого тестового комплекта, а последующее выполнение тестов происходит гораздо быстрее. В данном контексте термин «тестовый комплект» означает все тесты, выполняемые в одной JVM – например, все тесты, выполняемые из сборки Ant, Maven или Gradle для данного проекта или модуля. В маловероятном случае, если тест повреждает контекст приложения и требует перезагрузки (например, при изменении определения бина или состояния объекта приложения), фреймворк TestContext можно настроить на перезагрузку конфигурации и восстановление контекста приложения перед выполнением следующего теста.
Внедрение зависимостей для тестовых стендов
Когда фреймворк TestContext загружает контекст приложения, он может опционально настраивать экземпляры ваших тестовых классов с помощью внедрения зависимостей. Это позволяет обеспечить удобный механизм для настройки тестовых стендов с помощью предварительно сконфигурированных бинов из контекста вашего приложения. Большим преимуществом здесь является возможность повторного использования контекстов приложений в различных сценариях тестирования (например, для настройки графов объектов, управляемых Spring, транзакционных прокси, экземпляров DataSource и др.), что избавляет от необходимости дублировать сложную настройку тестовых стендов для отдельных тестовых случаев.
В качестве примера рассмотрим сценарий, в котором у нас есть класс ( HibernateTitleRepository ), реализующий логику доступа к данным для сущности предметной области Title . Мы хотим написать интеграционные тесты, которые тестируют следующие области:
- Конфигурация Spring: Главным образом, корректно ли представлено и имеется ли все необходимое, что связано с конфигурацией бина HibernateTitleRepository ?
- Конфигурация файла отображения Hibernate: Все ли правильно отображено и установлены ли правильные настройки отложенной загрузки?
- Логика репозитория HibernateTitleRepository : Выполняет ли сконфигурированный экземпляр этого класса ожидаемые функции?
Управление транзакциями
Одной из распространенных проблем в тестах, которые обращаются к реальной базе данных, является их влияние на состояние хранилища постоянного хранения (persistence store). Даже если вы используете базу данных для разработки, изменения состояния могут повлиять на будущие тесты. Кроме того, многие операции – такие как вставка или изменение постоянно хранимых данных – нельзя выполнять (или производить проверку) вне транзакции.
Фреймворк TestContext решает эту проблему. По умолчанию фреймворк создает и откатывает транзакцию для каждого теста. Можно написать код, способный предполагать существование транзакции. Если вы вызываете проксированные объекты в своих тестах транзакционно, они ведут себя правильно, в соответствии с настроенной транзакционной семантикой. Кроме того, если тестовый метод удаляет содержимое выбранных таблиц во время выполнения управляемой для теста транзакции, то эта транзакция откатывается по умолчанию, а база данных возвращается в состояние, предшествовавшее выполнению теста. Поддержка транзакций предоставляется тесту с помощью бина PlatformTransactionManager , определенного в контексте приложения теста.
Если вам нужно, чтобы транзакция была зафиксирована (нестандартно, но иногда полезно, если требуется, чтобы определенный тест заполнил или изменил базу данных), то можно указать фреймворку TestContext вызвать фиксацию транзакции вместо отката, используя аннотацию @Commit .
См. управление транзакциями с помощью фреймворка TestContext (см. ссылку в конце лекции).
Вспомогательные классы для интеграционного тестирования
Spring TestContext Framework предоставляет несколько абстрактных вспомогательных классов, которые упрощают написание интеграционных тестов. Эти основные тестовые классы предоставляют четко определенные перехватчики, подключаемые к инфраструктуре тестирования, а также вспомогательные переменные экземпляров и методы, которые позволяют получить доступ к:
- ApplicationContext – для выполнения явного поиска бинов или тестирования состояния контекста в целом.
- JdbcTemplate – для выполнения SQL-инструкций для запросов к базе данных. Можно использовать такие запросы для подтверждения состояния базы данных как до, так и после выполнения кода приложения, связанного с базой данных, и Spring обеспечит, что такие запросы будут выполняться в рамках той же транзакции, что и код приложения. При использовании в сочетании с ORM-инструментом следует избегать ложных срабатываний (см. ссылку в конце лекции).
Кроме того, вам может потребоваться создать свой собственный специальный суперкласс для всего приложения с переменными экземпляров и методами, специфичными для вашего проекта.
См. всё про фреймворк TestContext и его взаимодействие с: «Управлением/кэшированием контекста», вспомогательных классов для фреймворка, а также внедрять зависимости в тестовые стенды и управленять транзакциями с помощью.
Интеграционные тесты в микросервисах
Обсуждаем несколько подходов к тестированию микросервисов, чтобы понять, сколько нужно тестов и какие кейсы они должны покрывать.
Константин Яковлев
Senior Developer в DataArt
Кто любит, когда в пятницу вечером из продакшена прилетает баг и надо срочно его фиксить? Или когда все юнит-тесты зеленые, а на тестовом энвайроменте сервис не запускается? Скорее всего — никто. Все мы заинтересованы в качестве продукта, над которым работаем. Не только потому что мы ответственные разработчики, но и потому что любим отдохнуть в выходные.
Но появление багов неизбежно. Чтобы обеспечить качество продукта, нам необходимо выявлять их как можно раньше — в идеале, до того как наше решение ушло в продакшн. Для этого есть разные виды автоматического тестирования, начиная с выявления ошибок компиляции, заканчивая UI-тестированием на препродакшене и хорошо настроенными CI-процессами.
Написание тестов — не такая простая задача, какой кажется на первый взгляд. Мы всегда должны выбирать правильные подходы и где-то чем-то жертвовать для максимальной выгоды. В статье я хотел бы сфокусироваться на интеграционном тестировании и обсудить несколько подходов к нему — не всегда понятно, сколько нужно таких тестов и какие кейсы они должны покрывать.
Для начала определимся, что такое интеграционное тестирование или, как его еще называют, тестирование сервиса. Рассмотрим на примере небольшого проекта, какие вообще бывают типы тестов. Проект состоит из двух микросервисов: 1) сервис A — stateful и хранит состояние в некой DB; 2) сервис B — stateless и может являться некоторым воркером. Еще у нас есть WebApp, через которое мы взаимодействуем с нашими сервисами.
Самый первый вид тестирования — Unit-тестирование. Не буду углубляться в подробности, т. к. он всем хорошо известен. Просто скажу, что Unit-тестирование или, как его еще называют, изолированное тестирование — тестирование на уровне класса или группы классов, с помощью которого можно проверить каждый метод или функцию. Такое тестирование дает уверенность, что отдельные части кода работают, но не говорит, работает ли код в целом.
Этот минус решает Integration-тестирование, или тестирование сервиса. Здесь мы проверяем весь сервис в изоляции. Т.е. мы мокаем все внешние зависимости на другие сервисы. В нашем примере получаются сквозные тесты микросервиса A от HTTP request до DB и обратно. При этом виде тестирования мы можем быть уверены, что правильно настроен DI, все компоненты нормально работают вместе, и поведение соответствует бизнес-сценариям.
Но мы живем в микросервисном мире, и проверка каждого сервиса по отдельности не дает уверенности, что вся система работает. Тут на помощь приходит последний вид тестирования — end-to-end (E2E), или UI-тестирование. Оно может быть автоматическим и ручным. Проверяется работа всех компонентов системы вместе на соответствие бизнес-требованиям. Если Unit- и Integration-тестирование — по большей части, проверка с технической точки зрения, то E2E — проверка ожиданий пользователя от работы системы.
Ни одно обсуждение тестирования нельзя считать полным без упоминания пирамиды тестирования, предложенной Майком Коном в книге «Succeeding with Agile». Согласно пирамиде, самые многочисленные тесты — Unit. Они маленькие, изолированные и могут проверить любую отдельную часть вашего сервиса, вплоть до строчки кода. Далее — Integration. Это более объемные тесты, которые проходят через весь pipeline сервиса. И на вершине — E2E тесты, которые дают нам большую уверенность в работе системы, но самые долгие в имплементации и самыми неинформативные, поэтому их должно быть меньше всего. Также пирамида говорит, что, чем ближе к основанию, тем больше скорость написания тестов. Чем дальше, тем дороже написание, поддержка, и, в случае дефекта, — поиск причины.
Это была классическая стратегия тестирования, давайте рассмотрим и другие.
Перевернутая пирамида, или рожок тестирования. В этой стратегии основной упор — на E2E-тесты, т. к. они дают наибольшую уверенность, что работа всей системы полностью соответствует ожиданиям конечного пользователя. Одновременно это ловушка. С одной стороны, мы уверены в качестве продукта, а с другой — тратим огромное время на получение фидбека, что система работает после внесения каких-либо изменений. Если каждый Unit-тест выполняется за миллисекунды, Integration — за секунды, E2E может занимать несколько десятков секунд или минуты. И даже если тест выявил дефект, мы точно не знаем, в каком сервисе и в каком месте кода произошел сбой. Мы должны будем потратить достаточно много времени на поиск причины бага.
Я работал над одним проектом, в котором основными тестами были E2E, и полный прогон занимал несколько часов, поэтому их запускали только по ночам. Т. е. фидбэк по новой фиче мы получали только на следующее утро, и в случае дефекта начался долгий поиск причин. При таком подходе очень много времени расходовалось на поиски. В ожидании прохождения тестов параллельно могла начаться работа над другой задачей. Тогда приходилось отложить текущую задачу и вернуться к предыдущей. Это всеотрицательно сказывалось на продуктивности. Как разработчик я хочу максимально быстро узнавать о наличии дефекта. В идеале — на своей локальной машине во время имплементации. Этот подход не дает такой возможности. Поэтому я не рекомендую его никому.
Следующая стратегия — сота тестирования (testing honeycomb). Здесь основной упор делается на integration-тесты. Она идеально подходит для микросервисов, в частности, ее используют в Spotify.
Нагрузочное тестирование: особенности профессии
Kогда сервис небольшой (как говорят, размер микросервиса должен быть таким, чтобы agile-команда смогла его полностью переписать за 1 спринт) и в нем мало бизнес-логики, этот подход дает большие плюсы:
- Уверенность, что код делает то, что должен. При этом виде тестов вы проверяете только реалистичный input сервиса и ожидаемый output. Вам нет никакого дела, как все реализовано внутри.
- Мы можем рефакторить код внутри без изменения тестов. Например, мы можем полность изменить БД и/или переписать бизнес-логику, это никак не повлияет на тесты.
Но есть и минусы:
- Некоторые потери в скорости выполнения тестов. Как мы уже знаем, каждый Integration-тест выполняется несколько секунд, что приводит к потерям времени. Но в случае микросервисов потери незначительны, потому что объем тестов небольшой.
- Мы можем потерять некоторый фидбэк, если тесты упали. Но хоть мы точно и не знаем место, где произошел сбой, можем быстро его найти и устранить.
Время идет, и наш сервис развивается, в нем появляется больше кода, и становится сложнее бизнес-логика. При использовании стратегии призмы тестирования у нас могут возникнуть большие проблемы. Какие? Давайте разберем.
Все мы сталкивались с ситуацией, когда все тесты зеленые, но выявляется баг. Что мы можем сделать после этого? Найти причину, закрыть ее Integration-тестом, который воспроизводит проблему, пофиксить и выпустить патч. И в этом кроется основной недостаток Integration и любого другого вида сквозного тестирования. Мы упускаем из виду проблемы в архитектуре приложения. Как бы удивительно ни звучало, Unit-тесты нужны не только для проверки бизнес-логики и поиска багов, но и для выявления проблем в дизайне. Всем известно, что если вы не можете покрыть какую-то часть кода Unit-тестами, у вас проблемы в архитектуре. Unit-тесты позволяют заметить их на ранних стадиях. Например, если для написания одного Unit-теста вам необходимо замокать кучу зависимостей, есть проблема, необходимо провести рефакторинг.
Таким образом, Unit-тесты помогают не только находить логические ошибки, но и выявлять проблемы в дизайне. А что происходит, если вы их не пишете? Архитектура ухудшается и становится более запутанной. Повышается вероятность сделать ошибку при каких-либо изменениях. Это как гидра: пофиксили что-то здесь — сломалось что-то там. И круг замкнулся, у вас опять все тесты зеленые, но появляется ошибка, вы покрываете ее Integration-тестом, фиксите следствие, а не причину. И так опять, и опять, и опять. В итоге ваш проект состоит только из костылей, а вы — несчастны.
Другая проблема — время. Чем больше у вас Integration тестов, тем больше времени занимает их полный прогон. Для микросервисов со сложной бизнес-логикой и взаимодействием прогон всех тестов может занять ни один десяток минут. Это снова негативно сказыается на производительности и качестве кода. Вы будете очень редко запускать все тесты, а, скорее всего, их полный прогон будет только на CI после открытия PR.
Каков же рецепт? Универсального решения не существует. Найти стратегию на все случаи жизни невозможно, каждая имеет сильные и слабые стороны. И только одним видом тестирования не обойтись, необходимо брать лучшее от всех подходов и грамотно их сочетать.
Основная рекомендация — используйте Integration-тесты для проверки сервиса на соответствие бизнес-требованиям. Воспроизведите основные бизнес-кейсы для сервиса в Integration-тестах, но не пытайтесь проверять бизнес-логику через них. Для этого есть Unit-тесты. Тогда при рефакторинге и/или переезде на другую DB вам ничего не нужно будет делать с самими тестами. Вы всегда будете уверены, что работа сервиса соответствует требованиям.
Пока ваш сервис живет и функционирует, вы можете менять стратегию, если видите, что предыдущий подход больше не дает бенефитов. Допустим, у вас маленький сервис с небольшим количеством CRUD-операций, используйте подход «соты тестрования». С развитием сервиса, если заметите, что тесты начинают занимать все больше и больше времени, а внесение изменений становится все сложнее, переходите к стратегии пирамиды тестирования.При выявлении баги все-таки закрывайте ее Unit-тестами, а не интеграционниками.
Спасибо за внимание. Безбажных вам сервисов.
Как наконец-то начать писать тесты и не пожалеть об этом
Приходя на новый проект, я регулярно сталкиваюсь с одной из следующих ситуаций:
- Тестов нет совсем.
- Тестов мало, их редко пишут и не запускают на постоянной основе.
- Тесты присутствуют и включены в CI (Continuous Integration), но приносят больше вреда, чем пользы.
Что можно сделать, чтобы изменить сложившуюся ситуацию? Идея использования тестов не нова. При этом большинство туториалов напоминают знаменитую картинку про то, как нарисовать сову: подключаем JUnit, пишем первый тест, используем первый мок — и вперед! Такие статьи не отвечают на вопросы о том, какие тесты нужно писать, на что стоит обращать внимание и как со всем этим жить. Отсюда и родилась идея данной статьи. Я постарался кратко обобщить свой опыт внедрения тестов в разных проектах, чтобы облегчить этот путь для всех желающих.
Совсем вводных статей по данной теме более чем достаточно, поэтому не будем повторяться и попытаемся зайти с другой стороны. В первой части развенчаем миф о том, что тестирование несет исключительно дополнительные затраты. Будет показано, как создание качественных тестов может в свою очередь ускорить процесс разработки. Затем на примере небольшого проекта будут рассмотрены базовые принципы и правила, которых стоит придерживаться, чтобы эту выгоду реализовать. Наконец, в заключительном разделе будут даны конкретные рекомендации по внедрению: как избежать типичных проблем, когда тесты начинают, наоборот, существенно тормозить разработку.
Так как моя основная специализация — Java backend, то в примерах будет использован следующий стек технологий: Java, JUnit, H2, Mockito, Spring, Hibernate. При этом значительная часть статьи посвящена общим вопросам тестирования и советы в ней применимы к гораздо более широкому кругу задач.
Однако будьте осторожны! Тесты вызывают сильнейшую зависимость: однажды научившись ими пользоваться, вы уже не сможете без них жить.
Содержание
Тесты vs скорость разработки
Главные вопросы, которые возникают при обсуждении внедрения тестирования: сколько времени займет написание тестов и какие преимущества это будет иметь? Тестирование, как и любая другая технология, потребует серьезных усилий на освоение и внедрение, поэтому на первых порах никакой значимой выгоды ожидать не стоит. Что касается временных затрат, то они сильно зависят от конкретной команды. Однако меньше чем на 20–30 % дополнительных затрат на кодирование рассчитывать точно не стоит. Меньшего просто не хватит для достижения хоть какого-то результата. Ожидание мгновенной отдачи часто является главной причиной сворачивания этой деятельности еще до того, как тесты станут приносить пользу.
Но о какой же тогда эффективности идет речь? Давайте отбросим лирику о трудностях внедрения и посмотрим, какие конкретные возможности по экономии времени открывает тестирование.
Запуск кода в произвольном месте
При отсутствии тестов в проекте единственным способом запуска является поднятие приложения целиком. Хорошо, если на это будет уходить секунд 15–20, но далеко не редки случаи больших проектов, в которых полноценный запуск может занимать от нескольких минут. Что же это означает для разработчиков? Существенную часть их рабочего времени будут составлять эти короткие сессии ожидания, на протяжении которых нельзя продолжать работать над текущей задачей, но при этом времени на переключение на что-то другое слишком мало. Многие хотя бы раз сталкивались с такими проектами, где написанный за час код требует многочасовой отладки из-за долгих перезапусков между исправлениями. В тестах же можно ограничиться запуском маленьких частей приложения, что позволит значительно сократить время ожидания и повысит продуктивность работы над кодом.
Кроме того, возможность запуска кода в произвольном месте ведет к более тщательной отладке. Зачастую проверка даже основных позитивных сценариев использования через интерфейс приложения требует серьезных усилий и времени. Наличие же тестов позволяет проводить детальную проверку конкретного функционала гораздо проще и быстрее.
Еще один плюс — возможность регулирования размера тестируемого юнита. В зависимости от сложности проверяемой логики, можно ограничиться одним методом, классом, группой классов, реализующих некоторую функциональность, сервисом и так далее, вплоть до автоматизации тестирования приложения целиком. Такая гибкость позволяет разгрузить высокоуровневые тесты от многих деталей за счет того, что они будут проверены на более низких уровнях.
Повторный запуск тестов
Этот плюс часто приводят как суть автоматизации тестирования, однако давайте рассмотрим его под менее привычным углом зрения. Какие новые возможности для разработчиков он открывает?
Во-первых, каждый новый пришедший на проект разработчик сможет легко запустить имеющиеся тесты, чтобы разобраться в логике приложения на примерах. К сожалению, важность этого сильно недооценена. В современных условиях одни и те же люди редко работают над проектом дольше 1–2 лет. А так как команды состоят из нескольких человек, то появление нового участника каждые 2–3 месяца — типичная ситуация для относительно крупных проектов. Особо тяжелые проекты переживают смены целых поколений разработчиков! Возможность легко запустить любую часть приложения и посмотреть на поведение системы в разы упрощает погружение новых программистов в проект. Кроме того, более детальное изучение логики кода уменьшает количество допущенных ошибок на выходе и время на их отладку в будущем.
Во-вторых, возможность легко убедиться в том, что приложение работает корректно, открывает дорогу для непрерывного рефакторинга (Continuous Refactoring). Этот термин, к сожалению, гораздо менее популярен, чем CI. Он означает, что рефакторинг можно и нужно делать при каждой доработке кода. Именно регулярное следование небезызвестному правилу бойскаута «оставь место стоянки чище, чем оно было до твоего прихода», позволяет избегать деградации кодовой базы и гарантирует проекту долгую и счастливую жизнь.
Отладка
Отладка уже была упомянута в предыдущих пунктах, но этот момент настолько важен, что заслуживает более внимательного рассмотрения. К сожалению, не существует достоверного способа измерить соотношение между временем, потраченным на написание кода и на его отладку, так как эти процессы практически неотделимы друг от друга. Тем не менее наличие качественных тестов в проекте существенно сокращает время отладки, вплоть до почти полного отсутствия необходимости запускать дебаггер.
Эффективность
Все перечисленное может дать существенную экономию времени на первичную отладку кода. При правильном подходе только это уже окупит все дополнительные затраты на разработку. Остальные бонусы тестирования — повышение качества кодовой базы (плохо спроектированный код тяжело тестировать), уменьшение количества дефектов, возможность убедиться в корректности кода в любой момент и т. д. — достанутся практически бесплатно.
От теории к практике
На словах это все выглядит неплохо, но давайте перейдем к делу. Как уже было сказано ранее, материалов о том, как произвести первичную настройку тестовой среды, более чем достаточно. Потому сразу перейдем к готовому проекту. Исходники тут.
Задача
В качестве шаблонной задачки рассмотрим небольшой фрагмент бэкенда интернет-магазина. Напишем типовой API для работы с продуктами: создание, получение, редактирование. А также пару методов для работы с клиентами: смена «любимого продукта» и расчет бонусных баллов по заказу.
Доменная модель
Чтобы не перегружать пример, ограничимся минимальным набором полей и классов.
У клиента (Customer) есть логин, ссылка на любимый продукт и флаг, указывающий на то, является ли он премиальным клиентом.
У продукта (Product) — название, цена, скидка и флаг, указывающий на то, рекламируется ли он в данный момент.
Структура проекта
Структура основного кода проекта выглядит следующим образом.
Классы разбиты по слоям:
- Model — доменная модель проекта;
- Jpa — репозитории для работы с БД на основе Spring Data;
- Service — бизнес-логика приложения;
- Controller — контроллеры, реализующие API.
Классы тестов лежат в тех же пакетах, что и оригинальный код. Дополнительно создан пакет с билдерами для подготовки тестовых данных, но об этом ниже.
Удобно разделять юнит-тесты и интеграционные тесты. Они зачастую имеют разные зависимости, и для комфортной разработки должна быть возможность запустить либо одни, либо другие. Этого можно добиться разными способами: конвенции именования, модули, пакеты, sourceSets. Выбор конкретного способа — исключительно вопрос вкуса. В данном проекте интеграционные тесты лежат в отдельном sourceSet — integrationTest.
Подобно юнит-тестам, классы с интеграционными тестами лежат в тех же пакетах, что и оригинальный код. Дополнительно есть базовые классы, которые помогают избавиться от дублирования конфигурации и при необходимости содержат полезные универсальные методы.
Интеграционные тесты
Есть разные подходы к тому, с каких тестов стоит начинать. В случае, если проверяемая логика не очень сложна, можно сразу переходить к интеграционным (их еще иногда называют приемочными — acceptance). В отличие от юнит-тестов они позволяют убедиться, что приложение в целом работает корректно.
Архитектура
Для начала надо определиться, на каком конкретно уровне будут выполняться интеграционные проверки. Spring Boot предоставляет полную свободу выбора: можно поднимать часть контекста, весь контекст и даже полноценный сервер, доступный из тестов. При увеличении размера приложения этот вопрос становится все более сложным. Часто приходится писать разные тесты на разных уровнях.
Хорошей точкой старта будут тесты контроллеров без запуска сервера. В относительно небольших приложениях вполне приемлемо поднимать весь контекст целиком, так как по умолчанию он переиспользуется между тестами и инициализируется только один раз. Рассмотрим основные методы класса ProductController :
@PostMapping("new") public Product createProduct(@RequestBody Product product) < return productService.createProduct(product); >@GetMapping("") public Product getProduct(@PathVariable("productId") long productId) < return productService.getProduct(productId); >@PostMapping("/edit") public void updateProduct(@PathVariable("productId") long productId, @RequestBody Product product)
Вопрос обработки ошибок оставим в стороне. Предположим, что она реализована снаружи на основе анализа выбрасываемых исключений. Код методов очень простой, их реализация в сервисе ProductService не сильно сложнее:
@Transactional(readOnly = true) public Product getProduct(Long productId) < return productRepository.findById(productId) .orElseThrow(() ->new DataNotFoundException("Product", productId)); > @Transactional public Product createProduct(Product product) < return productRepository.save(new Product(product)); >@Transactional public Product updateProduct(Long productId, Product product) < Product dbProduct = productRepository.findById(productId) .orElseThrow(() ->new DataNotFoundException("Product", productId)); dbProduct.setPrice(product.getPrice()); dbProduct.setDiscount(product.getDiscount()); dbProduct.setName(product.getName()); dbProduct.setIsAdvertised(product.isAdvertised()); return productRepository.save(dbProduct); >
Репозиторий ProductRepository вообще не содержит собственных методов:
public interface ProductRepository extends JpaRepository
Все намекает на то, что юнит-тесты этим классам не нужны просто потому, что всю цепочку можно легко и эффективно проверить несколькими интеграционными тестами. Дублирование одних и тех же проверок в разных тестах приводит к усложнению отладки. В случае появления ошибки в коде теперь упадет не один тест, а сразу 10–15. Это в свою очередь потребует дальнейшего анализа. Если же дублирования нет, то единственный упавший тест, скорее всего, сразу укажет на ошибку.
Конфигурация
Для удобства выделим базовый класс BaseControllerIT , который содержит конфигурацию Spring и пару полей:
@RunWith(SpringRunner.class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @Transactional public abstract class BaseControllerIT
Репозитории вынесены в базовый класс, чтобы не захламлять классы тестов. Их роль исключительно вспомогательная: подготовка данных и проверка состояния базы после работы контроллера. При увеличении размера приложения это может перестать быть удобным, но для начала вполне подойдет.
Основная конфигурация Spring задается следующими строчками:
@SpringBootTest — используется для того, чтобы задать контекст приложения. WebEnvironment.NONE означает, что веб-контекст поднимать не надо.
@Transactional — оборачивает все тесты класса в транзакцию с автоматическим откатом для сохранения состояния базы.
Структура теста
Перейдем к минималистичному набору тестов для класса ProductController — ProductControllerIT .
@Test public void createProduct_productSaved()
Код теста должен быть предельно прост и понятен с первого взгляда. Если это не так, то большая часть плюсов тестов, описанных в первом разделе статьи, теряется. Хорошей практикой является разделение тела теста на три визуально отделяемые друг от друга части: подготовка данных, вызов тестируемого метода, валидация результатов. При этом очень желательно, чтобы код теста помещался на экране целиком.
Лично мне кажется более наглядным, когда тестовые значения из секции подготовки данных используются потом и в проверках. Альтернативно можно было бы явно сравнивать объекты, например так:
assertEquals(product, dbProduct);
В другом тесте на обновление информации о продукте ( updateProduct ) видно, что создание данных стало немного сложнее и для сохранения визуальной целостности трех частей теста они отделены двумя переводами строк подряд:
@Test public void updateProduct_productUpdated()
Каждую из трех частей теста можно упростить. Для подготовки данных отлично подходят тестовые билдеры, которые содержат в себе логику создания объектов, удобную для использования из тестов. Слишком сложные вызовы методов можно выносить во вспомогательные методы внутри тестовых классов, скрывая часть нерелевантных для данного класса параметров. Для упрощения сложных проверок можно также писать вспомогательные функции либо реализовывать собственные матчеры. Главное при всех этих упрощениях — не потерять наглядности теста: все должно быть понятно с первого взгляда на основной метод, без необходимости перехода вглубь.
Тестовые билдеры
Тестовые билдеры заслуживают отдельного внимания. Инкапсуляция логики создания объектов упрощает сопровождение тестов. В частности, заполнение не релевантных данному тесту полей модели можно скрыть внутри билдера. Для этого нужно не создавать его напрямую, а использовать статический метод, который заполнит недостающие поля значениями по умолчанию. Например, в случае появления новых обязательных полей в модели их можно будет легко добавить в этот метод. В ProductBuilder он выглядит так:
public static ProductBuilder product(String name)
Название теста
Крайне важно понимать, что конкретно проверяется в данном тесте. Для наглядности лучше всего дать ответ на этот вопрос в его названии. На примере тестов для метода getProduct рассмотрим используемую конвенцию именования:
@Test public void getProduct_oneProductInDb_productReturned() < Product product = product("productName").build(); productRepository.save(product); Product result = productController.getProduct(product.getId()); assertEquals("productName", result.getName()); >@Test public void getProduct_twoProductsInDb_correctProductReturned()
В общем случае заголовок тестового метода состоит из трех частей, разделенных подчеркиванием: имя тестируемого метода, сценарий, ожидаемый результат. Однако здравый смысл никто не отменял, и вполне оправданным может быть опускание каких-то частей названия, если они не нужны в данном контексте (например, сценарий в единственном тесте на создание продукта). Цель такого именования — добиться того, чтобы суть каждого теста была понятна без изучения кода. Это делает окошко результатов прохождения тестов максимально наглядным, а именно с него обычно и начинается работа с тестами.
Вот и все. На первое время минималистичного набора из четырех тестов вполне достаточно для проверки методов класса ProductController . В случае выявления багов всегда можно будет добавить недостающие тесты. При этом минимальное количество тестов значительно сокращает время и силы на их поддержку. В свою очередь это является критичным в процессе внедрения тестирования, так как первые тесты обычно получаются не самого лучшего качества и создают много неожиданных проблем. В то же время такого тестового набора вполне достаточно для получения бонусов, описанных в первой части статьи.
Стоит обратить внимание, что такие тесты не проверяют веб-слой приложения, однако зачастую этого и не требуется. При необходимости можно написать отдельные тесты для веб-слоя с заглушкой вместо базы ( @WebMvcTest , MockMvc , @MockBean ) или использовать полноценный сервер. Последнее может затруднить отладку и усложнить работу с транзакциями, поскольку транзакцию сервера тест уже контролировать не сможет. Пример такого интеграционного теста можно посмотреть в классе CustomerControllerServerIT .
Юнит-тесты
Юнит-тесты имеют ряд преимуществ перед интеграционными:
- Запуск занимает миллисекунды;
- Небольшой размер тестируемого юнита;
- Легко реализовать проверку большого количества вариантов, так как при вызове метода напрямую подготовка данных значительно упрощается.
Единственный класс в данном примере, который заслуживает юнит-тестирования, — это BonusPointCalculator . Его отличительная особенность — большое количество ветвлений бизнес-логики. Например, предполагается, что покупатель получает бонусами 10 % от стоимости продукта, помноженные на не более чем 2 мультипликатора из следующего списка:
- Продукт стоит больше 10 000 (× 4);
- Продукт участвует в рекламной кампании (× 3);
- Продукт является «любимым» продуктом клиента (× 5);
- Клиент имеет премиальный статус (× 2);
- В случае, если клиент имеет премиальный статус и покупает «любимый» продукт, вместо двух обозначенных мультипликаторов используется один (× 8).
private List calculateMultipliers(Customer customer, Product product) < Listmultipliers = new ArrayList<>(); if (customer.getFavProduct() != null && customer.getFavProduct().equals(product)) < if (customer.isPremium()) < multipliers.add(PREMIUM_FAVORITE_MULTIPLIER); >else < multipliers.add(FAVORITE_MULTIPLIER); >> else if (customer.isPremium()) < multipliers.add(PREMIUM_MULTIPLIER); >if (product.isAdvertised()) < multipliers.add(ADVERTISED_MULTIPLIER); >if (product.getPrice().compareTo(EXPENSIVE_THRESHOLD) >= 0) < multipliers.add(EXPENSIVE_MULTIPLIER); >return multipliers; >
Большое количество вариантов приводит к тому, что двумя-тремя интеграционными тестами здесь уже не ограничишься. Минималистичный набор юнит-тестов отлично подойдет для отладки такого функционала.
Соответствующий набор тестов можно посмотреть в классе BonusPointCalculatorTest . Вот некоторые из них:
@Test public void calculate_oneProduct() < Product product = product("product").price("1.00").build(); Customer customer = customer("customer").build(); Mapquantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").build(); assertEquals(expectedBonus, bonus); > @Test public void calculate_favProduct() < Product product = product("product").price("1.00").build(); Customer customer = customer("customer").favProduct(product).build(); Mapquantities = mapOf(product, 1L); BigDecimal bonus = bonusPointCalculator.calculate(customer, list(product), quantities::get); BigDecimal expectedBonus = bonusPoints("0.10").addMultiplier(FAVORITE_MULTIPLIER).build(); assertEquals(expectedBonus, bonus); >
Стоит обратить внимание, что в тестах идет обращение именно к публичному API класса — методу calculate . Тестирование контракта класса, а не его реализации позволяет избегать поломок тестов из-за нефункциональных изменений и рефакторинга.
Наконец, когда мы проверили внутреннюю логику юнит-тестами, в интеграционный все эти детали выносить уже не нужно. В данном случае достаточно одного более-менее репрезентативного теста, например такого:
@Test public void calculateBonusPoints_twoProductTypes_correctValueCalculated() < Product product1 = product("product1").price("1.01").build(); Product product2 = product("product2").price("10.00").build(); productRepository.save(product1); productRepository.save(product2); Customer customer = customer("customer").build(); customerRepository.save(customer); Mapquantities = mapOf(product1.getId(), 1L, product2.getId(), 2L); BigDecimal bonus = customerController.calculateBonusPoints( new CalculateBonusPointsRequest("customer", quantities) ); BigDecimal bonusPointsProduct1 = bonusPoints("0.10").build(); BigDecimal bonusPointsProduct2 = bonusPoints("1.00").quantity(2).build(); BigDecimal expectedBonus = bonusPointsProduct1.add(bonusPointsProduct2); assertEquals(expectedBonus, bonus); >
Как и в случае с интеграционными тестами, использованный набор юнит-тестов очень небольшой и не гарантирует полной корректности приложения. Тем не менее его наличие значительно повышает уверенность в коде, облегчает отладку и дает прочие бонусы, перечисленные в первой части статьи.
Рекомендации по внедрению
Надеюсь, предыдущих разделов было достаточно, чтобы убедить хотя бы одного разработчика попробовать начать использовать тесты в своем проекте. В этой главе будут кратко перечислены основные рекомендации, которые помогут избежать серьезных проблем и приведут к снижению первичных издержек на внедрение.
Постарайтесь начать внедрение тестов на новом приложении. Написать первые тесты в большом legacy-проекте будет намного сложнее и потребует большей квалификации, чем в свежесозданном. Поэтому по возможности лучше начинать с небольшого нового приложения. Если же новых полноценных приложений не ожидается, можно попробовать разработать какую-нибудь полезную утилиту для внутреннего использования. Главное, чтобы задача была более-менее реалистичной — выдуманные примеры не дадут полноценного опыта.
Настройте регулярный запуск тестов. Если тесты не запускаются на регулярной основе, то они не только перестают выполнять свою основную функцию — проверку корректности кода, — но и быстро устаревают. Потому крайне важно настроить хотя бы минимальный CI-конвейер с автоматическим запуском тестов при каждом обновлении кода в репозитории.
Не гонитесь за покрытием. Как и в случае любой другой технологии, первое время тесты будут получаться не самого хорошего качества. Здесь может помочь соответствующая литература (ссылки в конце статьи) или грамотный ментор, но необходимости самостоятельного набивания шишек это не отменяет. Тесты в этом плане похожи на остальной код: понять, как они повлияют на проект, получится только пожив с ними некоторое время. Поэтому для минимизации ущерба первое время лучше не гнаться за количеством и красивыми цифрами вроде стопроцентного покрытия. Вместо этого стоит ограничиться основными позитивными сценариями по собственному функционалу приложения.
Не увлекайтесь юнит-тестами. В продолжение темы «количество vs качество» нужно отметить, что честными юнит-тестами первое время увлекаться не стоит, потому что это легко может привести к чрезмерной спецификации приложения. В свою очередь это станет серьезным тормозящим фактором при последующем рефакторинге и доработках приложения. Юнит-тесты следует использовать только при наличии сложной логики в конкретном классе или группе классов, которую неудобно проверять на уровне интеграционных.
Не увлекайтесь заглушками классов и методов приложения. Заглушки (stub, mock) — еще один инструмент, который требует взвешенного подхода и соблюдения баланса. С одной стороны, полная изоляция юнита позволяет сосредоточиться на тестируемой логике и не думать об остальных частях системы. С другой стороны, это потребует дополнительного времени на разработку и, как и при использовании юнит-тестов, может привести к чрезмерной спецификации поведения.
Отвяжите интеграционные тесты от внешних систем. Очень частая ошибка в интеграционных тестах — использование реальной базы данных, очередей сообщений и прочих внешних по отношению к приложению систем. Безусловно, возможность запустить тест в реальном окружении полезна для отладки и разработки. Такие тесты в небольших количествах могут иметь смысл, особенно для запуска в интерактивном режиме. Однако повсеместное их использование приводит к целому ряду проблем:
- Для запусков тестов нужно будет настраивать внешнее окружение. Например, устанавливать базу данных на каждую машину, где будет собираться приложение. Это усложнит вход новых разработчиков в проект и настройку CI.
- Состояние внешних систем может отличаться на разных машинах перед запуском тестов. Например, в базе могут уже находиться нужные приложению таблицы с данными, которые не ожидаются в тесте. Это приведет к непредсказуемым сбоям в работе тестов, и их устранение потребует значительного количества времени.
- В случае, если ведется параллельная работа над несколькими проектами, возможно неочевидное влияние одних проектов на другие. Например, специфические настройки базы, выполненные для одного из проектов, смогут помочь корректно работать функционалу другого проекта, который, однако, сломается при запуске на чистой базе на другой машине.
- Тесты выполняются долго: полный прогон может достигать десятков минут. Это приводит к тому, что разработчики перестают запускать тесты локально и смотрят на их результаты только после отправки изменений в удаленный репозиторий. Такое поведение сводит на нет большинство плюсов тестов, о которых говорилось в первой части статьи.
Следите за тем, чтобы тесты выполнялись за разумное время. Даже если тесты не зависят от реальных внешних систем, время их выполнения может легко выйти из-под контроля. Чтобы такого не происходило, нужно постоянно следить за этим показателем и принимать меры в случае необходимости. Самое меньшее, что можно сделать, — выделить медленные тесты в отдельную группу, чтобы они не мешали работе над не связанными с ними задачами.
Старайтесь делать тесты максимально понятными и читаемыми. Как уже было показано в примере, тесты надо писать так, чтобы в них не нужно было разбираться. Время, потраченное на изучение теста, могло бы быть потрачено на изучение кода.
Не зацикливайтесь на TDD (Test-Driven Development). TDD является довольно популярной практикой, однако я не считаю ее обязательной, особенно на первых этапах внедрения. В целом, умение писать хорошие тесты не связано с тем, в какой момент они написаны. Что действительно важно, так это делать первичную отладку кода уже на тестах, поскольку это один из основных способов экономии времени.
Первые тесты написаны, что дальше?
Далее надо внимательно наблюдать за жизнью тестов в проекте и периодически задавать себе вопросы, подобные следующим:
- Какие тесты мешают рефакторингу и доработкам (требуют постоянных исправлений)? Такие тесты требуется переписать либо полностью удалить из проекта и заменить более высокоуровневыми.
- Какие тесты часто и непредсказуемо ломаются при многократном либо параллельном запуске, при запуске в разных средах (компьютер коллеги, сервер CI)? Они также требуют переработки.
- Какие ошибки проходят мимо тестов? На каждый такой баг желательно добавлять новый тест и в будущем иметь их в виду при написании тестов для аналогичного функционала.
- Какие тесты работают слишком долго? Нужно постараться их переписать. Если это невозможно, то отделить их от более быстрых, чтобы сохранить возможность оперативного локального прогона.
Заключение
Поначалу лучше не гнаться за количеством тестов, а сосредоточиться на их качестве. Огромное число неуместных юнит-тестов может легко стать якорем, тянущим проект на дно. Кроме того, наличие юнит-тестов не освобождает от необходимости написания интеграционных. Поэтому наиболее эффективная стратегия на первое время — начинать с покрытия основных позитивных сценариев интеграционными тестами и, в случае если этого оказывается недостаточно, добавлять локальные проверки юнит-тестами. Со временем будет накапливаться обратная связь, которая поможет исправить допущенные ошибки и получить более четкое представление об эффективном использовании разных методик автоматического тестирования.
Надеюсь, среди прочитавших найдутся те, чьи тонкие струны души окажутся задеты моим графоманством, и в мире появится еще несколько проектов с хорошими и эффективными тестами!
Болеутоляющее или статья о том, что можно писать тестируемый JavaScript
У всех наступал момент, когда ваше JavaScript-приложение, начавшееся с нескольких полезных строчек разрасталось на тысячу строк, затем на две, дальше — больше. Постепенно функция начинает принимать чуть больше параметров; ветки условий получают ещё немного условий. И в один прекрасный день появляется баг: что-то сломано. И нам предстоит распутать весь этот бардак в коде.
Сейчас код на фронтенде берёт на себя всё больше и больше ответственности — на самом деле, уже существует целый пласт приложений, существующих полностью на клиентской стороне — в связи со всем этим становятся очевидными две идеи. Первая — мы просто не можем прокликать все возможные варианты с помощью текущего метода тестирования. Вторая — возможно, нам потребуется изменить подход к тому, как мы привыкли писать код, в угоду возможности писать тесты.
Правда ли, что нам необходимо поменять то, как мы пишем код? Абсолютное да — так как мы осознаём пользу автоматического тестирования, но большинство из нас, возможно, сумеют прямо сейчас написать только интеграционные тесты. Интеграционные тесты важны тем, что отслеживают, насколько хорошо работают между собой отдельные части приложения, но в то же время они не несут никакой информации о том, работают ли отдельные части так, как от них ожидают.
В этот момент на сцену действия выходят модульные тесты (прим. переводчика: также известны как юнит-тесты и функциональные тесты). Нам придётся серьёзно потрудиться над написанием модульных тестов, пока мы не начнём писать тестируемый джаваскрипт.
Модульные тесты и интеграционные: в чём разница?
Обычно способ создания интеграционных тестов достаточно прямолинеен: мы просто пишем код, описывающий, как пользователь взаимодействует с нашим приложением, и то, что пользователь ожидает увидеть. Популярным средством автоматического тестирования в браузере является Selenium. Capybara для Ruby облегчает взаимодействие с Selenium, более того, существуют тысячи подобных инструментов на других языках.
Ниже представлен интеграционный тест для небольшой части поискового приложения:
def test_search fill_in('q', :with => 'cat') find('.btn').click assert( find('#results li').has_content?('cat'), 'Результаты поиска отображены' ) assert( page.has_no_selector?('#results li.no-results'), 'Результаты поиска отсутствуют' ) end
В то время как интеграционный тест заинтересован в проверке взаимодействия пользователя с приложением, внимание модульного теста лежит на небольших участках кода.
Если я вызову функцию с зафиксированными параметрами, то получу ли я ожидаемый результат?
Приложения, написанные в традиционном процедурном стиле, чаще всего трудно модульно тестировать — так же, как трудно поддерживать, отлаживать и расширять. Но если мы будем писать код, держа в уме необходимость модульного тестирования, то мы обнаружим не только то, что тестировать становится проще, но также то, что мы просто пишем аккуратный и более качественный код.
В качестве иллюстрации к тому, о чём я говорю, давайте взглянем на обычное поисковое приложение:
Когда пользователь начинает что-то искать, приложение отправляет XHR-запрос на сервер. Когда сервер отвечает данными в формате JSON, приложение принимает эти данные и отображает их на странице при помощи клиентской шаблонизации. Пользователь может кликнуть на элементе поисковой выдачи, чтобы показать, что ему понравился этот пункт; когда это происходит, имя человека, к которому пользователь проявил интерес, добавляется в список «Понравившиеся» в правой колонке приложения.
«Обычное» JavaScript приложение может выглядеть так:
Мой друг Адама Сонтега (Adam Sontag) называет это «Выбери себе приключение сам» — в каждой строчке мы с равной вероятностью можем иметь дело как с представлением, так и с информацией, обслуживанием логики пользовательского взаимодействия или проверкой состояния приложения. Остаётся только догадываться! Достаточно просто написать интеграционные тесты для этого кода, и в тоже время очень сложно написать тесты для тестирования отдельных функциональных частей приложения.
Почему это сложно? На это существуют четыре причины:
- общий недостаток структурированности; чаще всего всё происходит в коллбэке $(document).ready() — и, так как это анонимная функция, её невозможно протестировать ввиду невозможного к ней обращения;
- слишком сложные функции; Если в функции больше 10 строк кода, как в обработчике отправки формы, то эта функция, вероятно, выполняет и несёт отвественность за слишком много вещей;
- скрытые состояния; так как состояние pending помещено в замыкание, то нет никакой возможности проверить, правильно ли установлено это состояние;
- избыточная связанность функционала; к примеру, обработчику $.ajax совершенно нет необходимости иметь доступ к DOM;
Организация кода
Первое, что небходимо сделать — выбрать менее запутанный метод организации кода, разбить его на несколько зон ответственности:
- представление и пользовательское взаимодействие;
- управление данными;
- общее состояние приложения;
- настройка и код-прослойка, чтобы все части работали вместе;
В «традиционной» реализации, показанной выше, эти четыре категории перемешаны — на одной строчке мы работаем с представлением, двумя строчками ниже мы общаемся с сервером.
Несмотря на то, что мы можем без проблем писать интеграционные тесты для этого кода (и мы обязаны это делать!), писать модульные тесты действительно сложно. В наших функциональных тестах мы можем утверждать: «когда пользователь ищет что-то, он должен видеть соответствующие результаты», но мы не можем быть точнее. Если что-то пойдёт не так, нам следует определить, что именно пошло не так, и наши функциональные тесты не смогут помочь в этом.
Если мы переосмыслим то, как мы пишем код, то мы можем не только написать юнит- тесты, которые дадут нам лучшее представление о том, откуда все пошло не по плану, но и, в конечном итоге, писать более удобный код — поддерживаемый, расширяемый.
Каждая новая строчка кода будет следовать этому небольшому списку правил:
- каждый отдельный фрагмент кода должен быть отдельным объектом, попадающим в одну из четырёх зон ответственности, и не должен иметь ни малейшего представления о других объектах. Это поможет избегать запутанного кода;
- поддерживайте возможность настройки вместо того, чтобы задавать конкретные значения определённым параметрам. Это предотвратит необходимость повторения всего HTML-окружения, чтобы написать тесты;
- каждый метод любого объекта должен быть простым и коротким. Этот пункт позволит иметь простые и читаемые тесты;
- для создания объектов следует использовать конструкторы. Это поможет сделать возможным создание «чистых» копий объектов, необходимых для тестирования.
Для начала надо определиться, на какие части мы разобьём наше приложение. У нас есть три части, относящиеся к представлению и взаимодействию: поисковая форма, поисковые результаты и сектор для понравившегося.
Также существует кусок кода, относящийся к запросу данных с сервера, и часть, обеспечивающая возможность совместной работы остальных частей.
Начнём с наиболее простой части приложения — сектор для понравившегося. В оригинальном приложении следующий код отвечал за обновление этого сектора:
Код поисковой формы сильно переплетен с сектором понравившегося и также требует информации о том, как устроена разметка. Гораздо лучшим подходом (и для тестируемости тоже) будет создание объекта сектора понравившегося, ответственного за манипуляции с DOM:
В этом коде приведён конструктор, создающий новую копию объекта Likes Box. Созданная копия имеет метод .add() , используемый для добавления новых результатов. Мы можем написать немного тестов, чтобы проверить, работает ли этот метод:
var ul; setup(function( )< ul = $(' '); >); test('constructor', function ( ) < var l = new Likes(ul); assert(l); >); test('adding a name', function ( ) < var l = new Likes(ul); l.add('Дмитрий Менделеев'); assert.equal(ul.find('li').length, 1); assert.equal(ul.find('li').first().html(), 'Дмитрий Менделеев'); assert.equal(ul.find('li.no-results').length, 0); >);
Не так сложно, правда? Здесь используется Mocha в качестве тестирующего фреймворка и Chai как дополнительная библиотека. Mocha обеспечивает функции test и setup ; Chai — функцию assert . Существует бездна других фреймворков для тестирования, но я нахожу эти два достаточными для введения в предметную область. Вам же следует найти свой фреймворк по своим предпочтениям — Qunit популярен, а новый Intern подаёт большие надежды.
Приведённый код начинается с создания элемента, который будет использован как контейнер для сектора понравившегося. Затем запускается два теста: первая проверка на вменяемость — можем ли мы создать Like Box; вторая, чтобы удостовериться, что метод .add() имеет желаемый эффект. При наличии этих тестов, у нас появляется возможность безопасно рефакторить и быть уверенными, что мы сразу узнаем баге.
Код нашего приложения теперь выглядит так:
var liked = new Likes('#liked'); var resultsList = $('#results'); // … resultsList.on('click', '.like', function (e) < e.preventDefault(); var name = $(this).closest('li').find('h2').text(); liked.add(name); >);
Код, отвечающий за поисковые результаты, сложнее, чем Like Box, но давайте попробуем свои силы в рефакторинге. Точно так же, как мы создали метод .add() у Likes Box, мы хотим создать методы для общения с поисковыми результатами. Мы хотим добавлять новые результаты и удобные способы оповещения других частей приложения о событиях внутри поисковых результатов — например, когда кому-то понравился пункт поисковой выдачи.
var SearchResults = function (el) < this.el = $(el); this.el.on( 'click', '.btn.like', _.bind(this._handleClick, this) ); >; SearchResults.prototype.setResults = function (results) < var templateRequest = $.get('people-detailed.tmpl'); templateRequest.then( _.bind(this._populate, this, results) ); >; SearchResults.prototype._handleClick = function (evt) < var name = $(evt.target).closest('li.result').attr('data-name'); $(document).trigger('like', [ name ]); >; SearchResults.prototype._populate = function (results, tmpl) < var html = _.template(tmpl, < people: results >); this.el.html(html); >;
Теперь код нашего старого приложения, отвечающий за взаимодействие между поисковыми результатами и Likes Box, выглядит так:
var liked = new Likes('#liked'); var resultsList = new SearchResults('#results'); // … $(document).on('like', function (evt, name) < liked.add(name); >)
Такой код намного более простой и менее запутанный, потому что мы используем document как глобальный транспорт для сообщений, и, передавая данные через него, мы избавляем отдельные части приложения от необходимости знать друг о друге. (В реальной жизни мы использовали бы backbone или RSVP для управления событиями. В текущем демонстрационном приложении мы запускаем события в document для упрощения кода). Мы также спрячем всю рутинную работу — поиск имени понравившегося человека из поисковой выдачи — внутри объекта поисковых результатов, чтобы не загрязнять им код приложения. Наконец, хорошие новости — теперь мы можем писать тесты, чтобы доказать, что работа поисковых результатов соотствует нашим ожиданиям:
var ul; var data = [ /* ненастоящие данные */ ]; setup(function ( ) < ul = $(' '); >); test('constructor', function ( ) < var sr = new SearchResults(ul); assert(sr); >); test('display received results', function ( ) < var sr = new SearchResults(ul); sr.setResults(data); assert.equal(ul.find('.no-results').length, 0); assert.equal(ul.find('li.result').length, data.length); assert.equal( ul.find('li.result').first().attr('data-name'), data[0].name ); >); test('announce likes', function( ) < var sr = new SearchResults(ul); var flag; var spy = function ( ) < flag = [].slice.call(arguments); >; sr.setResults(data); $(document).on('like', spy); ul.find('li').first().find('.like.btn').click(); assert(flag, 'event handler called'); assert.equal(flag[1], data[0].name, 'обработчик события получил данные' ); >);
Взаимодействие с сервером — другая часть для обсуждения. Оригинальный код содержит в себе прямой вызов $.ajax() , и обработчик этого вызова работает напрямую с DOM:
$.ajax('/data/search.json', < data : < q: query >, dataType : 'json', success : function( data ) < loadTemplate('people-detailed.tmpl').then(function(t) < var tmpl = _.template( t ); resultsList.html( tmpl(< people : data.results >) ); pending = false; >); > >);
Повторюсь, что очень трудно писать модульные тесты для такого кода из-за того, что слишком много вещей происходит всего в нескольких строчках кода. Мы можем переделать объект с информацией в самостоятельный объект:
var SearchData = function ( ) < >; SearchData.prototype.fetch = function (query) < var dfd; if (!query) < dfd = $.Deferred(); dfd.resolve([]); return dfd.promise(); > return $.ajax( '/data/search.json', < data : < q: query >, dataType : 'json' >).pipe(function( resp ) < return resp.results; >); >;
Сейчас мы можем изменить код, чтобы получить результаты на странице:
var resultsList = new SearchResults('#results'); var searchData = new SearchData(); // … searchData.fetch(query).then(resultsList.setResults);
В который раз замечу, что мы невообразимо упростили код нашего приложениня, и спрятали всю сложность кода в объект Search Data вместо того, чтобы хранить его в общем коде. Также мы сделали наш поисковой интерфейс тестируемым, в тоже время надо помнить о некоторых оссобенностях при тестировании кода, взаимодействующего с сервером.
Первое — это то, что нам не нужно взаимодействовать с сервером по-настоящему — это проникновение в мир интеграционных тестов, а так как мы ответственные разработчики, то у нас уже есть тесты, сообщающие, что сервер работает правильно; всё верно? Вместо этого мы хотим создать заглушку для серверного взаимодействия, и это мы можем сделать с помощью библиотеки Sinion. Второй важный момент — нам также необходимо тестировать неидеальные случаи, например, пустой запрос.
test('constructor', function ( ) < var sd = new SearchData(); assert(sd); >); suite('fetch', function ( ) < var xhr, requests; setup(function ( ) < requests = []; xhr = sinon.useFakeXMLHttpRequest(); xhr.onCreate = function (req) < requests.push(req); >; >); teardown(function ( ) < xhr.restore(); >); test('fetches from correct URL', function ( ) < var sd = new SearchData(); sd.fetch('cat'); assert.equal(requests[0].url, '/data/search.json?q=cat'); >); test('вернуть promise', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); assert.isFunction(req.then); >); test('нет ответа, если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.equal(requests.length, 0); >); test('вернуть promise, даже если нет запроса', function ( ) < var sd = new SearchData(); var req = sd.fetch(); assert.isFunction( req.then ); >); test('no query promise resolves with empty array', function ( ) < var sd = new SearchData(); var req = sd.fetch(); var spy = sinon.spy(); req.then(spy); assert.deepEqual(spy.args[0][0], []); >); test('returns contents of results property of the response', function ( ) < var sd = new SearchData(); var req = sd.fetch('cat'); var spy = sinon.spy(); requests[0].respond( 200, < 'Content-type': 'text/json' >, JSON.stringify(< results: [ 1, 2, 3 ] >) ); req.then(spy); assert.deepEqual(spy.args[0][0], [ 1, 2, 3 ]); >); >);
Оставив это за пределами статьи, я провела рефакторинг объекта Search Form и упростила несколько участков кода и тестов, но если вам интересно, вы можете взглянуть на законченную версию приложения в моём репозитории на гитхабе.
По окончании переписывания приложения с использованием шаблонов тестируемого JavaScript, мы пришли к более ясному, чистому и поддерживаемому, в отличие от стартового, коду:
$(function( ) < var pending = false; var searchForm = new SearchForm('#searchForm'); var searchResults = new SearchResults('#results'); var likes = new Likes('#liked'); var searchData = new SearchData(); $(document).on('search', function (event, query) < if (pending) < return; > pending = true; searchData.fetch(query).then(function (results) < searchResults.setResults(results); pending = false; >); searchResults.pending(); >); $(document).on('like', function (evt, name) < likes.add(name); >); >);
Важнее того, что мы получили более аккуратный код, может быть только то, что он превосходно покрыт модульными тестами. Это означает, что мы можем смело рефакторить приложение без страха поломки чего-либо. Мы даже можем написать дополнительные тесты, если появится такая необходимость, и потом мы напишем код, который с успехом пройдёт все тесты.
Тестирование повышает качество жизни в долгосрочной перспективе
Несложно посмотреть на всё, что тут написано и спросить: «Подождите, вы хотите, чтобы я писал больше кода, который бы делал ту же самую работу?»
Причина в некоторых непререкаемых фактах относительно создания вещей в интернете. Вы потратите время, разрабатывая решение проблемы. Вы проверите своё решение, прокликав интерфейс в браузере и написав автоматические модульные тесты, или вероломно позволите пользователю тестировать ваше приложение в продакшене. И тем не менее, сколько бы вы не написали тестов, баги будут всегда.
Понимание тестирования заключается в том, что оно, возможно, и занимает чуть больше времени на старте, но в итоге оно экономит вам время в будущем. Вы будете прыгать от радости, когда тест, написанный вами, впервые обнаружит баг до того, как он попадёт в продакшн. Также вы будете счастливы, когда система тестов сможет подтвердить, что ваши правки действительно фиксят баг, для которого они предназначались.
Дополнительные источники
Эта статья только поверхностно описывает JavaScript-тестирование, но если вы заинтересовались и хотите изучить тему глубже, то обязательно проверьте следующие ссылки:
- Моя презентация с конференции Full Frontall (Брайтон, Великобритания, 2012);
- Grunt — инструмент, который поможет автоматизировать процесс тестирования;
- Книга Test-Driven JavaScript Development Кристиана Джохансона, создателя библиотеки Sinion. Это краткая, но очень информативная проверка по основным постулатам тестируемого JavaScript.