Мы все были там. Я имею в виду разработчиков. Вы закончили свои модульные тесты, и теперь пора проверить покрытие кода. Отлично. Выше 80% результат выглядит неплохо… Но так ли? Вы говорите себе: хорошо, я достиг цели отраслевого стандарта, я читал где-то, теперь я могу провести все те фантастические тесты, которые будут нашими хранителями для будущих рефакторов, и все будут счастливы, что они у нас есть.

Но что, если бы вместо этого вы спросили себя: «Создал ли я тесты только ради числа покрытия, или эти тесты действительно проверяют то, что имеет значение?»

Давайте поговорим о модульном тестировании

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

Используйте реальные варианты использования

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

Возьмем для примера Приложение Todos в репозитории Github. Помимо основной ветки, этот репозиторий содержит 2 дополнительных ветки:

  • парные тесты
  • разделенные тесты

Если вы посмотрите на покрытие кода в обеих ветвях, вы увидите, что процент довольно высок.

Единственное различие между ветвями, основанными на отчетах о покрытии кода, состоит в том, что ветвь decoupled-tests имеет меньшее покрытие и меньшее количество выполненных тестов.

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

Связанные тесты с производственным кодом

Если вы откроете репозиторий в ветке coupled-tests, вы обнаружите, что каждый файл производственного кода имеет соответствующий файл с тестами.

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

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

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

Как отделить тесты от производственного кода?

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

  • Пользователь может просматривать Todos
  • Пользователь может добавить новый Todo
  • Пользователь может удалить Todo
  • Пользователь может отметить Todo как выполненный
  • некоторые случаи использования ошибок: просмотр, добавление, удаление, обновление может завершиться ошибкой

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

Теперь давайте посмотрим на ветку decoupled-tests.

Как вы сразу заметили, рядом с производственным кодом больше нет тестовых файлов, и все наши тесты находятся в одном тестовом файле Todos.test.tsx, который содержит все упомянутые варианты использования. Тесты проверяют только компонент TodoList.tsx и, если мы реорганизуем TodoItem.tsx или AddTodo.tsx, тогда тесты будут проходить, как и мы. не меняет внешнее поведение (которое в данном случае находится в TodoItem.tsx.).

Детали реализации макета

Когда мы снова заглянем в ветку coupled-tests и тесты компонентов, мы заметим, что мы имитируем службу todos.ts.

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

Теперь удалим все макеты и оставим только необходимые. Ах, я слышу вопрос! Какие нужны макеты? Что ж, теперь мы переходим к разнице между интеграционными и модульными тестами. Необходимые имитации - это те, которые имитируют некоторую интеграцию с другой системой. В нашем примере это связь с сервером с помощью вызовов Ajax с помощью fetch api *. Итак, fetch api - это наша точка интеграции с другой системой, и именно здесь мы вводим mock в наши тесты, и именно это вы можете найти в ветке decoupled-tests.

Можно сказать, что это становится интеграционным тестом. Это? Если бы это было так, мы бы даже не имитировали fetch api и позволили нашим компонентам реально взаимодействовать с внешней системой. Так что, с моей точки зрения, это все еще юнит-тест.

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

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

Детали реализации тестирования

Это связано с тестами сопряжения с производственным кодом. Если нам удастся отделить тесты от производственного кода, редко случается, что детали реализации проверяются. Но каковы детали реализации? Можно думать об этом как о вспомогательном коде основного кода. Это большой компонент или класс, преобразованный в мелкие части, которые обычно являются деталями реализации. Но это также могут быть нижние уровни многослойного приложения. В приложении ReactJS это могут быть Redux store, Sagas, сервисы и т. Д. Это также детали реализации, о которых пользователям все равно.

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

В нашем примере компоненты todos.ts, TodoItem.tsx и AddTodo.tsx являются деталями реализации, которые мы не хотите протестировать индивидуально, как это делается в ветке coupled-tests. Вместо этого все эти файлы можно протестировать как часть компонента тестирования TodoList.tsx, как это делается в ветви decoupled-tests. И, как вы можете видеть из приведенного выше покрытия кода, эти файлы полностью покрываются, даже если они не тестируются явно. Это позволяет нам выполнять рефакторинг этих внутренних компонентов, не проваливая тесты, и требует меньше кода, что означает меньшее обслуживание.

И почему ветвь decoupled-tests имеет меньшее тестовое покрытие, чем ветка coupled-tests? Это потому, что в ветви decoupled-tests мы не тестируем App.tsx. Но если мы хотим иметь действительно 100% покрытие также и в разделенных тестах, это легко сделать. Мы можем просто заменить проверенный компонент TodoList в Todos.test.tsx на компонент App, и тест покажет, что все в порядке.

Тесты разработки

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

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

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

Что говорят другие?

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

Также широко известен термин контр-дисперсия, особенно в сообществе TDD, но я думаю, что он может применяться в целом, даже если вы не используете подход TDD. Покрытие кода может дать вам ложное ощущение хорошего теста. объяснил Мартин Фаулер в своем блоге.

Резкие заявления - согласны?

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

«Тестирование - это не количество тестовых покрытий, а проверка вариантов использования»

«Используйте тестовое покрытие только в качестве ориентира для выбора следующего варианта использования, который вы будете тестировать»

«Лучше 0 тестов, чем много плохих».

«Тесты - это еще один код, о котором вам нужно позаботиться»

«Не бойтесь удалять тесты»

«Пара разделенных тестов может охватывать больше кода, чем десятки связанных тестов»