Тесты Angular-компонентов можно запускать в jsdom или в настоящем браузере через Vitest Browser Mode. При выборе среды полезно спросить, что именно должен подтвердить тест: валидацию формы, перевод фокуса, размер контейнера или возможность нажать кнопку указателем.
Мы проверили шесть таких сценариев. До написания тестов для каждого задали ожидаемое поведение и одну изолированную мутацию, намеренно ломающую это поведение. Мутации выбрали сами, чтобы разобрать разные механизмы. Это небольшой учебный стенд, не случайная выборка ошибок рабочего приложения.
Часть этих механизмов общая для веб-интерфейсов. Здесь мы проверяли их на Angular-компонентах; роль TestBed, signals и отложенного позиционирования подробно видна в S4–S5.
Исправную и дефектную версии каждого Angular-компонента запустили тремя способами:
- A: jsdom + synthetic actions. Vitest работает в jsdom, взаимодействие выполняет
@testing-library/user-event. - B: Chromium + synthetic actions. Тот же подход с
@testing-library/user-event, но тест работает в headless Chromium через Browser Mode. - C: Chromium + provider actions. В том же Chromium используются действия
vitest/browserчерез Playwright provider.
Задача эксперимента: выяснить, какие из заданных пользовательских условий полностью проверяют эти тесты и каких наблюдений им не хватает. Скорость здесь не сравниваем и рейтинг инструментов не строим.
Различие синтетических и provider actions уже описано в Marmicode Cookbook, в том числе на примере перекрытой кнопки. Мы добавили проверку шести заранее выбранных мутаций, отдельный hit test без provider action и разбор ошибки в собственном тесте геометрии.
Общая картина: в S1–S3 jsdom хватило для формы, программного фокуса и заданной последовательности Tab. S4–S5 требовали реального browser layout. В S6 синтетический click вызывал handler перекрытой кнопки, хотя указатель не мог в неё попасть. Зелёный результат сохранялся даже при запуске такого теста в Chromium.
Что считали полной проверкой
Перед обнаружением мутации тест должен был пройти на исправном варианте. Затем мы смотрели, какую часть ожидаемого поведения он действительно проверяет.
bug_detected: исправная версия прошла, дефектная нарушила полный заданный критерий.partial: тест проверил часть этого критерия.unsupported: в выбранном стенде полный критерий нельзя достоверно проверить без подмены изучаемого поведения.
unsupported относится к полному поведению в этом стенде. Через mock можно проверить реакцию на размер или аргумент observe(), но это другие утверждения. partial и unsupported не равны false_negative, пропуску дефекта при полной проверке.
| Сценарий и пользовательский критерий | A: jsdom + synthetic | B: Chromium + synthetic | C: Chromium + provider |
|---|---|---|---|
| S1. Форма: отклонить неверный email, затем отправить верный | bug_detected |
bug_detected |
bug_detected |
| S2. Фокус диалога: при открытии перевести фокус на «Закрыть», при закрытии вернуть на «Открыть» | bug_detected |
bug_detected |
bug_detected |
| S3. Порядок Tab: пройти по доступным контролам и пропустить скрытый | bug_detected |
bug_detected |
bug_detected |
| S4. Ширина контейнера: обновиться после уменьшения внутреннего контейнера без изменения viewport | unsupported |
bug_detected |
bug_detected |
| S5. Popover у края: сменить сторону и сохранить весь интерактивный прямоугольник внутри viewport | unsupported |
bug_detected |
bug_detected |
| S6. Перекрытая кнопка: допустить нажатие указателем в центре и обновить статус | partial |
partial |
bug_detected |
Таблица относится к исходным тестовым адаптерам, то есть к коду действий, ожиданий и утверждений в каждом режиме. Поздняя проверка S6 через elementFromPoint сюда не включена; ниже она разобрана отдельно.
Компоненты, стили и мутации между режимами оставались одинаковыми. Адаптеры различались: в S1 A/B использовали type, C использовал fill; в S3 A/B вызывали user-event.tab, C посылал браузерный Tab. Поэтому эти результаты нельзя приписать одному полностью изолированному свойству среды.
Где эти сценарии находятся в пирамиде тестов
Во всех шести сценариях мы монтировали отдельный компонент через TestBed и проверяли его вместе с шаблоном и событиями DOM. По объёму это компонентные интеграционные проверки. Полное приложение с маршрутизацией, backend и пользовательским потоком здесь не запускалось. Browser Mode сам по себе не превращает такой тест в E2E.
В S1–S3 для заданного поведения хватило jsdom. В S4–S5 компоненту понадобились браузерные размеры и уведомления, в S6 пришлось проверить доступность нажатия. Отдельные unit-тесты в этих же сценариях могут проверять валидатор, выбор состояния по ширине или расчёт позиции. Они полезны, но не подтверждают взаимодействие компонента с браузером. E2E понадобятся для проверки потока целого приложения; такой уровень эксперимент не исследовал.
S1–S3: форма, фокус и Tab
В S1 форма отклоняла bad.example, показывала ошибку и оставляла счётчик отправок равным нулю. Затем принимала ok@example.com. Мутация убирала проверку формата, сохраняя проверку на пустое поле. Её нашли все три режима. Валидацию выполняло приложение с novalidate; нативное сообщение браузера в сценарий не входило.
Для S2 проверяли фокус: после открытия диалога и стабилизации отображения он должен был попасть на «Закрыть», после закрытия вернуться на «Открыть». Мутация убирала перевод фокуса при открытии. Проверка document.activeElement выявила её в A, B и C.
В S3 тест трижды нажимал Tab из известной стартовой позиции. Ожидаемая последовательность: «Первый», «Второй», «Конец». Между последними двумя находился контрол в секции с display: none. После замены этого свойства на opacity: 0 контрол оставался в порядке Tab, хотя визуально был скрыт. Изменение обнаружили и user-event.tab в jsdom, и браузерное действие.
Эти тесты обходились без измерений layout. Их результат относится к программному фокусу и указанной последовательности Tab. Focus trap, visibility: hidden, inert и остальные варианты клавиатурной навигации здесь не проверялись. Для компонента, который использует эти способы ограничения взаимодействия, нужны отдельные проверки порядка Tab и фокуса. S3 не является полным тестом доступности.
S4: какой контейнер наблюдает ResizeObserver
Компонент менял подпись в зависимости от ширины своего контейнера. При 360 px показывал «Широкий». После уменьшения до 240 px, ниже порога 300 px, должен был показать «Узкий». Viewport не менялся, внешний контейнер оставался шириной 400 px.
В исправной реализации ResizeObserver наблюдал за внутренним контейнером. Мутация переключала его на внешний. Нужная ширина менялась, но observer следил за другим элементом.
В Angular-компоненте уведомление обновляло signal, значение которого выводилось в шаблоне. Фрагмент исправной реализации:
this.observer = new ResizeObserver((entries) => {
this.compact.set(entries[0].contentRect.width < 300);
});
this.observer.observe(this.inner.nativeElement);
При уничтожении компонента observer отключался. В исходнике для этого уже был отдельный hook:
ngOnDestroy(): void {
this.observer?.disconnect();
}
Если вручную передать callback размер 240 px, можно проверить, что он переключает состояние компонента. Но в таком тесте уведомление уже доставлено независимо от того, какой элемент наблюдает браузер. Без отдельной проверки выбранного объекта эта подмена не проверяет ошибку нашего сценария.
Есть и более точный mock-тест: записать аргумент observe() и сравнить его с внутренним контейнером. Такой spy мог бы обнаружить именно эту мутацию в jsdom. Он проверял бы, на какой элемент подписался компонент. Это полезная проверка реализации, но она не подтверждает весь пользовательский сценарий: после реального сужения контейнера браузер доставил уведомление, а подпись обновилась. Такой spy в основную серию не входил.
В B и C мы реально сужали внутренний контейнер и ждали изменения подписи. Обе проверки нашли мутацию. Для A полный сценарий был unsupported: независимых измерений layout и браузерного уведомления в этом стенде не было.
S5: где оказался popover
Компонент открывал popover рядом с trigger, элементом, к которому он привязан. В обычном положении popover должен был находиться справа. Когда trigger перемещался к правому краю, в left = 740 при viewport 800 × 600 CSS px, popover должен был открываться слева.
Мы проверяли сторону и весь интерактивный прямоугольник внутри viewport с допуском в один пиксель. Мутация отключала смену стороны. Обычное открытие продолжало работать, а у края правая граница popover доходила до 948 px.
После исправления тестов B и C обнаружили нарушение. В A полный сценарий был unsupported: для него требовалось измерить фактическое положение popover.
Почему первый браузерный тест прошёл на дефекте
После открытия Angular добавлял popover в DOM, а его положение рассчитывалось позже через requestAnimationFrame. В промежутке элемент уже существовал, но сохранял координаты предыдущего открытия. Тест мог проверить этот старый прямоугольник и пройти на дефектной версии.
Мы исправляли ожидание в два шага. Сначала стали дожидаться перемещения trigger к краю перед открытием. Этого оказалось мало: нужно было также дождаться обновления позиции самого popover. Только после этого тест проверял сторону и все четыре границы viewport.
Ожидание обновления допускало и правильное, и ошибочное новое положение. Поэтому оно не подсказывало тесту нужный ответ. Ошибку обнаруживали следующие утверждения о стороне и границах.

