Vue без Node: как Julia Evans тестирует компоненты в браузере

Julia Evans (jvns.ca) показывает, как написать end-to-end тесты Vue-компонентов в браузере без Node, Deno и сборки. QUnit, mountComponent, waitFor, dispatchEvent для форм. Полный перевод.

Обложка: Vue без Node: как Julia Evans тестирует компоненты в браузере

Если у вас pet-проект на Vue, в котором вы боитесь что-то менять, потому что без билда и без тестов любое изменение похоже на лотерею, — рабочий способ обойтись без Node, Deno и сборщика всё-таки есть. Julia Evans (автор «Wizard Zines» и блога jvns.ca) описала минимальный рецепт в посте от 2 мая 2026: компоненты кладутся в window._components, функция mountComponent рендерит их в скрытый div, тесты QUnit запускаются прямо со страницы. Это перевод поста.

Заметка короткая и практическая. Сначала — краткий вывод, потом полный текст Julia.

Ключевые выводы
  • Что в посте: рабочий минимум для тестирования Vue-компонентов в браузере без Node, Deno и сборки. Тесты запускаются на отдельной странице через QUnit (тестовый фреймворк, который рендерится прямо в браузере), компоненты экспортируются в window._components, рендерятся через mountComponent в скрытый div вне viewport.
  • Как ждать DOM: Julia пишет свою функцию waitFor(), которая раз в 20 мс проверяет условие и сдаётся через 2 секунды. Без sleep()-затычек, обычным опросом DOM — паттерн, к которому рано или поздно приходит любая фронтенд-команда.
  • Заполнение форм требует событий: в Vue недостаточно присвоить value или checked — нужно диспатчить событие (input для текстов, change для чекбоксов), иначе реактивность Vue не отслеживает изменение.
  • Test coverage — Chrome это умеет из коробки: панель Coverage показывает покрытие JS и CSS-кода. Чтобы хорошо работало, Julia рекомендует выключить sourcemaps в DevTools и смотреть покрытие по бандлу.
  • Открытые вопросы: как запускать те же тесты в CI, как уйти от привязки к CSS-классам в селекторах (правильнее — getByRole или data-testid), и стоит ли переехать на Testing Library/Vue Test Utils.

Контекст: я хочу тестировать без Node

Привет! Один из моих долгосрочных проектов — выяснить, как писать frontend-JavaScript без Node и любого другого серверного JS-рантайма.

Главная проблема, в которую я постоянно упираюсь в своих frontend-проектах: я не знаю, как писать для них тесты. Раньше я пробовала Playwright, но он казался медленным и неуклюжим — всё время приходилось запускать новые browser-процессы, и оркестрация требовала Node-кода.

В итоге я просто не тестирую свой frontend, и это неприятно. Обычно я и не обновляю проекты особо часто, поэтому болезненность не сильно проявляется, но было бы хорошо вносить изменения с большей уверенностью!

Так что подходящий способ frontend-тестирования давно лежит у меня в списке желаний.

Идея: просто запускать тесты во вкладке браузера

Alex Chan когда-то написал отличный пост — «Testing JavaScript without a (third-party) framework» — в ответ на одну из моих предыдущих заметок этой серии. Там он показал, как сделать крошечный фреймворк юнит-тестирования, который запускается прямо со страницы в браузере.

Мне тогда очень понравился подход, но в посте речь шла только про unit-тесты, а мне нужны были end-to-end интеграционные тесты для моих Vue-компонентов, и я не знала, как это сделать.

Поэтому, когда на днях знакомый Marco в разговоре сказал «знаешь, ты же можешь просто запускать тесты Vue-компонентов в браузере», я подумала: «эй, надо попробовать ещё раз!»

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

Это было слегка непросто: документация Vue обычно предполагает, что вы используете Node как часть build-процесса (там много «шаг 1: npm install ЧТО-ТО»), а я не хотела использовать Node, Deno и так далее. Но на практике оказалось не очень сложно.

Проект, который я буду здесь тестировать, — это сайт фидбэка по моим зинам, который я написала в 2023.

Тестовый фреймворк: QUnit

