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

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

Веб-разработчики часто не любят, не хотят и не умеют писать тесты, потому что:
-
юнит-тесты редко встречаются на практике во фронтенде, в бэкенд-репозиториях их обычно больше;
-
недостаточно практической информации, так как, например, в документации Vitest, Jest, Vue Test Utils и других фреймворков и инструментов содержится в основном теория, но нет применимых best practices;
-
бизнес хочет фичи и бывает тяжело оправдать трудозатраты на написание тестов.
В этой статье попробуем разобраться с этими проблемами, особенно второй.
Контекст PREMIER
Чтобы примеры были понятными и наглядными, рассмотрим реальную ситуацию онлайн-кинотеатра PREMIER. Это платформа, на которой есть фильмы, сериалы, шоу, ТВ-каналы, спортивные трансляции, короткие ролики Prems — много всего. К тому же сервис основан в 2018 году, проекту уже 7 лет, кодовая база достаточно объемная и, хотя за это время мы проводили масштабный рефакторинг и держим фронтенд-архитектуру в порядке, мы вышли на плато по количеству открытых проблем. То есть получалось, что за месяц мы, к примеру, фиксим 50 багов, но и добавляем 50 новых.

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

Что можно сделать, чтобы при увеличении кодовой базы создавать меньше проблем:
-
Увеличить количество ревьюеров — если код будет проверять, например, не меньше четырёх специалистов, то он должен стать лучше.
-
Повысить качество ревью — любыми способами: от политик до порогов на метрики ревью.
-
Внедрить чек-листы для тестировщиков — чтобы обязательно проверять все платформы, разрешения, варианты дизайнов и т.д.
-
Писать тесты — на этом и сосредоточимся в статье: что тестировать, что не тестировать, как и при чём тут разработчики.
Тестирование — объемная область, пирамида тестирования состоит из многих слоёв от юнит до e2e — чем выше, тем более дорогими и специфичными становятся тесты. Поэтому мы начнём с базового уровня, который при этом позволяет обеспечить очень существенный вклад в качество продукта, — юнит-тестирования.
Юнит-тесты разумнее писать самому разработчику. Логика такая: берёшь задачу из таск-трекера; чтобы реализовать, делишь на логические блоки и подмодули; реализуешь и проверяешь, что результат соответствует требованиям; попутно тестируешь, что всё верно взаимодействует — тем более файлы спецификации под рукой.
Далее на примерах разберём подходы, с помощью которых вы сможете разобраться в юнит-тестировании. Пойдем от простого к сложному: сначала посмотрим, как тестировать простую кнопку, и постепенно дойдём до огромного виджета подписок, попутно разбирая более продвинутые принципы.
Что тестировать, а что нет
Чаще всего то, что мы тестируем, относится к одной из трёх основных групп.
-
HTML-разметка: текст, какой надо и где надо; правильные теги; отображение изображений и т.д.
-
Внешние вызовы (API, Store, Composables).
-
Пользовательские события — условно клик и событие после.
Но не надо тестировать библиотеку. Не надо проверять, как фреймворк — Vue, React или любая другая используемая библиотека — выполнит свою часть работы. Мы выбрали инструмент и рассчитываем, что если подали правильный вход, то получим правильный выход.
Пример 1. Тестирование кнопки
Кнопки есть везде, на любом сайте. Ниже на скрине «Сохранить» и «Возобновить подписку» — обычные кнопки, у которых есть различные props: текст, tabindex, флаг, можно ли на эту кнопку вообще нажать, и иконка.

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