Браузер давал реальные координаты, но наш тест читал их слишком рано. После исправления ожиданий и полноты утверждений мы повторили итоговые серии. Ранние ложные pass сохранили в журнале отклонений и исключили из матрицы.
Как исправили тест S5: Angular, код ожидания и порог 500
Все компоненты монтировались через Angular TestBed. Helper создавал fixture, добавлял его элемент в документ и выполнял:
fixture.detectChanges();
await fixture.whenStable();
Это фактически выполненная подготовка компонента.
Начиная с Angular 21, zoneless включён по умолчанию. У TestBed режим зависит от загрузки
zone.js: с ним по умолчанию используется Zone-based change detection, без него zoneless; явный provider может изменить выбор. В сохранённой конфигурации стенда ZoneJS не подключался.
Для новых zoneless-тестов Angular рекомендует по возможности дожидаться
fixture.whenStable(), позволяя Angular выполнить запланированное обновление. Ручной
detectChanges() принудительно запускает change detection и может скрыть отсутствие уведомления об изменении состояния. В выдержке выше мы сохранили исходный helper, а не заменили его новым вариантом.
Затем пользовательские действия меняли состояние компонента. В S5 открытие popover выглядело так:
open(): void {
this.opened.set(true);
requestAnimationFrame(() => this.place());
}
Popover появлялся через Angular @if. В следующем animation frame place() измерял trigger и popover и обновлял signal с координатой. Подготовка fixture через whenStable() выполнялась до этих пользовательских действий. В тесте открытия мы отдельно ждали нужного изменения геометрии. Стабильность Angular и правильное положение popover являются разными условиями: whenStable() не является утверждением о стороне или границах элемента.
В B и C добавили одинаковые ожидания. Ниже фрагмент исправленного B-теста перед проверкой положения у края:
await user.click(buttons[0]);
await until(() => Math.round(trigger.getBoundingClientRect().left) === 740);
await user.click(trigger);
await until(() => popup().left >= 500);
expect(popup().right).toBeLessThan(trigger.getBoundingClientRect().left);
expect(popup().left).toBeGreaterThanOrEqual(-1);
expect(popup().right).toBeLessThanOrEqual(801);
expect(popup().top).toBeGreaterThanOrEqual(-1);
expect(popup().bottom).toBeLessThanOrEqual(601);
Здесь buttons[0] переводит trigger к краю, trigger открывает popover, а popup() читает его прямоугольник. Общий helper until опрашивал условие каждые 20 мс до его выполнения или таймаута. В выдержках сохранён фактически использованный код. В собственном тесте ожидание можно оформить через штатные vi.waitFor или expect.poll; выбор helper здесь не исследовался.
До повторного позиционирования popover оставался на старом месте: его левая граница была примерно в 148 px от левого края экрана. Если проверить положение в этот момент, тест увидит результат предыдущего открытия.
Число 500 здесь служит промежуточным порогом. Пока координата равна примерно 148, тест ждёт. Когда popover переместится к trigger у правого края, ожидание закончится. И правильное, и дефектное новое положение проходят этот порог, поэтому само условие left >= 500 ещё не означает, что компонент работает правильно.
Проверка правильности идёт следом: popover должен оказаться слева от trigger и остаться внутри экрана. Дефектный вариант проходит ожидание, но затем падает, поскольку его правая граница достигает 948 px. Порог 500 подобран для двух позиций этого стенда; переносить его в другие тесты без изменения нельзя.
После уточнения ожиданий, добавления вертикальных границ и исправления CSS итоговые серии повторили. Ранние логи сохранили в журнале отклонений и исключили из матрицы.
S6: три способа проверить перекрытую кнопку
Видимая кнопка «Подтвердить» размером 160 × 48 px должна была после одного нажатия указателем в центре менять статус с «Ожидает» на «Подтверждено».
Поверх центра находился декоративный слой. В исправной версии у него стояло pointer-events: none. Мутация меняла значение на auto: кнопка и handler оставались прежними, но слой перехватывал нажатия.