Я использовала QUnit — тестовый фреймворк для JavaScript, который умеет запускаться прямо со страницы в браузере без Node-окружения (когда-то именно его использовала команда jQuery). Он отлично работает, но рассказать про его внутреннее устройство мне особо нечего — поэтому ограничусь этим. Думаю, подход Alex с самописным фреймворком тоже сработал бы. Я следовала официальной инструкции.

Что я оценила в QUnit — кнопка «rerun test», которая запускает только один конкретный тест. У меня в тестах много network requests, поэтому возможность запустить только один тест сильно облегчает дебаг.

Шаг 1: подготовить компонент к тестированию

Первое, что я сделала — настроила свои Vue-компоненты для тестового окружения.

В основном приложении я положила все компоненты в window._components, примерно так:

			const components = {
  'Feedback': FeedbackComponent,
  ...
}
window._components = components;
		

Затем смогла написать функцию mountComponent, которая делает то же самое, что мой обычный mount-код в production (рендерит крошечный шаблон с нужным компонентом).

Отличия только два:

  • Можно опционально передать дополнительные данные, чтобы использовать их как props.
  • Компонент монтируется во временный невидимый div, который удаляется из DOM по завершении теста. Div позиционирован за пределами viewport (position: absolute; top: -10000, ...), так что его не видно.

Вот как выглядит вызов mountComponent:

			const {div} = mountComponent(
  '<Page :feedbacks="feedbacks" id=2 />',
  {feedbacks: [testFeedback]},
);
		

А вот её код. Здесь qunit-fixture — это служебный div, который QUnit добавляет в тестовую страницу и автоматически очищает после каждого теста (его id зашит в QUnit, нужно только положить <div id="qunit-fixture"></div> в HTML тестовой страницы):

			function mountComponent(template, data) {
  const app = Vue.createApp({
    template: template,
    data: () => data,
  });
  for (const [c, v] of Object.entries(window._components)) {
    app.component(c, v);
  }
  const div = document.getElementById('qunit-fixture')
             .appendChild(document.createElement('div'));
  app.mount(div);
  return {div};
}
		

Результат — div, в котором можно программно кликать, заполнять формы, проверять, что появился нужный контент, и так далее. (Примечание переводчика: в оригинальной версии у Julia функция возвращала просто div, но дальше во всех вызовах используется const {div} = mountComponent(...). С return div такая деструктуризация даст undefined — поэтому мы поправили на return {div}. И добавили явный app.mount(div), который тоже опущен в оригинале.)

Шаг 2: добавить fixture-данные

Поскольку я писала end-to-end интеграционные тесты, в которых клиентский JS должен работать в связке с сервером, в БД нужны были тестовые данные. Я написала ~25 строк SQL для подготовки тестовых данных и добавила endpoint в dev-сервер, который запускает этот SQL и сбрасывает тестовые данные в известное состояние.

			async function reset() {
    return fetch('/api/reset_test_data', {method: 'POST'});
}
		

Затем просто вызываю await reset() в начале каждого теста, которому нужны тестовые данные.

Моя reset() на самом деле не всегда полностью сбрасывает всё — не очень хорошо, но для старта рабочий вариант, и его всегда можно улучшить.

Шаг 3: базовый тест

Так выглядит базовый тест! По сути мы рендерим div и проверяем, что в нём есть приблизительно правильные данные.

			QUnit.test('renders feedback content', async function (assert) {
  const {div} = mountComponent(
    '<Page :feedbacks="feedbacks" id=2 image=2 page_hash=2 />',
    {feedbacks: [testFeedback]},
  );
  assert.ok(div.textContent.includes('loved this section'));
});
		

Это все базовые кирпичики! А теперь — несколько проблем, на которые я наткнулась по ходу.

Как ждать рендера: проблема и waitFor()

В моих тестах много сетевых запросов, и нужно время, чтобы они завершились, а Vue потом сделал с результатами своё дело и обновил DOM.

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

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

Поэтому я написала маленькую функцию waitFor(), которая опрашивает условие каждые 20 мс. Таймаут — 2 секунды.

