- BrainTools - https://www.braintools.ru -

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации

Что должен делать разработчик, чтобы проект, над которым он работает, не имел проблем? Очевидно — нужно просто исправить все баги и больше не писать новых. 

Хорошая попытка, но в реальности и для существующего сервиса скорее всего потребуется ещё несколько шагов, чтобы радикальн�� уменьшить количество открытых багов. В том числе нелюбимое многими разработчиками — начать писать тесты. Зачем этим должны заниматься сами программисты, почему нельзя всё переложить на AI, с чего начать и каким принципам следовать, расскажу в статье.   

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 1

Меня зовут Руслан Мирзоев, я веб-разработчик в команде RUTUBE TECH, работаю над онлайн-кинотеатром PREMIER. Однако здесь я хочу рассказать о юнит-тестировании, потому что с одной стороны в своей работе убедился, насколько оно бывает полезно, а с другой — знаю, что даже у опытных разработчиков возникают сложности с написанием тестов.

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 2

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

  • юнит-тесты редко встречаются на практике во фронтенде, в бэкенд-репозиториях их обычно больше; 

  • недостаточно практической информации, так как, например, в документации Vitest, Jest, Vue Test Utils и других фреймворков и инструментов содержится в основном теория, но нет применимых best practices;

  • бизнес хочет фичи и бывает тяжело оправдать трудозатраты на написание тестов.

В этой статье попробуем разобраться с этими проблемами, особенно второй. 

Контекст PREMIER

Чтобы примеры были понятными и наглядными, рассмотрим реальную ситуацию онлайн-кинотеатра PREMIER. Это платформа, на которой есть фильмы, сериалы, шоу, ТВ-каналы, спортивные трансляции, короткие ролики Prems — много всего. К тому же сервис основан в 2018 году, проекту уже 7 лет, кодовая база достаточно объемная и, хотя за это время мы проводили масштабный рефакторинг [1] и держим фронтенд-архитектуру в порядке, мы вышли на плато по количеству открытых проблем. То есть получалось, что за месяц мы, к примеру, фиксим 50 багов, но и добавляем 50 новых. 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 3

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

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 4

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

  • Увеличить количество ревьюеров — если код будет проверять, например, не меньше четырёх специалистов, то он должен стать лучше. 

  • Повысить качество ревью — любыми способами: от политик до порогов на метрики ревью. 

  • Внедрить чек-листы для тестировщиков — чтобы обязательно проверять все платформы, разрешения, варианты дизайнов и т.д. 

  • Писать тесты — на этом и сосредоточимся в статье: что тестировать, что не тестировать, как и при чём тут разработчики. 

Пример чек-листа тестирования

Пример чек-листа тестирования

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

Юнит-тесты разумнее писать самому разработчику. Логика [2] такая: берёшь задачу из таск-трекера; чтобы реализовать, делишь на логические блоки и подмодули; реализуешь и проверяешь, что результат соответствует требованиям; попутно тестируешь, что всё верно взаимодействует — тем более файлы спецификации под рукой. 

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

Что тестировать, а что нет

Чаще всего то, что мы тестируем, относится к одной из трёх основных групп. 

  • HTML-разметка: текст, какой надо и где надо; правильные теги; отображение изображений и т.д. 

  • Внешние вызовы (API, Store, Composables). 

  • Пользовательские события — условно клик и событие после. 

Но не надо тестировать библиотеку. Не надо проверять, как фреймворк — Vue, React или любая другая используемая библиотека — выполнит свою часть работы. Мы выбрали инструмент и рассчитываем, что если подали правильный вход, то получим правильный выход. 

Пример 1. Тестирование кнопки

Кнопки есть везде, на любом сайте. Ниже на скрине «Сохранить» и «Возобновить подписку» — обычные кнопки, у которых есть различные props: текст, tabindex, флаг, можно ли на эту кнопку вообще нажать, и иконка.

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 6

