EmeraldWeb

Тестирование Деталей Реализации

Mount Olympus, Thessaloniki, Greece, origin: https://www.celebritycruises.com/blog/most-beautiful-mountains-in-the-world

Ранее, когда я использовал enzyme (как и все в те времена), я был крайне осторожен с некоторыми API в нём. Я полностью избегал поверхностного рендера и никогда не использовал такие API как instance(), state() или find('ИмяКомпонента'). И во время проведения кодревью пулреквестов других людей, я объяснял снова и снова, почему так важно избегать эти API. Потому что каждый из них позволяет проверять детали реализации ваших компонентов. И люди часто спрашивают меня, что же я подразумеваю под "детали реализации". Я имею ввиду, что тестирование, самое по себе, это уже сложно! Тогда зачем же создавать все эти правила, чтобы только усложнить этот процесс?

Почему тестировать детали реализации это плохо?

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

  1. Могут упасть при рефакторинге кода приложения. Ложно отрицательные.
  2. Могут не упасть, когда ты сломал код приложения. Ложно положительные.

Проясним, тест - это "работает ли программа?".

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

Если тест не пройден, значит результат проверки "отрицательный" (программа не работает).

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

Давайте взглянем подробнее на каждый случай, на примере простого компонента "аккордеон":

// accordion.js
import * as React from 'react'
import AccordionContents from './accordion-contents'

class Accordion extends React.Component {
  state = {openIndex: 0}
  setOpenIndex = openIndex => this.setState({openIndex})
  render() {
    const {openIndex} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
            {index === openIndex ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

export default Accordion

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

И вот тест, проверяющий детали реализации:

// __tests__/accordion.enzyme.js
import * as React from 'react'
// if you're wondering why not shallow,
// then please read https://kcd.im/shallow
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'

// Setup enzyme's react adapter
Enzyme.configure({adapter: new EnzymeAdapter()})

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})

test('Accordion renders AccordionContents with the item contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }
  const wrapper = mount(<Accordion items={[hats, footware]} />)
  expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})

Поднимите руку, если вы видели (или писали) такие тесты как этот в своей кодовой базе (🙌)

Окей, сейчас давайте взглянем что с этими тестами не так...

Ложно отрицательные при рефакторинге

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

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

Допустим, мы работаем над добавлением способности одновременного открытия множества вкладок аккордеона, тогда заменим внутренний стейт компонента с openIndex на openIndexes:

class Accordion extends React.Component {
--- state = {openIndex: 0}
--- setOpenIndex = openIndex => this.setState({openIndex})
+++ state = {openIndexes: [0]}
+++ setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]})
  render() {
----- const {openIndex} = this.state
+++++ const {openIndexes} = this.state
    return (
      <div>
        {this.props.items.map((item, index) => (
          <>
            <button onClick={() => this.setOpenIndex(index)}>
              {item.title}
            </button>
----------- {index === openIndex ? (
+++++++++++ {openIndexes.includes(index) ? (
              <AccordionContents>{item.contents}</AccordionContents>
            ) : null}
          </>
        ))}
      </div>
    )
  }
}

Шикарно, мы делаем быструю проверку в приложении и всё работает как надо. Итак, когда мы позже вернемся к компоненту для поддержки открытия множества аккордеонов - это будет просто! Затем мы запускаем тесты и 💥ба-бах💥 они упали. Который именно упал? setOpenIndex sets the open index state properly

Что в сообщении об ошибке?

expect(received).toBe(expected)

Expected value to be (using ===):
  0
Received:
  undefined

Падение теста предупредило нас о реальной проблеме? Нет! Компонент все ещё отлично работает.

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

test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
- expect(wrapper.state('openIndex')).toEqual(0)
+ expect(wrapper.state('openIndexes')).toEqual([0])
  wrapper.instance().setOpenIndex(1)
- expect(wrapper.state('openIndex')).toEqual(1)
+ expect(wrapper.state('openIndexes')).toEqual([1])
})

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

Ложно положительные

Окей, допустим ваш сотрудник работает над Аккордионом и видит этот код:

<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>

Мгновенно, ему даёт о себе знать чувство преждевременной оптимизации и он говорит себе: "Эй! Стрелочные функции в render плохо влияют на производительность, надо исправить. Я думаю это должно сработать, быстро внесу правки и запущу тесты".

<button onClick={this.setOpenIndex}>{item.title}</button>

Клёво. Запускаю тесты и... ✅✅ шикарно! Он сделал коммит кода без проверки в браузере, ведь тесты придают нам уверенности, правда? Тот коммит идёт в абсолютно несвязанном пулреквесте вместе с правками на тысячи строк кода и, по понятным причинам, он останется незамеченным. Аккордион ломается в продакшене и у Саши не получается купить билеты на концерт группы КАЗУСКОМА в феврале. Саша плачет, а ваша команда чувствует себя ужасно.

Так что же пошло не так? Разве у нас не было теста, проверяющего изменение стейта компонента при вызове setOpenIndex и что содержимое аккордеона отображается должным образом?! Да, было! Но проблема в том, что не было теста, проверяющего корректное подключение кнопки к setOpenIndex.

Это называется ложно положительным результатом. То есть, мы должны были получить провал теста, но не получили! Как бы нам подстраховаться, чтобы быть уверенными, что такого больше не произойдёт? Необходимо добавить ещё один тест для проверки корректного обновления стейта при клике кнопки. И затем повысить минимальный порог покрытия тестами до 100%, чтобы мы не совершили это ошибку снова. Ох, и стоит добавить около дюжины ESLint плагинов, чтобы убедиться что люди не будут использовать эти API, поощряющие тестирование деталей реализации!

