Тесты: 100% покрытия и юниты не нужны

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

Меня зовут Максим Вишневский, я Senior Frontend-разработчик в Циан. В этой статье поделюсь историей, как наша команда реформировала подходы к тестированию: как мы отказались от 100% покрытия и unit-тестов, чем их заменили и какой получили результат. Поговорим о проблемах с Enzyme, пользе Playwright, мокинге данных для бэка и взаимодействии с QA.

Циан — огромная компания. Ежемесячно наши ресурсы посещают 19 миллионов уникальных пользователей и размещают более 1,9 миллионов актуальных объявлений. Это большая кодовая база, которую нужно тестировать. Раньше мы подходили к этому вопросу с точки зрения «покрыто, значит, надёжно». Однако такое убеждение может привести к проблемам. Чтобы гарантировать стабильную работу наших сервисов, мы решили разобраться в том, как всё должно работать, и как мы можем это реализовать у себя.

Почему unit-тесты — это боль

Unit-тесты внутри компонентов — не лучшая затея. Приведу несколько причин.

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

Ниже пример кода на Enzyme. Берём компонент из контейнера и проверяем его наличие:

describe('Koмпонент ActionComponent’, () => {
  it('Рендерит компонент’, () => {
    const wrapper = shallow();
    const button = wrapper.find();

    expect(button.exists()).toBeTruthy();
  });
});

Coverage есть, значит, код покрыт тестами и всё работает. Но по сути мы получаем лишь уверенность в том, что в продакшене компонент стабильно встраивается. При этом не проверяем реальное поведение прода, corner-кейсы, не следим за соблюдением контракта между контейнером и компонентом, а также за тем, как он взаимодействует с окружающими компонентами.

  1. Unit-тесты плохо работают на проверку качества.

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

  1. В unit-тестах не тестируются user story.

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

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

  1. Нет уверенности в работе композиции компонентов.

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

  1. Нет гарантии качественной работы компонентов в проде.

Когда код успешно протестировали и выкатили, остаётся непонятным, качественно ли он работает. Да, мы можем полагаться на E2E (end-to-end) тесты от тестировщиков, но лично мне, как разработчику, хочется быть уверенным, что это действительно будет работать.

  1. Из-за unit-тестов накапливается избыточное количество кода.

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

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

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

Отказ от 100% покрытия

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

export function testedFunc(obj: Record) {
  obj.x = 42;
}

it('100% coverage test’, () => {
  const obj: Record = {};
  testedFunc(obj);

  expect (obj.x). toEqual(42);
});

Представим, нам нужно пропатчить объект. Мы провели тестирование и достигли 100% покрытия. Всё отлично, пока не возникает необработанное исключение:

it('exception', () => {
  const obj: Record = {};
  Object.preventExtensions(obj);

  testedFunc(obj);

  expect (obj.x).toEqual(42); // TypeError
});

Получается, есть позитивный сценарий со 100% покрытием и негативный сценарий, который мы не проверили.

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

Ещё может произойти знакомая многим ситуация: есть функция, осуществляющая простое деление. Мы проверили 2 числа, они отработали. Потом получили на экране наше любимое: «Стоит NaN рублей».

2454ed680197b3b16b1847bda1238228.jpg

Стали искать решение, как это побороть, и первым делом отказались от 100% покрытия, занизили его до 80%. Так мы решили проверить, будет ли прирост в статистике багов и каким будет dx (developer experience). Что это дало:

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

b1b47b8f2d1199fd0ca1641ad11f3d4f.jpg

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

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

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

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

647d2bd936b4cefde1e61cbd0e1aedc2.png

Отказ от Enzyme

Enzyme — это инструмент, предоставляющий гибкие возможности для тестирования компонентов React. Понятие гибкости здесь относительное:

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