Зафиксируем требования, которые далее будем валидировать с помощью тестов. Например, зададим самим себе следующие требования для проверки: по умолчанию размер кнопки medium; кнопка не совершает emit, когда заблокирована (у нас Vue.js — поэтому emit, для React это было бы onСlick). Каждое требование сопровождаем тестом и, поскольку это требования для разработки, то сами их и валидируем. 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 7

Исходный код разметки: 

<template>
  <component
    :is="tag"
    class="a-button"
    :class="[buttonTypeClass, buttonSizeClass, buttonIconPositionClass, buttonClasses]"
    :disabled="loading || disabled"
    :tabindex="tabindex"
    :aria-label="text"
    v-bind="$attrs"
    :type="(tag === 'button' && type) || null"
  >
    // вместо v-if в React было бы &&
    <template v-if="!loading">
      <a-icon v-if="icon" class="a-button__icon" :name="icon" />
      <span v-if="text" class="a-button__label">{{ text }}</span>
    </template>

    <template v-if="loading">
      <a-icon class="a-button__loading a-button__icon" :name="EIcon.MonoNavigationLoader" />
    </template>

    <template v-if="subtitle">
      <span class="a-button__subtitle font-caption-s">{{ subtitle }}</span>
    </template>
  </component>
</template>
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 8 [3]

Исходный код стилей:

const buttonTypeClass = computed<TClassValue>(() => setAtomClassByProp(props.variant, buttonVariants.types, 'a-button'))

const buttonSizeClass = computed<TClassValue>(() => setAtomClassByProp(props.size, buttonVariants.sizes, 'a-button'))

const buttonIconPositionClass = computed<TClassValue>(() =>
  setAtomClassByProp(props.iconPosition, buttonVariants.iconPositions, 'a-button'),
)

const buttonClasses = computed<TClassValue>(() => ({
  'a-button': true,
  'a-button--only-icon': !props.text,
  'a-button--disabled': props.disabled,
  'a-button--mobile': props.mobileView,
  'a-button--full-width': props.isFullWidth,
  'a-button--collapsed-text': props.collapsedText,
  'a-button--force-hover': props.forceHover,
}))
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 9 [3]

Каким тут может быть самый первый тест? Замаунтим компонент и проверим, что он существует.  