Исходный код разметки:
<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>
Исходный код стилей:
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,
}))
Каким тут может быть самый первый тест? Замаунтим компонент и проверим, что он существует.
it('AButton отображается', () => {
const wrapper = mount(AButton)
expect(wrapper.exists()).toBeTruthy()
})
Далее, начинаем тестировать что-то похитрее — динамический класс:
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', () => {})
Здесь мы замаунтили компонент, а затем по атрибуту проверяем, что нужный класс добавился. Мы проверяем не конкретный, например, цвет, а задание класса. И таких тестов у нас несколько.
По аналогии проверяем, заблокирована кнопка или нет: триггерим клик и проверяем, что он не смог отработать, если кнопка заблокирована.
it('Кнопка не совершает emit, когда заблокирована', async () => {
const wrapper = mount(AButton, {
props: {
disabled: true,
},
})
await wrapper.trigger('click')
expect(wrapper.emitted()).not.toHaveProperty('click')
})
Этот пример хорошо иллюстрирует паттерн 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')
})
Аналогично тестируем текст:
it('Отображается текст у кнопки', () => {
const wrapper = mount(AButton, {
props: {
text: 'Кнопка',
},
})
const label = wrapper.find('span')
expect(label.exists()).toBeTruthy()
expect(label.text()).equals('Кнопка')
})
Обратите внимание, как выше сделан поиск элемента span. В документации можно встретить много разных способов, как правильно найти элемент: по тексту (например, «Сохранить» — менее релевантно для мультиязычных интерфейсов), по тегу (например, button), по компоненту, по data_testid, по ref, по классу, по id. Этот список я упорядочил по приоритету и выделил жирным наиболее правильные — потому что нужно искать элемент именно так, как его нашел бы пользователь.
В тесте текста я ищу элемент по тегу span. Тут может появиться вопрос, а что если мы поменяем тег на button, тест же упадет? Да, так и должно быть — поменялась семантика, тест стал неактуален. Если нельзя найти по тексту, тегу или компоненту, то можно создать data_testid и искать по нему. Но не стоит искать элемент по классу или id.
Пример 2. Тестирование виджета
От одного простого элемента — кнопки — сделаем большой шаг и посмотрим, как тестировать сложный виджет.

Это всё один компонент, который может отображаться очень по-разному: в разделе с моими подписками три тарифные карточки и разные сценарии работы с подпиской; в разделе Спорт отображаются уже другие блоки, у них другое расположение, слайдеры, кнопки и т.д.
Очень много всего — значит, нужно разбить на этапы и тестировать поэтапно.
Тестирование скелетона
Начнем с разметки. Вот её код:
<template>
<template
v-if="
!loading[ELoadingInstances.Subscriptions] &&
!loading[ELoadingInstances.Products] &&
!loading[ELoadingInstances.Pages]
"
>
...
</template>
<su-subscriptions-skeleton v-else class="w-subscriptions__skeleton" />
</template>
Вся логика изначально обернута в скелетоны, поэтому нам надо протестировать загрузку всех необходимых блоков и продуктов и скелетонов для них. По сути дела, прогоняем все варианты загрузки данных для страницы:
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()
})
})
Обратите внимание на it.each, который в данном случае позволяет запускать один тест с разными наборами данных, сокращая дублирование кода и упрощая проверку функций на различных входных значениях. Я не пишу 4 разных теста, а заранее подготавливаю нужные мне данные и в одном тесте прогоняюсь по ним всем.
Тестирование секции превью о тарифах
Переходя от общего к частному, рассмотрим конкретный блок — блок тарифов в разделе спорт.

У блока достаточно разветвленная логика, есть описание, логотипы, условия и так далее. Код выглядит следующим образом:
<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>
Как это всё тестировать? Давайте проверим, что блок в принципе отобразился, и проверим два сценария — позитивный и негативный (под негативным сценарием имеется в виду не сломанное приложение, а невалидные данные и невыполнение каких-то условий).
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()
})
})
Здесь мы проверяем работу, когда нужно отрендерить блок, и когда этого не должно произойти. И это важный момент: если тестируете негативный сценарий, то одновременно с этим нужно протестировать и позитивный. Иначе можно, например, не обнаружить отсутствие кнопки, которая на самом деле должна быть, ведь негативный тест останется зелёным. Сочетание позитивных и негативных сценариев обеспечит гораздо лучшее покрытие.
Работа с моками
В юнит-тестировании в большинстве случаев удобнее мокать импортируемые модули. Часто не важно, что по итогу произошло, достаточно убедиться, что функция вызвалась и вызвалась с нужными параметрами.
Вот как, например, выглядит использование моков из 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())
})
Обратите внимание, здесь проверка не на вызов 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(),
},
],
},
})
Здесь stubs на компонент модального окна. Почему? Потому что может возникнуть ситуация, что при тестировании виджета оно не откроется и тест упадёт. Но ведь это проблема не виджета, а модалки, и тест не должен падать. Именно поэтому мы стабаем компоненты.
Также можно использовать shallowMount — это означает, что автоматом все вложенные компоненты будут застабаны:
it('Когда кнопка использует вариант colorful', () => {
const wrapper = shallowMount(AButton, {
props: {
variant: 'colorful',
},
})
expect(wrapper.attributes().class).contains('a-button--colorful')
})
Антихрупность
С точки зрения антихрупкости правильно, тестируя компонент, относиться к нему как к черному ящику. Не лезть внутрь компонента, в тесте не опираться на то, что находится внутри, не менять переменные и т.д.
Разберём подробнее этот принцип на примере хрупкого теста: представим, что есть онлайн-кинотеатр с фильтрами для выбора контента и что мы хотим проверить, как работает кнопка сброса фильтров.