Исходный тест A/B:
const fixture = await mount(CoveredButtonComponent);
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement;
const output = fixture.nativeElement.querySelector('output') as HTMLOutputElement;
expect(output.textContent?.trim()).toBe('Ожидает');
await userEvent.setup().click(button);
await until(() => output.textContent?.trim() === 'Подтверждено');
Здесь userEvent импортирован из @testing-library/user-event. Handler срабатывал, статус менялся и на дефектной версии. Поэтому исходные A/S6 и B/S6 получили partial.
user-event моделирует последовательности взаимодействия и выполняет собственные проверки. Его pointerEventsCheck проверяет, имеет ли элемент или наследует ли он pointer-events: none. Testing Library: pointerEventsCheck. Отдельно документация pointer API говорит, что библиотека не определяет, возможно ли описанное действие в указанной точке layout. Testing Library: pointer.
У самой кнопки в S6 не было pointer-events: none. Проблема находилась в другом слое над ней. Переданный в click target оставался кнопкой, а тест после действия проверял только смену статуса. Даже настоящий Chromium не добавлял к этому коду проверки попадания указателя.
Provider click
В C действие выполнялось через page из vitest/browser. Соответствующий фрагмент:
const fixture = await mount(CoveredButtonComponent);
const output = fixture.nativeElement.querySelector('output') as HTMLOutputElement;
expect(output.textContent?.trim()).toBe('Ожидает');
await page.getByRole('button', { name: 'Подтвердить' }).click();
await until(() => output.textContent?.trim() === 'Подтверждено');
Click вызывался без force. Исправная версия проходила. На дефектной provider сообщал, что .decoration перехватывает pointer events, и не мог выполнить действие. Проверка actionability обнаружила препятствие перед нажатием.
Отдельный B-тест с elementFromPoint
После основной серии мы добавили exploratory проверку B с document.elementFromPoint. Метод возвращает верхний элемент в заданной точке. Центр вычислялся из реального прямоугольника кнопки. Ниже функция из этого теста:
const hitAtCentre = () => {
const rect = button.getBoundingClientRect();
expect(rect.width).toBeGreaterThan(0);
expect(rect.height).toBeGreaterThan(0);
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const target = document.elementFromPoint(x, y);
return { x, y, target, hitButton: target === button || button.contains(target) };
};
Проверку выполняли до и после synthetic click. В исправной версии в центре (80, 24) находилась кнопка или её потомок. В дефектной возвращался .decoration, хотя handler по-прежнему менял статус. Тест требовал hitButton: true в обоих измерениях и обнаружил перекрытие.
| Проверка S6 | Наблюдение |
|---|---|
| Исходные A/B: synthetic click и статус | Handler меняет состояние после доставленного события. |
C: provider click без force и статус |
Действие проходит actionability, выполняется, статус меняется. |
| B + hit test (exploratory, вне основной матрицы) | В центре до и после click находится кнопка или её потомок. Дефектный слой нарушает это утверждение. |
В B теперь было два разных теста. Исходный проверял только смену статуса после synthetic click. Поздний дополнительно спрашивал, какой элемент находится в точке нажатия, и обнаружил перекрытие.
Основная таблица описывает первый набор тестов. Поэтому в ней B/S6 остаётся partial, а результат добавленного теста указан отдельно. Это не ограничение Browser Mode: браузер предоставлял данные для hit test, но исходный тест их не запрашивал. Проверка одной точки при этом не равна всему набору actionability checks provider.
В этом сценарии среда предоставляла сведения о геометрии, действие определяло способ взаимодействия, а test oracle задавал, какой результат считать правильным. Одной смены статуса хватало для проверки handler, но не для заявленного нажатия указателем.
Как проверить собственный тест
Посмотрите, какая поломка всё ещё оставит ваш тест зелёным:
| Что уже проверяет тест | Что может остаться незамеченным | Какое наблюдение добавить |
|---|---|---|
| Диалог появился | Фокус остался на opener | Проверить activeElement после открытия и возврат фокуса после закрытия. |
| Компонент реагирует на переданный размер | Observer подписан на другой контейнер | Реально изменить размер нужного контейнера и дождаться реакции. |
| Popover существует | Он не сменил сторону или вышел за viewport | Дождаться обновления layout, проверить положение и границы. |
| После synthetic click изменился статус | Другой слой мешает нажать кнопку | Выполнить provider action либо явный browser hit test для заданной точки. |
Для формы, программного фокуса и конкретного порядка Tab сначала проверьте, хватает ли наблюдений существующему jsdom-тесту. В клавиатурном сценарии выполняйте последовательность Tab: вызовы .focus() на ожидаемых элементах не проверяют переходы.
Когда условие зависит от фактического размера, положения или браузерного уведомления, проверьте эти данные в браузере. Mock может дополнять такую проверку тестом логики. Название и описание теста должны отражать ту часть поведения, которую он подтверждает.
Любую выбранную поломку проверяйте вместе с исправной версией. Если обе падают, сначала нужно разобраться с тестом и его условиями.
Условия эксперимента
Тесты запускали на Windows 11. Для B и C использовали headless Chromium с viewport 800 × 600 CSS px и scale factor 1.
Мы сами выбрали шесть поломок и проверили их на небольшом стенде. Результаты объясняют, что проверяют эти тесты. Они не говорят, как часто такие ошибки встречаются в рабочих приложениях и сколько ошибок найдёт тот же подход в другом проекте. На других операционных системах и браузерных движках мы тесты не запускали. Большие наборы тестов и E2E-потоки тоже не проверяли.
Скорость по этим материалам сравнивать нельзя: старые локальные прогоны выполняли разный объём работы. Память, работу в CI и затраты на миграцию мы не измеряли. О flakiness выводов тоже нет. Один ранний холодный запуск S4/S5 закончился по таймауту; причину мы не установили.
Перед статьёй мы сверили сохранённые результаты и логи. Независимого повторения с чистой установкой на другой машине не было.
Версии и сохранённые результаты
На стенде использовали следующие версии:
| Инструмент | Версия |
|---|---|
| Angular | 22.2.0 |
| Vitest | 5.0.2 |
@vitest/browser-playwright |
5.0.2 |
| jsdom | 30.1.1 |
| Playwright | 1.63.0 |
@testing-library/user-event |
14.6.7 |
| Chromium | 153.0.8010.12, revision 1243 |
| Node.js | 24.19.0 |
До итоговой серии увеличили время ожидания условия до 5 секунд, а таймаут теста до 15 секунд.
Мы сохранили протокол, код, патчи, lockfile, конфигурацию и результаты запусков. Чтобы проверить таблицу, заново разобрали архив и сверили 21 запуск с JSON-отчётами: 3 запуска исправных версий и 18 с мутациями. Результат не изменился. В S6 посмотрели и текстовые логи: provider сообщал, что слой перехватывает нажатие. Одной отметки «тест упал» для такого объяснения было бы мало.
Итог
В исходных A/B-тестах S6 handler срабатывал, и перекрытая кнопка проходила тест. Перекрытие обнаружилось, когда мы стали проверять доступность нажатия. С этого стоит начинать выбор среды: что должен суметь сделать пользователь и какое наблюдение в тесте это подтверждает.