it('AButton отображается', () => {
  const wrapper = mount(AButton)

  expect(wrapper.exists()).toBeTruthy()
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 10 [3]

Далее, начинаем тестировать что-то похитрее — динамический класс: 

it('По умолчанию внешний вид кнопки использует вариант primary', () => {
  const wrapper = mount(AButton)

  expect(wrapper.attributes().class).contains('a-button--primary')
})

it('Когда кнопка использует вариант secondary', () => {})

it('Когда кнопка использует вариант colorful', () => {})

it('Когда кнопка использует вариант sberpay', () => {})

it('Когда кнопка использует вариант sbp', () => {})

it('По умолчанию размер кнопки - medium', () => {})

it('Когда установили размер кнопки - large', () => {})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 11 [3]

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

По аналогии проверяем, заблокирована кнопка или нет: триггерим клик и проверяем, что он не смог отработать, если кнопка заблокирована. 

it('Кнопка не совершает emit, когда заблокирована', async () => {
  const wrapper = mount(AButton, {
    props: {
      disabled: true,
    },
  })

  await wrapper.trigger('click')

  expect(wrapper.emitted()).not.toHaveProperty('click')
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 12 [3]

Этот пример хорошо иллюстрирует паттерн AAA — Arrange, Act, Assert. Сначала создаем данные для теста, затем ключевое действие, а потом сравнение. Если порядок другой, то, возможно, с тестом что-то не так.

Тестирование наличия иконки: замаунтили, нашли иконку и проверили, что есть нужный класс. 

it('У кнопки отображается иконка по умолчанию слева', () => {
  const wrapper = mount(AButton, {
    props: {
      icon: 'card',
    },
  })

  const icon = wrapper.find('.a-icons')

  expect.soft(wrapper.attributes().class).contains('a-button--left')
  expect.soft(icon.exists()).toBeTruthy()
  expect.soft(icon.attributes().class).contains('icon-card')
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 13 [3]

Аналогично тестируем текст: 

it('Отображается текст у кнопки', () => {
  const wrapper = mount(AButton, {
    props: {
      text: 'Кнопка',
    },
  })

  const label = wrapper.find('span')

  expect(label.exists()).toBeTruthy()
  expect(label.text()).equals('Кнопка')
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 14 [3]

Обратите внимание [4], как выше сделан поиск элемента span. В документации можно встретить много разных способов, как правильно найти элемент: по тексту (например, «Сохранить» — менее релевантно для мультиязычных интерфейсов), по тегу (например, button), по компоненту, по data_testid, по ref, по классу, по id. Этот список я упорядочил по приоритету и выделил жирным наиболее правильные — потому что нужно искать элемент именно так, как его нашел бы пользователь. 

В тесте текста я ищу элемент по тегу span. Тут может появиться вопрос, а что если мы поменяем тег на button, тест же упадет? Да, так и должно быть — поменялась семантика, тест стал неактуален. Если нельзя найти по тексту, тегу или компоненту, то можно создать data_testid и искать по нему. Но не стоит искать элемент по классу или id. 

Пример 2. Тестирование виджета

От одного простого элемента — кнопки — сделаем большой шаг и посмотрим, как тестировать сложный виджет.

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 15

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

Очень много всего — значит, нужно разбить на этапы и тестировать поэтапно. 

Тестирование скелетона

Начнем с разметки. Вот её код: 

<template>
  <template
    v-if="
      !loading[ELoadingInstances.Subscriptions] &&
      !loading[ELoadingInstances.Products] &&
      !loading[ELoadingInstances.Pages]
    "
  >
    ...
  </template>
  <su-subscriptions-skeleton v-else class="w-subscriptions__skeleton" />
</template>
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 16 [3]

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

describe('Корректно показываются скелетоны в зависимости от разных условий', () => {
  const testCases = [
    {
      description: 'Рендерит скелетон пока идет загрузка подписок',
      loadingInstance: ELoadingInstances.Subscriptions,
    },
    {
      description: 'Рендерит скелетон пока идет загрузка продуктов',
      loadingInstance: ELoadingInstances.Products,
    },
    {
      description: 'Рендерит скелетон пока идет загрузка страниц',
      loadingInstance: ELoadingInstances.Pages,
    },
    {
      description: 'Рендерит скелетон пока идет загрузка всех элементов',
      loadingInstance: {
        [ELoadingInstances.Subscriptions]: true,
        [ELoadingInstances.Products]: true,
        [ELoadingInstances.Pages]: true,
      },
    },
  ]
  
  it.each(testCases)('$description', async ({ loadingInstance }) => {
    const wrapper = await mountSuspended(WSubscriptions, {
      global: {
        plugins: [
          createTestingPinia({
            initialState: {
              loading: {
                loading: typeof loadingInstance === 'object' ? loadingInstance : { [loadingInstance]: true },
              },
            },
          }),
        ],
      },
      props: getDefaultProps(),
    })

    const skeleton = wrapper.find('.w-subscriptions__skeleton')

    expect(skeleton.exists()).toBeTruthy()
  })

})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 17 [3]

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

Тестирование секции превью о тарифах 

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

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 18

У блока достаточно разветвленная логика, есть описание, логотипы, условия и так далее. Код выглядит следующим образом: 

<div v-if="isInfoVisible" class="w-subscriptions__product-info-wrap">
  <div
    class="w-subscriptions__product-info"
    data-qa-selector="product-info-card"
    data-test-id="product-info-card"
  >
    <div
      v-if="product?.icon && product.productCode !== PRODUCT_CODES.GAZPROM_BONUS"
      class="w-subscriptions__icon-wrap"
    >
      <img :src="product.icon" alt="" class="w-subscriptions__product-icon" />
    </div>
    <div
      v-if="product.productCode === PRODUCT_CODES.GAZPROM_BONUS"
      class="w-subscriptions__product-title font-h2"
      data-test-id="product-gazprom-title"
    >
      {{ product.description }}
    </div>
    <div
      v-if="product.description"
      data-test-id="product-description"
      class="w-subscriptions__product-description font-body"
      :class="{ 'w-subscriptions__product-description--sport': isSportTab }"
      data-qa-selector="product-info-description"
      v-html="
        product.productCode === PRODUCT_CODES.GAZPROM_BONUS ? product.shortDescription : product.description
      "
    />
    <img
      v-if="product?.subscriptionBackground && isSportTab
      :src="product.subscriptionBackground"
      alt=""
      class="w-subscriptions__product-logo"
    />
  </div>
</div>
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 19 [3]

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

describe('Корректно показывает информацию о продукте', () => {
  it('Показывает информацию, если подходит под список заранее объявленных путей', async () => {
    const { wrapper } = await mountComponent()

    const productInfo = wrapper.find(testSelector('product-info-card'))

    expect(productInfo.exists()).toBeTruthy()
  })

  it('Не показывает информацию, если не подходит под список заранее объявленных путей', async () => {
    const { wrapper } = await mountComponent({
      _initialState: {
        subscriptions: {
          subscriptions: [],
          products: [],
        },
      },
      _props: {
        currentSlug: 'Не существующий slug',
      },
    })

    const productInfo = wrapper.find(testSelector('product-info-card'))

    expect(productInfo.exists()).toBeFalsy()
  })

})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 20 [3]

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

Работа с моками

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

Вот как, например, выглядит использование моков из Vitest: vi.fn() мокает функцию-болванку, которая ничего не делает.

vi.mock('lib/composables/useOffers', () => ({
  default: vi.fn().mockReturnValue({
    showTariff: vi.fn(),
  }),
}))

it('Идет запрос на получение информации о тарифе', async () => {
    const { pinia, wrapper } = await mountComponent()
    const tariffCard = wrapper.findComponent({ name: 'e-tariff-card' })

    await tariffCard.vm.$emit('subscribe')
    await wrapper.vm.$nextTick()

    expect(useOffers().showTariff).toHaveBeenCalledWith(getTariff())
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 21 [3]

Обратите внимание, здесь проверка не на вызов toHaveBeenCalled, а проверка toHaveBeenCalledWith(getTariff()).

Честность и антихрупкость 

В нашем контексте принципы честности можно сформулировать очень просто: 

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

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

Честность

Разберём принцип честности на примере:

const { pinia, wrapper } = await mountComponent({
          _initialState: {
            subscriptions: {
              subscriptions: [],
              products: [getProductItem()],
            },
            account: {
              isUserAuthenticated: true,
            },
          },
          _global: {
            stubs: {
              MModal: true,
            },
          },
          _props: {
            currentSlug: EProductSlug.Start,
            product: getProductItem(),
            subscriptions: [],
            tariffs: [
              {
                info: getTariff(),
                productItem: getProductItem(),
              },
            ],
          },
        })
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 22 [3]

Здесь stubs на компонент модального окна. Почему? Потому что может возникнуть ситуация, что при тестировании виджета оно не откроется и тест упадёт. Но ведь это проблема не виджета, а модалки, и тест не должен падать. Именно поэтому мы стабаем компоненты. 

Также можно использовать shallowMount — это означает, что автоматом все вложенные компоненты будут застабаны: 

it('Когда кнопка использует вариант colorful', () => {
  const wrapper = shallowMount(AButton, {
    props: {
      variant: 'colorful',
    },
  })

  expect(wrapper.attributes().class).contains('a-button--colorful')

})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 23 [3]

Антихрупность

С точки зрения [5] антихрупкости правильно, тестируя компонент, относиться к нему как к черному ящику. Не лезть внутрь компонента, в тесте не опираться на то, что находится внутри, не менять переменные и т.д. 

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

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 24

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

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

it('После нажатия на кнопку сброса фильтров она становится недоступна', async () => {
    const filter = ref({
      types: ['movies'],
      genres: [],
      countries: [],
      years: [],
      plots: ['pro-vrachi'],
      additionalQueries: {},
      orderBy: 'new',
    })

    const wrapper = shallowMount(EContentFilters)

    await wrapper.vm.$nextTick()

    wrapper.vm.isMobileFiltersOpen = true

    await wrapper.vm.$nextTick()

    const resetButton = wrapper.find('[data-test-id="resetFiltersBtn"]')

    await expect(resetButton.attributes('disabled')).not.toBeFalsy()

    await resetButton.trigger('click')

    await expect(resetButton.attributes('disabled')).toBeTruthy()
  })
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 25 [3]

Правильнее было бы сделать следующим образом:

it('Кнопка сброса фильтров становится недоступна после нажатия', async () => {

  const wrapper = shallowMount(EContentFilters);

  // Открываем мобильные фильтры через публичный метод или пропс (если это необходимо)

  // Например, если компонент поддерживает метод openMobileFilters:

  // await wrapper.setProps({ isOpen: true });
  // await nextTick()

  // Находим кнопку сброса фильтров

  const resetButton = wrapper.find('[data-test-id="resetFiltersBtn"]');

  // Проверяем, что кнопка изначально доступна

  expect(resetButton.attributes('disabled')).toBeUndefined();

  // Эмулируем нажатие на кнопку сброса

  await resetButton.trigger('click');

  // Проверяем, что кнопка стала недоступна после нажатия

  expect(resetButton.attributes('disabled')).toBeDefined();
});
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 26 [3]

Работа с асинхронностью 

Работу с асинхронностью коротко можно охарактеризовать так: если мы делаем какой-либо триггер, например, клик на что-то, то после этого даем UI перерисоваться, делая nextTick.

it('После формы регистрации перезапрашиваются данные о тарифах и пользователе', async () => {

  const subscriptionStore = useSubscriptionsStore(pinia)

  vi.mocked(subscriptionStore.getSubscriptionsByProductCode).mockImplementation(() => [])

  const tariffCard = wrapper.findComponent({ name: 'e-tariff-card' })

  await tariffCard.trigger('click')

  expect.soft(useAuthFlow).toHaveBeenCalledWith({ skipEmail: true, skipSetPinCode: true })

  expect.soft(refreshSubscriptionsCallbackFn).toHaveBeenCalled()

  await wrapper.vm.$nextTick()

  expect.soft(getProductList).toHaveBeenCalled()

  await wrapper.vm.$nextTick()

  expect.soft(getProfileConfig).toHaveBeenCalled()

})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 27 [3]

При этом обратите внимание на прошлый пример: там есть expect.soft, который позволяет реализовать Soft Assertion. Идея состоит в том, чтобы выполнять весь тест, даже если по дороге что-то пошло не так. 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 28

В примере слева, если авторизация упадёт, то упадёт весь тест и мы не узнаем, что там дальше, допустим, с refreshSubscriptions. Справа же прогонится вся последовательность экспектов и у нас будет больше информации, мы сможем быстрее локализовать проблемы. 

Работа с хранилищами

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

Мы, тестируя компоненты, в которых есть зависимости от strore, используем плагин @pinia/testing. С ним доступен метод createTestingPinia, который возвращает инстантс Pinia, в который можно передать начальное состояние и привести компонент в нужное для тестирования кейса состояние. Также мы можем напрямую перезаписывать значения в сторах и проверять определённые сценарии.

const mountComponent = async ({ initialState = {}, global = {}, _props = {} } = {}) => {
  const refreshSubscriptionsCallbackFn = vi.fn()
  const pinia = createTestingPinia({
    initialState: {
      loading: {
        loading: {
          [ELoadingInstances.Subscriptions]: false,
          [ELoadingInstances.Products]: false,
          [ELoadingInstances.Pages]: false,
        },
      },
      ..._initialState,
    },
  })

  const wrapper = await mountSuspended(WSubscriptions, {
    global: {
      directives: {
        observable,
      },
      plugins: [pinia],
      ..._global,
    },

    props: { ...getDefaultProps(refreshSubscriptionsCallbackFn), ..._props },

  })

  return { wrapper, pinia, refreshSubscriptionsCallbackFn }
}
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 29 [3]

А затем, при необходимости, можно замокать экшены и т.п.: 

const subscriptionStore = useSubscriptionsStore(pinia)

vi.mocked(subscriptionStore.getSubscriptionsByProductCode).mockImplementation(() => [getGazpromProductItem()])

vi.mocked(subscriptionStore.getProductByProductCode).mockImplementation(() => getGazpromProductItem())
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 30 [3]

Взаимодействие с внутренними компонентами

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

Первый вариант — с помощью контракта: 

<template #slides>
    <e-tariff-card
        v-for="({ info, subscription, productItem }, idx) in tariffs"
        :key="info?.tariffId || idx"
        @subscribe="
        onSubscribe({
            subscription,
            tariff: info,
            buttonText: $event,
            subscriptions,
            tariffWithTypeFree,
            tariffs,
            product: productItem,
        })
        "
    />
</template>
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 31 [3]

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

it('Идет запрос на получение информации о тарифе', async () => {
   const { pinia, wrapper } = await mountComponent()

   const tariffCard = wrapper.findComponent({ name: 'e-tariff-card' })

   await tariffCard.vm.$emit('subscribe')

 })
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 32 [3]

Почему DRY в тестах не релевантен

В программировании есть принцип DRY — это don’t repeat yourself, которому, говорят, очень важно следовать. Однако в тестах, на мой взгляд, его можно и нужно нарушать. Потому что, когда в тесте вызывается 100500 функций, это сложно читать и вообще-то сложно писать. А чтобы больше разработчиков писали тесты и делали это чаще, лучше, чтобы писать их было легко.

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

  • писать код вместо документации (ведь никто не любит писать документацию);

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

Почему не стоит злоупотреблять AI-инструментами 

Основная цель юнит-тестирования — валидация бизнес-требований. Однако AI генерирует тесты, исходя из прикладного входа, который ему был предоставлен. То есть без вашего участия AI не узнает и не проверит все бизнес-сценарии. А также по опыту [6] могу сказать, что LLM генерируют в основном позитивные тесты и очень редко тестируют негативные сценарии, которые, как мы обсуждали выше, тоже нужны и важны. 

Поэтому, на мой взгляд, правильный сценарий использования AI-помощников в юнит-тестировании такой: 

  • сгенерировали тесты, прогнали; 

  • своим, а не искусственным интеллектом [7] посмотрели бизнес-требования — какие учтены, какие нет;

  • снова закинули в AI;

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

Что посмотреть для производительности

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

  • isolate — отключение изоляции:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
	isolate: false,
  },
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 33 [3]
  • test.concurrent — параллелизм в тестах. По умолчанию внутри одного теста проверки проходят последовательно, что можно ускорить с помощью одновременного исполнения:

test.concurrent('the first test', () => {
  expect(1).toBe(1)
})

test.concurrent('the second test', () => {
  expect(2).toBe(2)
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 34 [3]
  • shards — шардирование. Если тесты запускаются в GitLab CI, их можно распараллелить на несколько раннеров, разбив на шарды (shards). Это ускоряет выполнение, но в конце требуется объединить результаты — например, отчеты покрытия кода (cobertura).

Пример: допустим, у вас 100 тестов и 4 раннера. Тесты делятся на 4 группы (шарда) по 25 тестов. Каждый раннер выполняет свой шард, а после завершения отчеты покрытия объединяются в один общий.

Сложности:

  • нужно корректно объединить отчеты (например, с помощью cobertura-merge или аналогичных инструментов);

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

vitest run --reporter=blob --shard=1/3

vitest run --reporter=blob --shard=2/3

vitest run --reporter=blob --shard=3/3

vitest run --merge-reports
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 35 [3]
  • cache — кеширование:

import { defineConfig } from 'vitest/config'

export default defineConfig({
  cacheDir: 'custom-folder/.vitest'
})
Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 36 [3]
  • Профайлинг — всё то же самое, что и с остальным кодом. Для тестов можно запустить профайлинг, посмотреть, сколько времени они занимают и уделить внимание скорости, где это необходимо. Например, если тест проходит дольше 500 мс, то для меня это повод проверить его и постараться ускорить. 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 37
  • Мутационное тестирование. Помогает обнаружить слабые или неполные тесты, то есть проверяет качество самих тестов. Суть метода в том, чтобы вносить небольшие случайные изменения в исходный код и следить, замечают ли тесты эти изменения. Если тесты продолжают проходить успешно, значит, они либо недостаточно эффективны, либо не покрывают все важные сценарии. Подробнее можно посмотреть в библиотеке stryker.js [8].

А ещё очевидная, казалось, бы вещь, которая при этом может сильно ускорить прогоны в CI — это использование фейковых таймеров useFakeTimers. Все реальные таймеры, которые есть в приложении, в тестах можно заменить на фейковые. Потому что зачем ждать секунду, если можно не ждать. 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 38

Результат внедрения юнит-тестирования

Что же, помогло ли нам тестирование? Насколько мы смогли с его помощью уменьшить количество проблем сервиса? 

Юнит-тестирование для веб-разработчиков: концепции и аспекты, которых не найти в документации - 39

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

Итого: как покрыть систему тестами

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

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

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

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

  • Запускать все тесты локально (git hooks) и в CI — без этого, мне кажется, вся затея вообще не имеет смысла. Наша задача максимально быстро локализовать проблему и без удобно настроенного окружения этого не сделать. 

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

  • Писать тесты при рефакторинге старого кода — раз уж рефакторим код, то сразу и покроем его тестами. Если тесты на модуль уже были написаны, то, если они соответствовали принципу честности, то они должны остаться зелеными. Также при рефакторинге можно попробовать применить TDD: описать бизнес-требования и user story, написать тесты, а уже потом код. 

  • Тесты на уже написанную функциональность всё равно придется делать — их можно отнести к внутреннему техдолгу. 

При этом, естественно, бывают ситуации, когда тесты не нужны и выделять на них ресурсы преждевременно. Например, стартап на стадии pivot/preseed ещё не генерирует выручки, чтобы инвестировать в качество,  и вообще имеет все шансы не выжить. Так же как и для вариантов A/B-тестирования писать тесты может быть избыточно, ведь какие-то из вариантов заведомо будут отвергнуты. Тестирование — не серебряная пуля, а один из возможных инструментов обеспечения качества. 

Подписывайтесь на этот блог и канал Смотри за IT [9], если хотите знать больше о создании медиасервисов. Там рассказываем об инженерных тонкостях и продуктовых находках, делимся видео выступлений и кадрами из жизни команд Цифровых активов «Газпром-Медиа Холдинга» таких, как RUTUBE, PREMIER, Yappy.

А для погружения в тему тестирования для веб-разработчика можно посмотреть эти видео: 

Автор: RuSaG0

Источник [13]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/25236

URLs in this post:

[1] проводили масштабный рефакторинг: https://habr.com/ru/companies/habr_rutube/articles/914942/

[2] Логика: http://www.braintools.ru/article/7640

[3] Image: https://sourcecraft.dev/

[4] внимание: http://www.braintools.ru/article/7595

[5] зрения: http://www.braintools.ru/article/6238

[6] опыту: http://www.braintools.ru/article/6952

[7] интеллектом: http://www.braintools.ru/article/7605

[8] stryker.js: https://github.com/stryker-mutator/stryker-js

[9] Смотри за IT: https://t.me/+4auISiahMm9jMGRi

[10] «Тестирование JavaScript от А до Я (Jest, React Testing Library, e2e, screenshot)»: https://www.youtube.com/watch?v=y2emL1fMRyY

[11] «Принципы тестирования frontend приложений»: https://youtu.be/zu7hEgeCmr4?si=WgkrMscT6_YglBY1

[12] «Как сделать автотесты полезными. Эволюция автотестов в Яндекс ID»: https://holyjs.ru/archive/2024%20Autumn/talks/9b9deb53a7ed43afad74c10f94ab3768/

[13] Источник: https://habr.com/ru/companies/habr_rutube/articles/991330/?utm_source=habrahabr&utm_medium=rss&utm_campaign=991330

www.BrainTools.ru

Rambler's Top100