Версия в посте Julia не показана прямо — приведём свою, минимально работающую (и идейно соответствующую её описанию):

			async function waitFor(condition, timeout = 2000) {
  const start = Date.now();
  while (Date.now() - start < timeout) {
    const result = condition();
    if (result) return result;
    await new Promise(r => setTimeout(r, 20));
  }
  throw new Error('waitFor timed out');
}
		

Использование выглядит так:

			QUnit.test('click item', async function (assert) {
  const {div} = mountComponent(
    '<Feedback zine_id="test123" image_width="800px" />',
    {});
  const item = await waitFor(() => div.querySelector('.feedback-item'));
  item.click();
  // остальной код теста...
});
		

Похоже, существует много реализаций этой идеи, и они продуманы лучше моей (беглый поиск в Google: qunit-wait-for, Playwright expect.poll).

Понять, чего именно ждать, — нетривиально

Иногда мне казалось, что я нашла правильную точку для ожидания в DOM («просто дождись, пока появится этот textarea!»), но на практике из-за внутренних деталей программы нужно было ждать чего-то другого, что было сложно зацепить.

В итоге я добавила в один компонент произвольное значение в DOM, когда он завершал важное действие (вроде data-this-thing-is-ready=true). Это не очень-то красиво.

Думаю, правильный способ починить такую проблему теста — рефакторинг, который заодно делает приложение более надёжным для пользователя. Если в DOM есть элемент, с которым пользователю на самом деле ещё нельзя взаимодействовать — может быть, его и не стоит показывать?

Добавлять CSS-классы для селекторов? Спорный вопрос

В итоге я добавила несколько классов на HTML-элементы — нужно было их находить в тестах, чтобы кликать или ждать появления в DOM.

Я могу позже изменить этот подход — тестовые фреймворки для frontend обычно советуют избегать CSS-классов и использовать что-то вроде getByRole (выбор по семантической роли элемента, например «кнопка» или «поле формы») или, в крайнем случае, data-testid (специальный data-атрибут, который добавляют исключительно ради тестов и игнорируют в production-стилях).

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

Заполнение форм — отдельная боль

Чтобы заполнить форму, недостаточно просто выставить value — нужно ещё диспатчить событие, чтобы Vue понял, что элемент изменился. И для checkbox, и для textarea нужны разные события.

			textarea.value = 'banana banana banana';
textarea.dispatchEvent(new Event('input'));

checkbox.checked = true;
checkbox.dispatchEvent(new Event('change'));
		

Это слегка раздражает — и заставляет понять, зачем вообще могут понадобиться UI-тестовые библиотеки. Например, Testing Library (набор фреймворк-агностичных утилит для тестирования UI с упором на доступность) и Vue Test Utils (официальная библиотека Vue для unit-тестов компонентов). Их подходы к формам выглядят иначе:

  • Пример заполнения формы из Testing Library выглядит совершенно иначе, чем то, что делаю я.
  • У Vue Test Utils раздел про работу с формами выглядит так, будто сильно упрощает всё это.

Test coverage — Chrome это умеет из коробки

Мне хотелось понять, какое у меня test coverage, и оказывается, в Chrome есть встроенная функция code coverage для JS и CSS!

Мой JS собирается в один файл bundle.js через esbuild — поэтому я могу просто посмотреть на bundle.js и увидеть, какие строки не покрыты тестами.

Процесс получился чуть капризный: пришлось выключить sourcemaps в Chrome DevTools, чтобы заработало, и есть специфическая, не самая очевидная последовательность действий, чтобы увидеть данные покрытия.

Это было прикольно

Как обычно с такими постами: я никогда особо не работала frontend- или backend-разработчиком (кроме как для себя!) и чувствую, будто постоянно учусь делать совсем базовые штуки.

Мне реально кайфовалось от этого. Мои frontend-проекты всегда кажутся хрупкими, потому что они без тестов; и, может быть, однажды у меня появится тест-сьют, в котором я буду уверена.

Что я ещё обдумываю:

  • Пока писала пост, нашла frontend-библиотеку Testing Library — у неё много рекомендаций, как писать тесты, которые сильно отличаются от моих первоначальных идей. Я попробовала переписать всё на Testing Library — получилось неплохо, посмотрим, что из этого выйдет. Они распространяют .umd.js файл, который работает без Node.
  • Не до конца понимаю, как относиться к тому, что эти тесты никак не запускаются из командной строки. Может быть, есть простой способ работать в основном в браузере, но иметь возможность погонять и в CI, если нужно?
