Покрытие кода тестами

Покрытие кода тестами в библиотеке Choices.js строится вокруг проверки поведения DOM-компонента, управляемого состоянием, событиями и динамическими списками. Основная сложность тестирования заключается в том, что библиотека одновременно работает с пользовательским вводом, виртуализированным состоянием выбора и изменением структуры DOM, что требует комбинирования unit-, integration- и end-to-end подходов.

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

1. Модульные тесты (Unit tests) Проверяют изолированные функции: парсинг опций, фильтрацию, нормализацию данных, генерацию внутренних структур.

2. Интеграционные тесты (Integration tests) Оценивают взаимодействие компонентов внутри библиотеки: связку состояния, событий и DOM-рендеринга.

3. End-to-End тесты (E2E) Проверяют поведение библиотеки в реальном браузере: ввод пользователя, выбор элементов, клавиатурную навигацию, доступность.

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


Настройка тестового окружения

Для тестирования DOM-библиотек используется JSDOM или полноценный браузерный раннер.

Часто применяются:

  • Jest — основной раннер для unit и integration тестов
  • Vitest — альтернатива с более высокой скоростью выполнения
  • Testing Library (DOM Testing Library) — работа с DOM через поведение пользователя
  • Playwright / Cypress — E2E сценарии

Дополнительно подключается инструмент покрытия кода:

  • Istanbul (nyc) — анализ покрытия строк, ветвлений и функций

Пример базовой конфигурации покрытия:

{
  "collectCoverage": true,
  "coverageDirectory": "coverage",
  "coverageReporters": ["text", "lcov", "html"],
  "collectCoverageFrom": [
    "src/**/*.js",
    "!src/**/*.spec.js"
  ]
}

Unit-тестирование внутренних модулей

Внутренние функции Choices.js обычно не зависят от DOM и поддаются чистому тестированию.

Пример: нормализация входных данных

import { normalizeOptions } from '../src/utils/normalize';

test('преобразует строковые опции в объекты', () => {
  const input = ['Apple', 'Banana'];
  const result = normalizeOptions(input);

  expect(result).toEqual([
    { label: 'Apple', value: 'Apple' },
    { label: 'Banana', value: 'Banana' }
  ]);
});

Проверка крайних случаев

Особое внимание уделяется:

  • пустым массивам
  • null/undefined входам
  • дублирующимся значениям
  • нестандартным типам (числа, объекты)
test('обрабатывает пустой массив', () => {
  expect(normalizeOptions([])).toEqual([]);
});

Тестирование состояния и событий

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

Основные события:

  • addItem
  • removeItem
  • highlightItem
  • search
  • change

Проверка добавления элемента

test('добавляет элемент в выбранные', () => {
  const choices = new Choices(selectElement);

  choices.setChoiceByValue('apple');

  expect(choices.getValue(true)).toContain('apple');
});

Проверка удаления

test('удаляет элемент из выбранных', () => {
  const choices = new Choices(selectElement);

  choices.setChoiceByValue('apple');
  choices.removeActiveItems();

  expect(choices.getValue(true)).not.toContain('apple');
});

Интеграционные тесты DOM-рендеринга

На этом уровне проверяется связка логики и DOM.

Пример: рендер списка

import { screen } from '@testing-library/dom';

test('рендерит список опций', () => {
  new Choices(selectElement, {
    choices: [
      { label: 'Apple', value: 'apple' },
      { label: 'Banana', value: 'banana' }
    ]
  });

  expect(screen.getByText('Apple')).toBeTruthy();
  expect(screen.getByText('Banana')).toBeTruthy();
});

Проверка фильтрации

test('фильтрует список при вводе', () => {
  const instance = new Choices(inputElement);

  instance.setChoices([
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' }
  ]);

  instance.input.value = 'App';
  instance.input.dispatchEvent(new Event('input'));

  expect(screen.getByText('Apple')).toBeTruthy();
  expect(screen.queryByText('Banana')).toBeNull();
});

Мокирование DOM и событий

При тестировании важно контролировать поведение событий:

  • keyboard events
  • mouse events
  • input events

Пример клавиатурной навигации

test('обрабатывает стрелки вниз', () => {
  const instance = new Choices(selectElement);

  const event = new KeyboardEvent('keydown', { key: 'ArrowDown' });
  instance.container.dispatchEvent(event);

  expect(instance.highlightedIndex).toBeGreaterThanOrEqual(0);
});

E2E тестирование

E2E сценарии проверяют реальное взаимодействие в браузере.

Пример сценария в Playwright:

test('выбор элемента через интерфейс', async ({ page }) => {
  await page.goto('http://localhost:3000');

  await page.click('.choices__input');
  await page.fill('.choices__input', 'App');

  await page.click('text=Apple');

  const value = await page.$eval('select', el => el.value);
  expect(value).toBe('apple');
});

Покрытие ветвлений и логики

Инструменты покрытия фиксируют:

  • statement coverage
  • branch coverage
  • function coverage
  • line coverage

Особенно важно покрывать:

  • условия фильтрации
  • обработку пустых состояний
  • асинхронную загрузку данных
  • fallback-ветки

Пример сложной ветки:

function isValidChoice(choice) {
  if (!choice) return false;
  if (choice.disabled) return false;
  if (typeof choice.value === 'undefined') return false;

  return true;
}

Тесты должны покрывать каждую ветвь отдельно.


Тестирование асинхронной загрузки данных

При использовании remote data:

  • задержки сети
  • debounce поискового ввода
  • отмена предыдущих запросов
test('обрабатывает асинхронный поиск', async () => {
  const instance = new Choices(selectElement, {
    searchEnabled: true,
    callbackOnSearch: async () => {
      return [
        { label: 'Apple', value: 'apple' }
      ];
    }
  });

  await instance.search('App');

  expect(instance.resultsCount).toBe(1);
});

Проверка доступности (a11y)

Критично тестировать:

  • aria-атрибуты
  • навигацию клавиатурой
  • роли элементов
  • фокусировку
test('имеет корректные aria-атрибуты', () => {
  new Choices(selectElement);

  expect(selectElement.getAttribute('aria-expanded')).toBe('false');
});

Стабильность тестов и борьба с флакерами

Флаки часто возникают из-за:

  • асинхронных обновлений DOM
  • debounce/timeout логики
  • race conditions событий

Подходы:

  • использование fake timers
  • ожидание DOM через waitFor
  • изоляция экземпляров библиотеки
  • сброс состояния между тестами
jest.useFakeTimers();

test('debounce работает корректно', () => {
  instance.search('a');

  jest.advanceTimersByTime(300);

  expect(instance.resultsCount).toBeGreaterThanOrEqual(0);
});

Метрики качества покрытия

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

  • покрытие критических путей (critical paths)
  • покрытие пользовательских сценариев
  • плотность assertion per test
  • устойчивость к рефакторингу

Особое внимание уделяется путям:

  • создание инстанса
  • изменение выбора
  • очистка состояния
  • разрушение компонента (destroy lifecycle)

Интеграция покрытия в CI

В CI-процессе проверяются пороги:

  • lines ≥ 90%
  • branches ≥ 85%
  • functions ≥ 95%

Пример GitHub Actions:

- name: Run tests
  run: npm test -- --coverage

- name: Check coverage
  run: npx nyc check-coverage --lines 90 --branches 85