Можно это сделать напрямую, меняя переменную в тесте. Но так делать нельзя: тестировать нужно методом черного ящика, то есть мы не должны знать содержание компонента внутри.
Проблемы могут быть следующие: если поменять название переменной внутри кода — код продолжит работать, а тест упадёт.
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()
})
Правильнее было бы сделать следующим образом:
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();
});
Работа с асинхронностью
Работу с асинхронностью коротко можно охарактеризовать так: если мы делаем какой-либо триггер, например, клик на что-то, то после этого даем 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()
})
При этом обратите внимание на прошлый пример: там есть expect.soft, который позволяет реализовать Soft Assertion. Идея состоит в том, чтобы выполнять весь тест, даже если по дороге что-то пошло не так.

В примере слева, если авторизация упадёт, то упадёт весь тест и мы не узнаем, что там дальше, допустим, с 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 }
}
А затем, при необходимости, можно замокать экшены и т.п.:
const subscriptionStore = useSubscriptionsStore(pinia)
vi.mocked(subscriptionStore.getSubscriptionsByProductCode).mockImplementation(() => [getGazpromProductItem()])
vi.mocked(subscriptionStore.getProductByProductCode).mockImplementation(() => getGazpromProductItem())
Взаимодействие с внутренними компонентами
Представьте, есть виджет подписок и тарифные карточки, например, с тремя тарифами разной длительности. Как нам протестировать сценарий, как будто пользователь кликнул на одну из карточек?
Первый вариант — с помощью контракта:
<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>
Второй вариант — напрямую сделать клик, то есть сымитировать клик пользователя. Но с точки зрения упомянутого принципа честности это не лучший способ, потому что, если внутри карточки тарифа что-то сломается, то сломается и наш тест и при этом не покажет корректность вызова. Поэтому делаем не клик, а вызываем emit:
it('Идет запрос на получение информации о тарифе', async () => {
const { pinia, wrapper } = await mountComponent()
const tariffCard = wrapper.findComponent({ name: 'e-tariff-card' })
await tariffCard.vm.$emit('subscribe')
})
Почему DRY в тестах не релевантен
В программировании есть принцип DRY — это don’t repeat yourself, которому, говорят, очень важно следовать. Однако в тестах, на мой взгляд, его можно и нужно нарушать. Потому что, когда в тесте вызывается 100500 функций, это сложно читать и вообще-то сложно писать. А чтобы больше разработчиков писали тесты и делали это чаще, лучше, чтобы писать их было легко.
Портянки кода в тестах — это нормально. Потому что в тесте главное — это понять, работает или не работает код, и сделать это быстро. Развернутый тест позволяет нам:
-
писать код вместо документации (ведь никто не любит писать документацию);
-
быть уверенным, что, когда тест окажется красным, это будет значить, что сломался конкретный модуль и не нужно прыгать по 10 файлам и долго расследовать корень проблемы.
Почему не стоит злоупотреблять AI-инструментами
Основная цель юнит-тестирования — валидация бизнес-требований. Однако AI генерирует тесты, исходя из прикладного входа, который ему был предоставлен. То есть без вашего участия AI не узнает и не проверит все бизнес-сценарии. А также по опыту могу сказать, что LLM генерируют в основном позитивные тесты и очень редко тестируют негативные сценарии, которые, как мы обсуждали выше, тоже нужны и важны.
Поэтому, на мой взгляд, правильный сценарий использования AI-помощников в юнит-тестировании такой:
-
сгенерировали тесты, прогнали;
-
своим, а не искусственным интеллектом посмотрели бизнес-требования — какие учтены, какие нет;
-
снова закинули в AI;
-
при этом используя принципы честности и антихрупкость и имея в виду советы из этой статьи, что тестировать, что не тестировать и т.д.
Что посмотреть для производительности
Performance в тестировании, конечно, тоже важен. Нельзя обойти эту тему стороной, но для глубокого погружения нужна отдельная статья, поэтому здесь перечислю основные ключевые слова и направления, которые могут помочь улучшить производительность тестирования.
-
isolate — отключение изоляции:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
isolate: false,
},
})
-
test.concurrent — параллелизм в тестах. По умолчанию внутри одного теста проверки проходят последовательно, что можно ускорить с помощью одновременного исполнения:
test.concurrent('the first test', () => {
expect(1).toBe(1)
})
test.concurrent('the second test', () => {
expect(2).toBe(2)
})
-
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
-
cache — кеширование:
import { defineConfig } from 'vitest/config'
export default defineConfig({
cacheDir: 'custom-folder/.vitest'
})
-
Профайлинг — всё то же самое, что и с остальным кодом. Для тестов можно запустить профайлинг, посмотреть, сколько времени они занимают и уделить внимание скорости, где это необходимо. Например, если тест проходит дольше 500 мс, то для меня это повод проверить его и постараться ускорить.

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

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