... Но я не буду заморачиваться... Фэээ, я так устал от всех этих ложно положительных и отрицательных, что лучше вообще не писать тесты. УДАЛИТЬ ВСЕ ТЕСТЫ! Было бы здорово, если бы у нас был инструмент с более широкой ямой успеха?
Да, было бы! И, представьте себе, у нас ЕСТЬ такой инструмент!

Тестирование без деталей реализации

Итак, мы могли бы переписать все эти тесты с enzyme, ограничивая себя API не имеющими доступа до деталей реализации, но вместо этого я просто собираюсь использовать React Testing Library, с ним будет очень сложно добавить детали реализации в мои тесты.
Давайте проверим это сейчас!

// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '../accordion'

test('can open accordion items to see the contents', () => {
  const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
  const footware = {
    title: 'Favorite Footware',
    contents: 'Flipflops are the best',
  }
  render(<Accordion items={[hats, footware]} />)

  expect(screen.getByText(hats.contents)).toBeInTheDocument()
  expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()

  userEvent.click(screen.getByText(footware.title))

  expect(screen.getByText(footware.contents)).toBeInTheDocument()
  expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})

Отлично! Единственный тест, надежно проверяющий всё поведение компонента. И он будет пройден независимо от названия стейта, будь тот openIndex, openIndexes или tacosAreTasty 🌮. Прекрасно! Избавились от ложно отрицательного результата! И если я неправильно подключу обработчик клика, то тест упадёт. Отлично, избавились и от ложно позитивного результата! И мне не пришлось запоминать каких либо списков правил. Я просто использовал инструмент идиоматически и получил тест, дающий мне уверенность, что мой аккордеон работает, как пользователь того хочет.

Итак... Тогда что же такое детали реализации?

Детали реализации это то, что пользователь, обычно, не использует, не видит и даже не знает о них.

Первый же вопрос на который нам нужен ответ: "Кто пользователь этого кода?" Ну, конечный пользователь, взаимодействующий с нашим компонентом в браузере - это определенно пользователь кода. Он будет наблюдать и взаимодействовать с отображаемыми кнопками и прочим содержимым. Но у нас также есть разработчик, который будет рендерить аккордеон с пропсами (в нашем случае, переданный список предметов). У Реакт компонентов, обычно, два пользователя: конечный пользователь и разработчик. Конечные пользователи и разработчики — это два «пользователя», которых необходимо учитывать в коде нашего приложения.

Здорово, так какие части нашего кода каждый из этих пользователей использует, видит и знает о них? Конечный пользователь увидит/провзаимодействует с тем, что мы отображаем в методе render. Разработчик увидит/провзаимодействует с пропсами, что передаются в компонент. Значит наши тесты должны, обычно, только видеть/взаимодействовать с переданными пропсами и результатом рендера.

Это в точности то, что делает тест React Testing Library. Мы передаем ему наш Реакт элемент из Аккордион компонента с поддельными пропсами, затем взаимодействуем с результатом рендера, запрашивая результат содержимого, который должен быть отображен для пользователя (или гарантируем, что он не будет отображен) и кликаем на отрендеренную кнопку.

Теперь рассмотрим тест enzyme. С enzyme мы получаем доступ до стейта openIndex.
Это не то что бы напрямую интересует кого-либо из наших пользователей. Они не знают этого названия, они не знают хранится ли openIndex как примитив или как массив, им всё равно, откровенно говоря. Их также, особенно, не интересует метод setOpenIndex. И всё же, наш тест знает обе эти детали реализации.

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

"Чем больше ваши тесты воссоздают путь использования вашего приложения, тем больше уверенности они могут дать вам - Kent C. Dodds."

А что насчёт хуков?

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

А что с React Testing Library? Работает в любом случае. Проверьте в песочнице, чтобы увидеть в действии. Тесты, написанные вами с React Testing Library, мне нравится называть:

Свободные от деталей реализации и дружелюбные для рефакторинга.

Mountain goat, origin: https://www.nationalgeographic.com/animals/mammals/facts/mountain-goat

Заключение

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

  1. Какая часть вашей непротестированной кодовой базы критична к поломке? (например, процесс оформления заказа)
  2. Попробуйте уменьшить масштаб тестирования до одного или нескольких юнит тестов. (например, при клике кнопки "оформить", запрос с корзиной заказа отсылается на /checkout)
  3. Посмотрите на код и решите кто тут "пользователь" (например, разработчик рендарящий форму оформления заказа и/или конечный пользователь кликающий на кнопку)
  4. Запишите список инструкций для этого пользователя, вручную проверяющий тот код, чтобы убедиться что код не сломан. (например, рендер формы с поддельными данными в корзине заказа, клик по кнопке оформления, затем убедиться что болванка /checkout API была вызвана с правильными данными и отреагировала поддельным успешным ответом, затем убедиться что сообщение об успехе оформления отображено)
  5. Преобразовать этот список инструкций в автоматизированные тесты.

Надеюсь, статья оказалась полезной для вас! Если вы по-настоящему хотите вывести ваше тестирование на новый уровень, тогда я определенно рекомендую вам получить про-лицензию на TestingJavaScript.com🏆

Удачи!

P.S. Если хочется поиграться вот песочница.

P.S.P.S. Упражнение для вас... Что случится со вторым тестом enzyme, если изменить название компонента AccordionContents?