Сегодня автоматизированное тестирование стало широко распространенной практикой в ​​разработке программного обеспечения, и в любом проекте мы стараемся достичь высокого значения показателей покрытия кода.

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

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

🤖 Причина написания автоматических тестов

Зачем вообще писать автоматизированные тесты? Иногда кажется, что написание тестов только замедляет работу, добавляет ответственности и ничего не дает взамен. Все можно проверить руками, верно?

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

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

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

🙈 Мифы о метриках тестового покрытия

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

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

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

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

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

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

Это сделает тесты более структурированными и снизит риск написания тестов, которые производят «фантомное» покрытие, а не тестируют все.

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

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

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

🗝 Определение ключевых критериев

По моему опыту, наиболее эффективный подход к написанию тестов — начать с определения того, что я называю «ключевыми критериями». Это простое объяснение того, что делает конкретный компонент, в виде маркированного списка. Все основные функции должны быть включены в этот список, включая все крайние случаи. И это должно быть понятно из деталей реализации. Нравится:

* display list of posts
* provide a link to a post page
* if no posts display a message

Этот подход является масштабируемым и может отлично работать для модульного тестирования одной небольшой функции, или для тестирования службы, или всего раздела веб-сайта. Чем больше абстракция, тем более высокоуровневый язык следует использовать. Цель состоит в том, чтобы сохранить его кратким.

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

Действительно хорошее тестовое покрытие — это высокие (90+%) метрики покрытия + гарантия того, что тесты будут давать сбои при каждом изменении, затрагивающем ключевые критерии.

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

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

✍️ Написание тестов

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

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

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

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

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

После исправления тестов и проверки их надежности мы можем исправить код и удалить ошибку.

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

📦 Определение границ

Еще одна важная часть — определить, что модуль делает, а что нет. Даже если модуль делает что-то одно, в какой-то части он может полагаться на другие модули или внешние системы.

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

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

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

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

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

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

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

💥 Определение пограничных случаев

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

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

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

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

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

Но для критического кода это может быть слишком рискованно, и может потребоваться комплексное покрытие. Одним из возможных подходов может быть определение сложных критериев и желаемого вывода в удобочитаемой форме: в CSV или JSON, а тест будет считывать ввод, выполнять код и сравнивать его с выводом. Другие способы включают генерацию тестового кода, выборочные проверки или другой творческий подход. Генерация тестового кода

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

🔌 Добавьте интеграцию и сквозные тесты

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

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

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

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

🚲 Баланс между тестами и скоростью

Написание тестов происходит очень медленно. В целом в тестах строк кода в 1,5–2 раза больше, чем в реализации. И, как и везде в разработке программного обеспечения, все дело в компромиссах.

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

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

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

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

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

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

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