FAQ
1
А можно ли запустить эти же тесты в CI, без живого браузера?

Сама Julia пишет, что не нашла простого способа сделать оба мира сразу. Стандартный путь, если хочется CI — взять headless-браузер: Playwright или Puppeteer запускают Chrome в headless-режиме и могут крутить ту же тестовую страницу. Но это снова возвращает Node в зависимостях, чего Julia как раз и пытается избежать в этой серии.

2
Зачем выносить компоненты в <code class="tp-inline-code language-none">window._components</code>? Это вообще нормальная практика?

Это «костыль» под конкретный сценарий: компоненты должны быть достижимы и в production-коде (через обычный mount приложения), и из тестового окружения, которое грузит ту же страницу. window._components даёт оба сразу. В проектах со сборкой на Vite или esbuild обычно вместо этого экспортируют компоненты как ES-модули и импортируют их в тестовом файле — но если у вас нет сборки и не хочется её добавлять только ради тестов, window._components остаётся вполне рабочим вариантом.

3
Чем <code class="tp-inline-code language-none">getByRole</code> лучше CSS-классов?

getByRole ищет элементы по их семантической роли в Accessibility Tree — то есть именно так, как их видит скринридер или другая ассистивная технология. Если ваш тест проходит через getByRole("button") и кнопка действительно доступна как кнопка для пользователя, вы заодно проверили доступность. CSS-класс к доступности никак не относится — он легко переживает рефакторинг, после которого тест зелёный, а кнопка для скринридера невидима.

4
А Vue Test Utils чем отличается от подхода из поста?

Vue Test Utils — официальная библиотека Vue для unit-тестирования компонентов. Она запускает Vue в Jest или Vitest (то есть в Node, через jsdom — имитацию DOM на JS), а не в реальном браузере. Подход Julia — наоборот, запускает в реальном браузере без Node. У обоих есть свои плюсы: Vue Test Utils — стабильнее в CI, подход Julia — ближе к реальному окружению пользователя.

5
А что если у меня не Vue, а React или Svelte?

Тот же подход применим — это не про Vue, а про идею «тесты в браузере без Node». Достаточно: компоненты, доступные глобально, mount-функция в тестовый div, QUnit (или другой фреймворк, умеющий работать страницей), и реакции на DOM-события через dispatchEvent. На уровне Testing Library нижний слой @testing-library/dom распространяется в UMD-сборке — поверх него можно работать с компонентами React и Svelte без сборщика.

Что забрать с собой

Мои frontend-проекты всегда кажутся хрупкими, потому что они без тестов. Может быть, однажды у меня появится тест-сьют, в котором я буду уверена.
Julia Evansавтор Wizard Zines, блог jvns.ca

Главный вывод поста: для маленьких персональных или библиотечных проектов «тестовая страница в браузере» — совершенно рабочий путь. Не нужно тащить Node, build-сервер и jsdom только ради того, чтобы у вас были тесты. Чек-лист минимальной настройки:

  1. Положить компоненты в window._components (или другое глобальное пространство имён).
  2. Подключить QUnit (или другой фреймворк, который умеет запускаться прямо со страницы) в HTML-файл с тестами.
  3. Написать mountComponent(template, data), которая создаёт временный div и монтирует туда Vue-app.
  4. Опционально (если у компонента есть бэкенд): сделать endpoint /api/reset_test_data на dev-сервере для сброса БД к фикстуре.
  5. Реализовать waitFor(condition, timeout=2000) для ожидания DOM-условий вместо sleep().
  6. Для форм диспатчить input (текстовые поля) и change (чекбоксы) после изменения свойств элементов. Для кликов хватит обычного .click() на элементе.
  7. Для покрытия — Chrome DevTools, вкладка Coverage; sourcemaps выключить.

Оригинал поста Julia Evans — на jvns.ca. Альтернативы для тех, кто хочет больше готового: Vue Test Utils (через jsdom в Node), Testing Library (с UMD-сборкой работает без Node).