Vue без Node: как Julia Evans тестирует компоненты в браузере
Julia Evans (jvns.ca) показывает, как написать end-to-end тесты Vue-компонентов в браузере без Node, Deno и сборки. QUnit, mountComponent, waitFor, dispatchEvent для форм. Полный перевод.
Если у вас 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, примерно так:
Затем смогла написать функцию mountComponent, которая делает то же самое, что мой обычный mount-код в production (рендерит крошечный шаблон с нужным компонентом).
Отличия только два:
- Можно опционально передать дополнительные данные, чтобы использовать их как props.
- Компонент монтируется во временный невидимый div, который удаляется из DOM по завершении теста. Div позиционирован за пределами viewport (
position: absolute; top: -10000, ...), так что его не видно.
Вот как выглядит вызов mountComponent:
А вот её код. Здесь qunit-fixture — это служебный div, который QUnit добавляет в тестовую страницу и автоматически очищает после каждого теста (его id зашит в QUnit, нужно только положить <div id="qunit-fixture"></div> в HTML тестовой страницы):
Результат — 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 и сбрасывает тестовые данные в известное состояние.
Затем просто вызываю await reset() в начале каждого теста, которому нужны тестовые данные.
Моя reset() на самом деле не всегда полностью сбрасывает всё — не очень хорошо, но для старта рабочий вариант, и его всегда можно улучшить.
Шаг 3: базовый тест
Так выглядит базовый тест! По сути мы рендерим div и проверяем, что в нём есть приблизительно правильные данные.
Это все базовые кирпичики! А теперь — несколько проблем, на которые я наткнулась по ходу.
Как ждать рендера: проблема и waitFor()
В моих тестах много сетевых запросов, и нужно время, чтобы они завершились, а Vue потом сделал с результатами своё дело и обновил DOM.
Думаю, мы давно усвоили: вставлять sleep()-затычки и надеяться, что тайминги совпадут, — медленно, нестабильно и доводит до бешенства. Поэтому нужен другой способ.
Насколько я понимаю, обычный путь — найти способ по DOM понять, можно ли двигаться дальше. Что-то вроде «если эта кнопка видна — значит, можно действовать».
Поэтому я написала маленькую функцию waitFor(), которая опрашивает условие каждые 20 мс. Таймаут — 2 секунды.
Версия в посте Julia не показана прямо — приведём свою, минимально работающую (и идейно соответствующую её описанию):
Использование выглядит так:
Похоже, существует много реализаций этой идеи, и они продуманы лучше моей (беглый поиск в 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 нужны разные события.
Это слегка раздражает — и заставляет понять, зачем вообще могут понадобиться 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
А можно ли запустить эти же тесты в CI, без живого браузера?
Сама Julia пишет, что не нашла простого способа сделать оба мира сразу. Стандартный путь, если хочется CI — взять headless-браузер: Playwright или Puppeteer запускают Chrome в headless-режиме и могут крутить ту же тестовую страницу. Но это снова возвращает Node в зависимостях, чего Julia как раз и пытается избежать в этой серии.
Зачем выносить компоненты в <code class="tp-inline-code language-none">window._components</code>? Это вообще нормальная практика?
Это «костыль» под конкретный сценарий: компоненты должны быть достижимы и в production-коде (через обычный mount приложения), и из тестового окружения, которое грузит ту же страницу. window._components даёт оба сразу. В проектах со сборкой на Vite или esbuild обычно вместо этого экспортируют компоненты как ES-модули и импортируют их в тестовом файле — но если у вас нет сборки и не хочется её добавлять только ради тестов, window._components остаётся вполне рабочим вариантом.
Чем <code class="tp-inline-code language-none">getByRole</code> лучше CSS-классов?
getByRole ищет элементы по их семантической роли в Accessibility Tree — то есть именно так, как их видит скринридер или другая ассистивная технология. Если ваш тест проходит через getByRole("button") и кнопка действительно доступна как кнопка для пользователя, вы заодно проверили доступность. CSS-класс к доступности никак не относится — он легко переживает рефакторинг, после которого тест зелёный, а кнопка для скринридера невидима.
А Vue Test Utils чем отличается от подхода из поста?
Vue Test Utils — официальная библиотека Vue для unit-тестирования компонентов. Она запускает Vue в Jest или Vitest (то есть в Node, через jsdom — имитацию DOM на JS), а не в реальном браузере. Подход Julia — наоборот, запускает в реальном браузере без Node. У обоих есть свои плюсы: Vue Test Utils — стабильнее в CI, подход Julia — ближе к реальному окружению пользователя.
А что если у меня не Vue, а React или Svelte?
Тот же подход применим — это не про Vue, а про идею «тесты в браузере без Node». Достаточно: компоненты, доступные глобально, mount-функция в тестовый div, QUnit (или другой фреймворк, умеющий работать страницей), и реакции на DOM-события через dispatchEvent. На уровне Testing Library нижний слой @testing-library/dom распространяется в UMD-сборке — поверх него можно работать с компонентами React и Svelte без сборщика.
Что забрать с собой
Мои frontend-проекты всегда кажутся хрупкими, потому что они без тестов. Может быть, однажды у меня появится тест-сьют, в котором я буду уверена.
Главный вывод поста: для маленьких персональных или библиотечных проектов «тестовая страница в браузере» — совершенно рабочий путь. Не нужно тащить Node, build-сервер и jsdom только ради того, чтобы у вас были тесты. Чек-лист минимальной настройки:
- Положить компоненты в
window._components(или другое глобальное пространство имён). - Подключить QUnit (или другой фреймворк, который умеет запускаться прямо со страницы) в HTML-файл с тестами.
- Написать
mountComponent(template, data), которая создаёт временный div и монтирует туда Vue-app. - Опционально (если у компонента есть бэкенд): сделать endpoint
/api/reset_test_dataна dev-сервере для сброса БД к фикстуре. - Реализовать
waitFor(condition, timeout=2000)для ожидания DOM-условий вместоsleep(). - Для форм диспатчить
input(текстовые поля) иchange(чекбоксы) после изменения свойств элементов. Для кликов хватит обычного.click()на элементе. - Для покрытия — Chrome DevTools, вкладка Coverage; sourcemaps выключить.
Оригинал поста Julia Evans — на jvns.ca. Альтернативы для тех, кто хочет больше готового: Vue Test Utils (через jsdom в Node), Testing Library (с UMD-сборкой работает без Node).