describe('Koмпонент ActionComponent', () = {
  it('Обрабатывает клик', () => {
    const wrapper = shallow();
    const button = wrapper.find();
    const onClick = button.prop('onClick")

    onClick();

    expect (someHandler).toBeCalled();
  });
});

Так можно добраться до пропсов, выполнить клик и что-то проверить. Но это не продакшен-ready компонент — он не отрендерился, это всё ещё функциональное состояние.

Мы не проверяем атомарное состояние компонента, ничего не интегрируем, мы просто какой-то магией куда-то достучались. Зачем мы это пишем? Закрываем coverage. Это даёт хоть какую-то надежду на то, что сбой не случится.

Для меня, как разработчика, ключевой недостаток Enzyme в скорости работы. Он очень медленный. Когда у вас 505 секунд на более полутора тысяч тестовых сценариев, медлительность становится проблемой.

83eb44c47cbb73e5e826661993025d1f.jpg

На момент выступления, в 2023 году, в Enzyme было открыто более двухсот issue без ответов. Команда разработчиков React официально отказалась от поддержки этого инструмента, ни одна версия поддерживаться не будет.

Мы решили отказаться от использования Enzyme и вместо этого по рекомендации от разработчиков React попробовали React Testing Library (RTL).

8956e96b064c15b776b08f325dd083e6.png

React Testing Library (RTL)

В целом, как инструмент RTL оказался неплохим, с несколькими заметными плюсами:

RTL рендерит компоненты, предоставляя нам понятную структуру, что даёт большую уверенность в том, как код будет работать в продакшене. Это также позволяет нам писать интеграционные тесты.

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

RTL работает довольно быстро, хотя не всегда на уровне, которого нам хотелось, но в целом достаточно эффективно.

На фоне плюсов у RTL есть минусы:

Иногда моков больше, чем кода. С этим минусом можно работать, но он существенный.

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

Некоторые сложности возникают при тестировании функциональной части компонентов и хуков. Мы пробовали решить эту проблему, разделяя логику и компоненты: внутри контейнера оставили только jsx, сделали хук use component. Вся логика находится внутри контейнера, соответственно последний тестировали снэпшотами, хук протестировали unit-тестом. Как будто бы просто переложили всё в разные места, но суть осталась той же.

Несмотря на минусы, мы оставили RTL. Он особенно пригодился для покрытия отдельных сценариев (corner-кейсов) и для тестирования интеграций.

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

Нет необходимости включать их в функциональные тесты, это накладно. С помощью RTL можно покрыть такие сценарии unit-тестами с минимальным воздействием на инфраструктуру. 

Также RTL позволил нам проводить интеграционное тестирование, собирая компоненты вместе и проверяя их взаимодействие. Это оказалось полезным средством для проверки работы композиций компонентов.

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

Ценности при тестировании

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

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

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

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

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

На начальном этапе много говорили о пирамиде тестирования. Это способ группировки и организации тестов на разные уровни, в зависимости от их назначения. Пирамида группируется по принципу: основа — уровень unit-тестов, ему отводится самое большое место; средний уровень — интеграционные тесты; на верхушке — E2E-тесты.

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

a8e2bfdae877f720bbdf19cd1e419ff8.jpg

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

Unit-тесты остаются в основании, так как у нас ещё есть функции, которые стоит ими тестировать. Например, селекторы, редьюсеры, хуки и ещё хелперы — различные утилиты, в тестировании которых интеграционное и функциональное тестирование не помогут.

134765eb1b288ae32051bafcfae3e511.jpg

Больше места теперь выделяем интеграционным/функциональным и E2E-тестам, мы фокусируемся на них. И наконец-то тестируем user story — пришли к ценностям! Но пока только на бумаге.

Мы задались вопросом, что такое user story? Что это за ценность и как мы должны для себя её определять?

User story

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

c0bd5eeadf4f61694a7ecb12485c5620.jpg

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

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

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

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

Интеграционное тестирование

Интеграционное тестирование проверяет взаимодействие между несколькими компонентами приложения.

Вот что включает в себя интеграционный тест:

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

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

Это важно, чтобы убедиться, что данные, которые мы передаём между компонентами, остаются валидными. Несмотря на типизацию в языках программирования, таких как TypeScript, могут возникать ситуации, когда данные между компонентами мутируют. В React это не такая частая ситуация как при работе с Vue.js.

Функциональное тестирование

Функциональное тестирование занимается проверкой работы системы с точки зрения бизнес-требований и пользовательских сценариев. Вот что оно включает:

Это сложное и всестороннее тестирование, которое охватывает всю бизнес-логику приложения. Мы проверяем, соответствует ли работа приложения заданным требованиям.

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

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

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

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

Отказ от unit-тестов компонентов

Мы выпилили unit-тесты компонентов — это было для нас своеобразным прыжком веры, выбросили их и ладно.

5b19130e63d673a0be39a927997f4dc3.jpg

Что изменилось:

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

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

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

Для написания функциональных тестов пробовали два инструмента — сначала Cypress, затем Playwright.

Cypress: плюсы и минусы

У этого инструмента есть несомненные плюсы:

  • Имеет режим визуального тестирования. Вы видите реальное время, в течение которого Cypress производит клики и взаимодействует с элементами.

  • Позволяет описать поведение для бизнес-логики. Эта возможность как раз соответствует нашим ценностям. Мы к этому шли.

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

Мы написали тесты, запустили их, но хоть всё и поднялось, мы столкнулись с рядом проблем:

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

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

Плагины для vs code оказались неудобными и медленными. Асинхронный синтаксис оказался своеобразным.

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

0641f551f483ccd5ba70fd64dc19c6c4.png

Мы рассмотрели эту ситуацию и пришли к выводу, что нужно пересмотреть наши подходы и ценности. И выбрали Playwright.  

Playwright: лучше, чем Cypress

Инструмент Playwright похож на Cypress, только новее, быстрее и круче. Предлагает множество преимуществ:

При запуске локально на компьютере с процессором M1 (не Intel), Playwright показывает реальное ускорение в 3–4 раза по сравнению с другими инструментами.

9de5b4f974519b4b31f146e605681595.jpg

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

b4a7e9095828dd091c5c3e04180e062c.png

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

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

471f4d921bb9188a4424a6b1a56afa59.png

Результаты этих тестов нужно было где-то хранить. Для такой задачи решили использовать Allure.

Allure для хранения результатов тестов

Какие возможности мы получили с Allure:

Allure собирает все результаты функциональных тестов в одном месте, что удобно для их анализа и сравнения. Вы можете комбинировать результаты своих функциональных тестов с E2E-тестами, проведёнными тестировщиками.

Команда QA, а также разработчики, могут легко проверить, какие сценарии уже покрыты тестами и где нужно что-то описать дополнительно. В будущем есть возможность мёржить покрытия между E2E-тестами тестировщиков и функциональными тестами.

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

f079d62e65fc959d0b3fdfb05ab1883e.jpg

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

Логи ошибок в Allure представлены в более понятном и читаемом формате по сравнению с логами Jenkins.

d7a8942c267a71361ac4b64d3bbe130e.png

Как вы понимаете, проблемы ещё остались, но не глобальные.

Мокинг серверных данных

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

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

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

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

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

Отказ от unit-тестов может повлечь за собой риск пробития SLO. Это набор метрик, которые определяют желаемое поведение вашего продукта. Допустим, у вас Grafana с «ёлкой», вы вывели туда графики, и ваше приложение должно рендериться минимум 10 раз в час. Если оно отрендерилось два раза в час в прайм-тайме, то эта штука сообщит вам. А ещё она может звонить ночью голосом Робо-бабы и мешать вам спать. Поэтому лучше для всех, чтобы SLO не пробивало, и следить сразу же при раскатке.

24fdafa853ef367cfd100c72cc1e7fb0.png

Планы на будущее

Что хотелось бы реализовать в перспективе и что мы ещё не успели сделать:

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

Удаление unit-тестов — не самая простая задача, потому что просто выпилить недостаточно. Нужно отслеживать параметры, понимать, где узкие места, чтобы не провалиться. К тому же у нас множество микрофронтов, около 200, но обкатали мы всего на 3–4 сервисах.  Поэтому работы будет много.  

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

Вы можете посмотреть выступление на Frontend Conf 2023:

Habrahabr.ru прочитано 3613 раз