На графике суммарный эффект от внедрения более тщательного ревью и тестов. Это только начало, но прогресс уже очевиден, и мы продолжаем увеличивать тестовое покрытие.
Итого: как покрыть систему тестами
С чего начать, чтобы внедрить в свой проект юнит-тестирование, сделать эта практику регулярной и в результате обеспечить хороший процент покрытия тестами, который даст уверенность, что система работает как надо?
Во-первых, если у вас или вашей команды нет опыта в тестировании, то его нужно получить. Для этого предлагаю пойти, как и в этой статье, от простых компонентов к более сложным: выделить ресурсы на технический долг и начать тестировать, например, кнопки, постепенно повышая градус сложности, приобретая опыт и лучше разбираясь, как всё работает.
Во-вторых, когда у вас уже есть опыт, нет смысла пытаться сразу покрыть весь код тестами. Внедрение нужно как-то упорядочить, например, разбить на блоки и переходить к следующему, когда настроили процесс в предыдущем:
-
Писать тесты на баги. Часто баги цепляются друг за друга, а тесты на баги помогут убедиться, что мы не будет воспроизводить известный баг снова.
-
Запускать все тесты локально (git hooks) и в CI — без этого, мне кажется, вся затея вообще не имеет смысла. Наша задача максимально быстро локализовать проблему и без удобно настроенного окружения этого не сделать.
-
Покрывать весь новый код тестами сразу — договориться об этом как о требовании для следующих релизов и, соответственно, учитывать в оценке задач.
-
Писать тесты при рефакторинге старого кода — раз уж рефакторим код, то сразу и покроем его тестами. Если тесты на модуль уже были написаны, то, если они соответствовали принципу честности, то они должны остаться зелеными. Также при рефакторинге можно попробовать применить TDD: описать бизнес-требования и user story, написать тесты, а уже потом код.
-
Тесты на уже написанную функциональность всё равно придется делать — их можно отнести к внутреннему техдолгу.
При этом, естественно, бывают ситуации, когда тесты не нужны и выделять на них ресурсы преждевременно. Например, стартап на стадии pivot/preseed ещё не генерирует выручки, чтобы инвестировать в качество, и вообще имеет все шансы не выжить. Так же как и для вариантов A/B-тестирования писать тесты может быть избыточно, ведь какие-то из вариантов заведомо будут отвергнуты. Тестирование — не серебряная пуля, а один из возможных инструментов обеспечения качества.
Подписывайтесь на этот блог и канал Смотри за IT, если хотите знать больше о создании медиасервисов. Там рассказываем об инженерных тонкостях и продуктовых находках, делимся видео выступлений и кадрами из жизни команд Цифровых активов «Газпром-Медиа Холдинга» таких, как RUTUBE, PREMIER, Yappy.
А для погружения в тему тестирования для веб-разработчика можно посмотреть эти видео:
-
Для новичков: «Тестирование JavaScript от А до Я (Jest, React Testing Library, e2e, screenshot)».
-
Для реактёров, которым не зашли мои примеры на Vue: «Принципы тестирования frontend приложений».
-
Для продвинутых и продолжающих специалистов: «Как сделать автотесты полезными. Эволюция автотестов в Яндекс ID».
Автор: RuSaG0


