Реальные приложения не помещаются в один плоский документ. Платёжная форма приходит в iframe от Stripe, виджет карты - от стороннего сервиса, OAuth-экран открывается на чужом домене, а дизайн-система прячет разметку внутри web-components и shadow DOM. Тест, который умеет кликать только по элементам основной страницы, спотыкается ровно на самых дорогих сценариях - оплате и входе. Поэтому профессиональный E2E обязан уметь заходить за эти границы.
Начать стоит с того, чем iframe отличается от shadow DOM, потому что подходы разные. iframe - это вложенный отдельный документ со своим деревом, часто с другого домена; обычный локатор основной страницы туда не дотягивается. Shadow DOM - это инкапсулированное поддерево внутри того же документа, которым пользуются web-components; это не отдельный документ, а спрятанная часть текущего. Первое требует явного входа, второе Playwright по большей части снимает сам.
Для iframe есть frameLocator. page.frameLocator('iframe[title="Оплата картой"]') возвращает объект, внутри которого работают те же семантические локаторы, что и на странице: getByRole, getByLabel, getByText. То есть внутри фрейма вы описываете элементы ровно так же, как снаружи, - разница лишь в том, что сначала явно указываете, в какой фрейм войти. Вложенные фреймы адресуют цепочкой frameLocator, шаг за шагом.
Сам фрейм адресуют устойчивым признаком, а не порядковым номером. Атрибут title, name или доступное имя фрейма переживут перестановку виджетов на странице, тогда как nth-child по списку iframe сломается от любого изменения окружения. Это тот же принцип пользовательского контракта, что и с обычными локаторами: описывать фрейм по стабильному смыслу, а не по позиции в разметке.
Полезно один раз увидеть оба приёма рядом - вход во фрейм и работу с web-component, - чтобы не путать их при написании сценария. Ниже - клик по кнопке внутри платёжного iframe и обращение к элементу внутри кастомного компонента; к этой паре возвращаются, когда локатор основной страницы упирается в границу вложенного документа или инкапсуляции.
С shadow DOM ситуация проще, и это важное практическое облегчение. Семантические локаторы Playwright по роли и тексту по умолчанию пронизывают открытый shadow DOM: getByRole и getByText находят элемент внутри web-component так же, как в обычной разметке, без отдельного API. Для дизайн-систем на веб-компонентах это означает, что тесты пишутся привычно, а инкапсуляция остаётся деталью реализации, о которой сценарий может не знать.
У инкапсуляции есть предел, который надо держать в голове. Пронизывание работает для открытого shadow root; закрытый (mode: 'closed') недоступен ни Playwright, ни самому пользователю программно - если компонент намеренно закрыл поддерево, добраться до его внутренностей извне нельзя. Это не ограничение инструмента, а свойство платформы: закрытый shadow root спрятан от всего внешнего кода по определению.
Типичные провалы здесь узнаваемы. Искать элемент платёжной формы обычным локатором страницы и получать вечный timeout, не поняв, что он в iframe и нужен frameLocator. Адресовать фрейм порядковым номером, из-за чего тест ломается при добавлении соседнего виджета. И пытаться пробиться в закрытый shadow root вместо того, чтобы проверять компонент через его публичное поведение. Заходите в iframe через frameLocator по устойчивому признаку, а открытый shadow DOM доверяйте семантическим локаторам.
// iframe: сначала войти во фрейм по устойчивому признаку, затем обычные локаторы
const payment = page.frameLocator('iframe[title="Оплата картой"]')
await payment.getByLabel('Номер карты').fill('4242 4242 4242 4242')
await payment.getByRole('button', { name: 'Оплатить' }).click()
// открытый shadow DOM: семантический локатор пронизывает его сам
await page.getByRole('button', { name: 'Добавить в корзину' }).click()