Какие уровни тестирования есть во frontend и какими инструментами их покрывают?

MiddleFrontend инструменты #tooling #testing

Вопрос

Что такое пирамида тестирования применительно к frontend и какие инструменты используются на каждом уровне?

Короткий ответ

Пирамида тестирования — это соотношение количества тестов по уровням: много быстрых дешёвых unit-тестов (Jest/Vitest) в основании, меньше компонентных тестов, проверяющих поведение (Testing Library), и совсем немного медленных, но самых реалистичных E2E-тестов (Playwright/Cypress) на вершине.

Подробный ответ

  • Unit-тесты (Jest / Vitest) — тестируют изолированные функции и модули: утилиты, хелперы, редьюсеры, композаблы без DOM. Запускаются в памяти (jsdom/happy-dom при необходимости DOM-заглушки), выполняются за миллисекунды, легко пишутся в большом количестве. Ловят логические ошибки в изолированном коде, но ничего не знают про то, как модули работают вместе.
  • Компонентные тесты (Testing Library — @testing-library/vue, @testing-library/react) — рендерят компонент и проверяют его поведение с точки зрения пользователя: что видно на экране, что происходит по клику, — а не внутренние детали реализации (не "у компонента state.count === 1", а "после клика на экране написано '1'"). Философия Testing Library намеренно ограничивает доступ к внутренностям компонента (нет прямого доступа к instance/state), чтобы тесты не ломались при рефакторинге реализации и ломались только при реальном изменении поведения. Это медленнее unit-тестов (нужен DOM), но покрывает интеграцию компонента с шаблоном/стилями/событиями.
  • E2E-тесты (Playwright / Cypress) — запускают приложение целиком в настоящем (или near-real) браузере, кликают по реальному UI, ходят по реальным сетевым запросам (или мокают их на уровне сети). Проверяют полный пользовательский сценарий — логин, оформление заказа — через все слои: фронт, API, иногда БД. Самые медленные и самые дорогие в поддержке (флаки из-за таймингов, сети), но и самые близкие к реальности: ловят баги интеграции, которые unit- и компонентные тесты в принципе не видят.

Компромисс пирамиды: чем выше уровень, тем дороже (время выполнения, время написания, стабильность) и тем ближе к реальному пользовательскому опыту тест. Поэтому основную массу проверок стараются покрыть unit- и компонентными тестами, а E2E оставляют для критичных сквозных сценариев (checkout, авторизация), а не для каждой мелочи.

Отдельная категория — визуальное регрессионное тестирование (screenshot testing): инструмент (Playwright, Chromatic для Storybook, Percy) делает скриншот компонента/страницы и сравнивает попиксельно с эталоном, чтобы ловить непреднамеренные визуальные изменения (сдвинулась кнопка, съехали отступы), которые обычные assertion-тесты не замечают.

Пример

// sum.test.js — unit-тест (Vitest)
import { describe, it, expect } from 'vitest'
import { sum } from './sum'

describe('sum', () => {
  it('складывает два числа', () => {
    expect(sum(2, 3)).toBe(5)
  })
})
// Counter.test.js — компонентный тест (Testing Library)
import { render, screen, fireEvent } from '@testing-library/vue'
import Counter from './Counter.vue'

test('увеличивает счётчик по клику', async () => {
  render(Counter)
  const button = screen.getByRole('button', { name: /increment/i })

  await fireEvent.click(button)

  // проверяем то, что видит пользователь, а не внутренний state
  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})
// checkout.spec.ts — E2E-тест (Playwright)
import { test, expect } from '@playwright/test'

test('пользователь может оформить заказ', async ({ page }) => {
  await page.goto('/cart')
  await page.getByRole('button', { name: 'Оформить заказ' }).click()
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByRole('button', { name: 'Оплатить' }).click()

  await expect(page.getByText('Заказ оформлен')).toBeVisible()
})

Дополнительные вопросы

  • Почему Testing Library намеренно не даёт лёгкого доступа к internal state компонента?
  • Как бороться с "флакающими" (нестабильными) E2E-тестами?
  • В каких случаях компонентный тест стоит заменить на E2E, и наоборот?