Вступление

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

Я считаю, что такой пример опасен и ведет лишь к ложным ориентирам.

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

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

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

Если же ты решился, то сложно переоценить пользу тестов. Тесты помогали много раз.

Я собрал здесь множество советов и практик, которые юзал много лет на проектах разных масштабов и технологий. Будь это аутсорс или продукт. Mobile или front-end.

В гугле есть 2 термина для таких спецов:

  • кодеры, кто наклепал и забыл

  • программный инженер, кто глобально думает о поддержке своего кода

Эта статья для последних

Программная инженерия охватывает не только написание кода, но и все инструменты и процессы создания и дальнейшей поддержки этого кода (с) "Делай как в Google. Разработка программного обеспечения"

Содержание:

  • Пирамида тестирования

  • Кто это такой ваш юнит-тест?

  • Зачем нужны юнит-тесты?

  • Проверка только самых важных частей кода

  • Метрики

  • Свойства идеальных тестов

  • Когда не стоит проводить unit-тестирование

  • Антипатерны

  • Изоляция теста

Пирамида тестирования

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

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

Хороший тестировщик учит и менторит всех в команде как следить за качеством.

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

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

Если рассматривать программирование как жизнь на земле, то TDD окажется аналогом квантовой механики . Рефакторинг будет соответствовать химии, а простота проектирования — микробиологии . На уровне физиологии у нас окажутся принципы SOLID, объектно-ориентированное проектирование и функциональное программирование, а на уровне экологии — архитектура. Соответственно, получить чистый код без TDD сложно или невозможно. (c) Роберт Мартин

Недостатки e2e и интеграционных тестов

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

Автоматизированные тесты не должны осуществлять проверку бизнес-правил через пользовательский интерфейс. (c) Роберт Мартин

Это очень распространенная проблема, когда клиентские приложения проверяют корректность бизнес-логики через UI пользователя. Почему это плохо?

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

  • Некоторая бизнес-логика не зависят от View слоя или имеют его по-минимуму.

  • Дорого для корнер-кейсов. Создавать e2e или компонентные тесты дорого по времени для всех состояний. Часто пишут только один успешный сценарий

  • Долгий запуск других тестов. Обычно е2е запускаются рано утром и могут достигать 20-40 часов ожидания.

  • Часто дорогие тесты детектят только факт падения, но не дают причины

Чем полезны unit-тесты

Модульное тестирование (unit testing) — тесты, задача которых проверить каждый класс системы по отдельности. Желательно, чтобы это были минимально делимые кусочки системы, например, модули. Unit-тесты — это тесты для одного класса. Такие тесты используют для тщательной проверки сложной логики и алгоритмов, инкапсулированных в одном классе. Желательно, чтобы у таких классов не было изменяемых зависимостей.

Зачем нужны юнит-тесты?

цель — обеспечение стабильного роста программного проекта. Ключевым словом здесь является «стабильный». В начале жизни проекта развивать его довольно просто. Намного сложнее поддерживать это развитие с прошествием времени.

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

Плюсы юнит-тестов:

  • Выгода на долгой дистанции.

  • Рефакторинг. Огромные главы у того же Мартина о бесполезности рефакторинга без юнит-тестов

  • Скорость поддержки. Намного легче находить дефекты, баги, браки

  • Переиспользование кода

  • Документация кода. Нет ничего эффективней, чем понимать код по актуальным тестам бизнес-логики

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

Юнит-тест позволяет найти баги на этапе разработки. Зафиксировать поведение и гарантировать, что этот кейс был проверен

Рефакторинг — это практика, позволяющая писать чистый код . Она трудно реализуема, а порой и невозможна без TDD. Соответственно, получить чистый код без TDD сложно или невозможно. (с) все тот же дядька Мартин

Проверка только самых важных частей кода

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

Тестирование бизнес-логики обеспечивает тестам наилучшую эффективность.

Все остальные части можно разделить на три категории:

  • инфраструктурный код;

  • внешние сервисы и зависимости — например, базы данных и сторонние системы;

  • код, связывающий все компоненты воедино.

Метрики

Code Coverage: наиболее часто используемая метрика покрытия — code coverage, также известная как test coverage. Эта метрика равна отношению количества строк кода, выполняемых по крайней мере одним тестом, к общему количеству строк в основном коде проекта.

Code coverage (test coverage) = Количество выполненных строк кода / Общее количество строк кода

func isStringLong() -> Bool {
    if text.count > 5 {
        return true
    }
         
    return false
 }

Покрытие в этом примере вычисляется легко. Общее количество строк в методе равно 5. Количество строк, выполняемых в тесте, равно 4 — тест проходит все строки кода, кроме команды return true. Таким образом, покрытие равно 4/5 = 0,8 = 80 %.

Что будет, если отрефакторить этот метод и убрать избыточную команду if?

func isStringLong() -> Bool {
    return "string".count > 5
}

Изменился ли процент покрытия? Да, изменился. Покрытие кода увеличилось до 100 %. Но улучшилось ли качество тестов с таким рефакторингом? Конечно же, нет. Тест по-прежнему проверяет то же количество ветвлений в коде. Этот простой пример показывает, как легко подтасовать процент покрытия. Чем компактнее ваш код, тем лучше становится этот процент, потому что в нем учитывается только количество строк. В то же время попытки втиснуть больше кода в меньший объем не изменяют общую эффективность тестов.

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


