Главная задача юнит тестов - получить как можно быстрее фитбэк от тестов по поводу кода
Признаемся честно: слово «тестирование» вызывает у многих разработчиков примерно такую же радость, как поход к стоматологу. Большинство морщится и думает: «Опять эти тесты... Лучше бы новую фичу запилил!» И я вас прекрасно понимаю — сам когда-то был в лагере скептиков.
Но после нескольких лет руководства командой фронтенда, десятков ночных дебагов и сотен часов, потраченных на поиск неуловимых багов, я пришёл к неожиданному выводу: качественные юнит-тесты — это не якорь, замедляющий разработку, а реактивный двигатель, ускоряющий её.
В этой статье я расскажу, почему наша команда делает ставку именно на юнит-тесты, и как они могут превратить вашу разработку из хаотичного забега с препятствиями в уверенный марафон с чёткими ориентирами.
Соглашение о терминах
Прежде чем погрузиться в глубины тестирования, давайте договоримся о терминах, чтобы говорить на одном языке:
Интеграционные тесты — в нашем мире это тесты с использованием React Testing Library, которые проверяют, как компоненты взаимодействуют друг с другом
Юнит-тесты — тесты с использованием Enzyme (shallow render) который щас устарел, но на смену пришел Shallowly ну или аналогичные инструменты, фокусирующиеся на изолированной проверке отдельных функций или компонентов
Важное замечание: юнит-тесты — не серебряная пуля и не панацея от всех проблем. Как и любой инструмент, они могут быть как полезными, так и бесполезными, в зависимости от того, как их применять. Но есть одно неоспоримое преимущество — они безошибочно укажут на проблемы в вашем коде, если написание теста превращается в квест с семью печатями.
Немного предыстории
Прежде чем я начну расхваливать юнит-тесты направо и налево, позвольте рассказать историю из жизни. У нас есть крупный проект на React, масштабы которого впечатляют:
Language |
Files |
Lines |
Code |
Comments |
Blanks |
---|---|---|---|---|---|
JavaScript |
2590 |
91950 |
68344 |
15700 |
7906 |
JSX |
2564 |
148050 |
103765 |
27614 |
16671 |
TypeScript |
894 |
42988 |
32153 |
7506 |
3329 |
TSX |
679 |
28454 |
19791 |
6216 |
2447 |
Всего |
6942 |
317518 |
228996 |
57367 |
31155 |
Впечатляет, не правда ли? И почти все компоненты и функции в проекте следуют принципам SOLID, особенно принципу DRY (Don't Repeat Yourself). По крайней мере, мы очень старались.
Примечание:
SOLID - это набор принципов объектно-ориентированного проектирования, включающий Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation и Dependency Inversion.
DRY (Don't Repeat Yourself) - принцип, направленный на снижение повторения кода.
Нам по первой вообще зашла модная волна «тестировать как пользователь» с React Testing Library, пытаясь имитировать интеграционные или даже E2E тесты. Логика казалась железной: меньше тестов — меньше проблем с поддержкой. Но мы получили диаметрально противоположный результат:
-
Тесты превратились в черепах на снотворном — они стали выполняться в десятки раз дольше. Вместо мгновенной обратной связи приходилось ждать минуты, а иногда и десятки минут. О каком TDD может идти речь, если после каждого изменения ты можешь выпить кофе, пока тесты выполняются?
«Где сломалось?» стало любимой игрой команды — когда падал тест, начиналось настоящее детективное расследование. Достаточно было добавить один новый хук или функцию, которую нужно мокать, и всё — пиши пропало! Особенно «весело», когда ты не помнишь, где и когда это добавил, потому что тесты запускаешь только в конце разработки (они же черепашьи).
Хрупкость тестов достигла космических масштабов — небольшое изменение в одной функции, которая используется по всему проекту, приводило к каскадному падению тестов. Представьте: вы изменили форматирование даты, и сразу 100+ тестов загорелись красным. И для каждого нужно отдельно разбираться — тест действительно должен падать или это ложное срабатывание?
Моки захватили власть над кодом — мы перестали писать тесты и начали писать моки. В нашем проекте около 100 форм с карточками объектов. Все они разные, но используют общие компоненты — инпуты, табы, селекты. У этих компонентов есть общая логика — запросы на бэкенд, валидация, фильтрация. При интеграционном тестировании перед нами вставал суровый выбор: либо мокать все эти компоненты и их взаимодействия (а это уже не тесты, а имитация), либо по сто раз прокликивать одни и те же инпуты в разных контекстах.
-
Бесконечное дублирование тестов — используя RTL, мы невольно тестировали одни и те же функции снова и снова. Представьте: у вас есть компонент формы с валидацией полей, который использует десяток других компонентов которые используют одни и теже функции или хуки. При тестировании этой формы через RTL вы неизбежно тестируете все вложенные компоненты, их логику и взаимодействие. Теперь представьте, что эти же компоненты используются в десятке других мест. В результате одна и та же функция валидации email тестируется сотни, а то и тысячи раз! Это не просто избыточность — это настоящий кошмар для производительности. Когда вы меняете простую функцию валидации, вам приходится ждать, пока пройдут ВСЕ тесты, которые её используют, хотя достаточно было бы одного юнит-теста. Это как проверять работу двигателя, тестируя каждую машину в автопарке, вместо того чтобы просто протестировать сам двигатель один раз.
Тесты начали падать из-за... времени выполнения — с комплексной логикой (зависимыми друг от друга инпутами, условной валидацией, динамически меняющимися полями) тесты просто не успевали выполниться за отведенное Jest-ом время. Приходилось дробить тесты на более мелкие, писать хаки с увеличенными таймаутами. А потом CI/CD стал отваливаться, потому что тесты выполнялись по 2-3 часа! Вы можете представить себе пайплайн, который висит 3 часа из-за тестов?
Наша главная задача — получить как можно быстрее фидбэк от тестов о состоянии кода. И мы на собственном опыте убедились, что интеграционные тесты категорически не подходят для этого. А про E2E в таком масштабе даже думать страшно.
Как мы решили проблему
Решение оказалось до боли простым — мы «просто» перешли на настоящие юнит-тесты. И результаты не заставили себя ждать:
Тесты стали быстрее — вместо 30 секунд они выполнялись за доли секунды
Локализация ошибок упростилась — если функция ломалась, тест точно показывал, где проблема, и её было легко исправить
Поддерживать тесты стало проще — тест проверял только одну функцию или компонент, поэтому его не нужно было переписывать при изменении других частей системы
Писать тесты стало легче — одна функция, одно действие, один тест. Прекрасная формула для счастья разработчика
Как мы решали проблему и на какие грабли мы наступили
Когда мы начали писать юнит-тесты, нас быстро накрыла волна отчаяния. Компоненты оказались настолько огромными, с такой глубоко вложенной и запутанной логикой, что написание юнит тестов превратилось в настоящую пытку. Каждый новый тест требовал десятков моков, а разобраться, что именно мы тестируем, становилось всё сложнее. На написание простейших юнит тестов уходили часы, а то и дни.
«Что-то здесь не так», — думали мы, ломая голову над очередной мок-конструкцией. И тут нас осенило — мы пытаемся запихнуть в голову то, что туда просто физически не помещается! Наши компоненты были настолько перегружены ответственностью, что ни один человек не мог удержать в памяти всю их логику. И тут, как гром среди ясного неба, прозвучал вопрос: «А как же SOLID? А именно — Single Responsibility Principle (SRP)?»
Single Responsibility Principle (SRP) — первая буква "S" в SOLID. SRP гласит, что каждый класс/функция должна иметь только одну причину для изменения, то есть одну ответственность.
Для функций это означает:
Каждая функция должна выполнять одну конкретную задачу
Функция должна быть легко понимаемой и предсказуемой
Если функция делает несколько вещей, её стоит разделить на несколько отдельных функций
И вот тут нас буквально прорвало! После рефакторинга компонентов согласно SRP, тесты начали писаться с поразительной скоростью. А фидбэк от этих тестов прилетал практически мгновенно — они выполнялись молниеносно, что вызывало почти детский восторг.
Мы пришли к болезненному, но важному осознанию: сложный код не просто сложно поддерживать — писать для него юнит-тесты невероятно дорого и мучительно. Именно поэтому многие разработчики предпочитают «замести проблему под ковёр», протестировав свои монструозные компоненты интеграционными тестами как чёрные ящики. Но это лишь отсрочка неизбежного.
Вывод оказался простым: если писать код как учили мудрые дядьки в старые времена, то всё получается как по маслу — код остаётся поддерживаемым, тесты пишутся быстро, а самое главно код теперь прозразчный а жизнь в проекте становится приятной, а текучка кадров, как выяснилось, волшебным образом снижается.
Что останавливает разработчиков от написания юнит-тестов?
Скептики тестирования (а я знаю, что вы сейчас читаете эту статью и скептически хмыкаете) обычно приводят следующие аргументы:
-
«Я не понимаю, как это тестировать!»
Если вы не можете быстро написать тест для своей функции, это тревожный сигнал. Возможно, ваша функция — это монструозное творение, которое делает всё сразу: и данные форматирует, и запросы к API отправляет, и интерфейс обновляет, и кофе варит. Как говорил дядюшка Боб: «одна функция — одно действие». И поверьте моему опыту: писать чистый и лаконичный код намного сложнее, чем выписывать многостраничные функции с процедурно-императивным подходом которые содержат десятки ветвлений и вложенных циклов.
-
«У нас дедлайн горит, какие тесты!?»
Ах, эта сладкая иллюзия экономии времени! Да, сегодня вы сэкономили час, не написав тесты. А завтра потратите день, разбираясь, почему ваш идеальный код внезапно решил отправиться в отпуск без предупреждения. Технический долг имеет свойство накапливаться с ужасающей скоростью, а процентная ставка по нему — просто грабительская. Тем более когда дедлайн и прод горит можно в край и без одного тестика подлить окуратно но быть спокойным что другое не сломано.
-
«Зачем тестировать очевидное?»
«Это же просто функция, которая складывает два числа! Что тут может пойти не так?» — говорит разработчик перед тем, как его простая функция ломается на отрицательных числах, дробях, строках или null-значениях. В программировании «очевидное» часто оказывается миражом, а пограничные случаи имеют неприятную привычку возникать в самый неподходящий момент. Кто сталкивался с неймингом где руские буквы вкрались в название такие как 'с' или 'e'? . Самые тупые ошибки обычно и самые трудные в поиске так как вроде очевидные же вещи.
Юнит-тесты должны быть
Чтобы от юнит-тестов была реальная польза, а не просто зелёные галочки для успокоения совести, они должны обладать несколькими ключевыми качествами:
-
Быстрыми
в написании (если тест пишется дольше самой функции — что-то не так)
в выполнении (миллисекунды, а не секунды или минуты)
в поддержке (изменение функции не должно приводить к полной переработке теста)
-
Лёгкими
в понимании (даже новичок в команде должен понять, что проверяет тест)
в отладке (при падении теста должно быть очевидно, где искать проблему вплодь до курсора в строчке кода)
в модификации (тесты должно быть легко адаптировать под новые требования)
-
Правдивыми
если тест проходит, функция действительно работает правильно
если тест падает, он точно указывает на проблему, а не на ложную тревогу
-
Изолированными
тест не должен зависеть от других тестов или внешнего состояния
результат выполнения должен быть детерминированным, независимо от окружения
Для чего нужны юнит-тесты
Фиксация функционала
Мой любимый случай — когда вы работаете над новой фичей, показали заказчику кнопку, которая должна делать «А», а через две недели разработки вдруг выясняется, что она делает «Б». Что произошло? Вы для реализации функционала просто замокали какие то данные и внесли изменения и случайно забыли убрать мок (флаг показа кнопки). Юнит-тест в этом случае действует как якорь, который не даёт функциональности уплыть в неизвестном направлении. (типа да это на код ревью человек должен делать... но зачем делать человеку если это можно тупо автоматизировать ? )
Понимание кода
Написание теста заставляет вас досконально разобраться в том, как работает тестируемый код. Это особенно ценно, когда вы имеете дело с чужим кодом или с кодом, который вы написали давно и успели забыть детали. Тесты — это своеобразная документация, которая всегда актуальна и выполнима.
Борьба со сложностью
Если вы не можете легко написать тест для функции, это почти всегда означает, что функция слишком сложная, делает слишком много или имеет слишком много зависимостей. Тесты естественным образом подталкивают вас к написанию более простого, модульного и чистого кода.
Поощрение чистого кода
Юнит-тесты — это беспощадный критик вашего кода. Они быстро выявят, если ваша функция имеет слишком много ответственностей или сильно связана с другими частями системы. Когда вы пытаетесь протестировать функцию в 1000 строк со множеством побочных эффектов, вы быстро понимаете, что что-то пошло не так в вашей архитектуре.
Получается, интеграционные тесты не нужны?
Ни в коем случае! Интеграционные тесты необходимы, но они решают другие задачи. На боевом проекте мы используем их для проверки взаимодействия компонентов, особенно для позитивных сценариев, чтобы убедиться, что система в целом работает правильно.
Интеграционные тесты особенно полезны для библиотек где степень переиспользования внутренних функций или компонентов очень низкая. Например, сама библиотека Shallowly для юнит тестов React компонентов тестируется интеграционными даже я бы сказал что они покрыты E2E-тестами.
Но когда речь идет о быстром обнаружении и локализации проблем, юнит-тесты вне конкуренции.
Юнит-тесты идеально подходят для проверки:
Сложных ветвлений, которые трудно воспроизвести вручную
Пограничных случаев (0, undefined, null, пустые массивы и т.д.)
Чистой бизнес-логики без привязки к DOM
где большая переиспользуемость функций и компонентов
Интеграционные тесты нужны, но они не скажут вам, где именно упала функция форматирования цены. А юнит-тест укажет на проблему с хирургической точностью.
Как мы пишем юнит-тесты
В нашей команде мы придерживаемся нескольких простых принципов при написании юнит-тестов:
Тестируем только публичный API компонентов или функций — внутренние детали реализации могут меняться
Используем моки для изоляции тестируемого кода от внешних зависимостей
Следуем структуре Arrange-Act-Assert для единообразия и понятности тестов
Даём тестам говорящие имена, отражающие что именно они проверяют
Поддерживаем тесты в актуальном состоянии, обновляя их вместе с кодом
Мы также интегрировали наши тесты с инструментами вроде Wallaby.js, что позволяет видеть результаты тестов прямо в IDE в режиме реального времени — это невероятно ускоряет процесс разработки.
Заключение
Юнит-тесты — это инвестиция в будущее вашего проекта и вашу собственную безмятежность. Они могут показаться лишней работой сейчас, но они окупаются стократно, когда ваш проект растёт, а команда меняется.
Для скептиков, которые всё ещё сомневаются, у меня есть простой аргумент: вы можете не писать тесты сейчас, но отлаживать и исправлять баги вам придётся в любом случае. Просто с тестами это будет происходить в контролируемой среде за вашим рабочим столом, а не в боевых условиях после полуночи, когда заказчик в панике звонит и сообщает, что всё сломалось.
Начните с малого — покройте тестами одну функцию сегодня, другую завтра. Вскоре вы заметите, как растёт ваша уверенность в коде и снижается количество внезапных «сюрпризов». И помните: если писать юнит-тест сложно, возможно, проблема не в тесте, а в коде, который вы пытаетесь тестировать.
А если кто-то из коллег начнёт ворчать о «пустой трате времени на тесты», просто покажите ему эту статью. И не забудьте позлорадствовать, когда в следующий раз его код без тестов превратится в непроходимые джунгли багов и технического долга. Ой, кажется, последнее я сказал вслух?
Да и мне кажется что тесты на работу всей системы должны писать тестировщики а не разработчики. Так как у них на руках все инструменты и они знают что именно нужно проверить по спецификации и по требованиям от заказчика.
Unnemed112
Согласен с автором, ощутил на себе как тесты экономят время и ресурсы. Я бы еще добавил, что существует тип проектов, как библиотеки, которые в принципе разрабатываются только через написание тестов.