Branch coverage:

Другая метрика покрытия называется branch coverage (покрытием ветвей).

Branch coverage показывает более точные результаты, чем code coverage. Вместо того чтобы использовать количество строк кода, эта метрика ориентируется на управляющие структуры — такие как команды if и switch. Она показывает, какое количество таких управляющих структур обходится по крайней мере одним тестом в проекте

Branch coverage = Количество покрытых ветвей / Общее количество ветвей

Чтобы вычислить метрику branch coverage, необходимо подсчитать все возможные ветви (branches) в коде и посмотреть, сколько из них выполняются тестами. Вернемся к предыдущему примеру со строкой.

Метод IsStringLong содержит две ветви: одна для ситуации, в которой длина строкового аргумента превышает пять символов, и другая для строк, длина которых менее или равна 5 символам. Тест покрывает только одну из этих ветвей, поэтому метрика покрытия составляет 1/2 = 0,5 = 50 %. При этом неважно, какое представление будет выбрано для тестируемого кода — будете ли вы использовать команду if, как прежде, или выберете более короткую запись. Метрика branch coverage принимает во внимание только количество ветвей; она не учитывает, сколько строк кода понадобилось для реализации этих ветвей.

Свойства идеальных тестов:

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

    1. объем кода, выполняемого тестом

    2. сложность этого кода

    3. важность этого кода с точки зрения бизнес-логик

    Как правило, чем больше кода тест выполняет, тем выше вероятность выявить в нем баг

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

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

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

  5. простота поддержки — оценивает затраты на сопровождение кода. Метрика состоит из двух компонентов:

    1. Насколько сложно тест понять. Этот компонент связан с размером теста. Чем меньше кода в тесте, тем проще он читается.

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

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

Когда не стоит проводить unit-тестирование

Модульное тестирование — не универсальный инструмент проверки программного продукта. В некоторых ситуациях оно лишь отнимет время и силы, не показав значимого результата, например:

  • при тестировании сложных и разветвленных алгоритмов, таких как красно-черное дерево, придется разработать большое число тестов, что существенно усложнит и замедлит проверку;

  • отсутствии четких результатов — например, в математическом моделировании природных процессов, настолько сложных, что их «выход» невозможно спрогнозировать, а можно только описать в виде интервалов вероятных значений;

  • тестировании кода, взаимодействующего с системой, — например, модуля, связанного с портами, таймерами и другими «нестабильными» компонентами, от которых его сложно изолировать;

  • проверке всего приложения — модульное тестирование не покажет ошибки интеграции, баги ядра и другие аспекты, не относящиеся непосредственно к конкретному модулю;

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

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

Антипатерны

  • Тестовый код не должен дублироваться.

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

    • Не ориентируйтесь ни на какую конкретную реализацию при написании тестов. Проверяйте рабочий код с точки зрения «черного ящика»

  • Закомментированные или выключенные тесты. Тесты всегда должны находиться в рабочем и актуальном состоянии.

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

Изоляция теста

Вопрос изоляции — это корень различий между классической и лондонской школой юнит-тестирования

Что же означает «изоляция кода» в юнит-тестировании? Есть 2 школы тестирования: лондонская и классическая.

  • Лондонская школа описывает это как изоляцию тестируемого кода от его зависимостей. Это означает, что если класс имеет зависимость от другого класса или нескольких классов, все такие зависимости должны быть заменены на тестовые заглушки (test doubles). Это позволит вам сосредоточиться исключительно на тестируемом классе, изолировав его поведение от внешнего влияния

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

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


В следующих статьях мы поговорим о более детальных примерах с кодом, черных и белых ящиках

Используемая литература:

Комментарии (5)


  1. NeoNN
    06.01.2023 11:33

    Простите, но вы только что узнали про тесты и решили написать статью на хабр? С чего вы решили, что они не популярны в СНГ? И как же по-вашему работают IT-компании и пишут продакшен-код?


    1. levbond Автор
      06.01.2023 11:35

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

      Часто в том же бэке тесты — необходимость. К сожалению, в других платформах пока еще слабая культура


      1. insighter
        06.01.2023 18:45

        >К сожалению, в других платформах пока еще слабая культура

        Работаю в крупном федеральном ритейлере, тесты есть и для бэка (юнит + интеграционные) и UI тесты (JavaScript, React).

        Вот за мобилку не знаю правда


      1. NickDoom
        06.01.2023 23:03
        +2

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

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

        Но у меня всё довольно-таки самобытно-велосипедно организовано, я довольно далёк от устоявшихся в IT корпоративных подходов, так что мой основной девиз — паранойя. Сначала прога состоит из бага — её нет, значит, в ней неправильно всё. Потом число багов становится конечным, и начинается охота. Разбить лес на квадраты и прочёсывать граблями, и если где-то хотя бы один шорох листа не соответствует теоретическому ожиданию, пусть даже всё работает и результат верный — баг там, его надо ловить. Исключений не бывает.

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


  1. Gargo
    07.01.2023 13:11

    >В большинстве случаев ручное тестирование — огромная трата денег и времени.

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