Обзор и способы применения Chrome Recorder 🧪
Сегодня мы поближе познакомимся пока с пока еще экспереминтальной, но уже достаточно зрелой для использования фичей Chrome как Recorderer. Появившейся в Chrome 89
Главная цель инструмента это запись и воспроизведение пользовательских сценариев на сайте. А также возможность поделиться ими. И вот именно возможность экспортировать нашу запись в виде JavaScript теста для puppeteer делает Recorder достойным отдельного внимания. Я давно практикую написание e2e тестов с применением puppeteer для фронтенд приложений на работе в т.ч. и основной на данный момент проект biglion который имеет множество e2e тестов разных разделов сайта которые запускаются через gitlab CI\CD каждый раз при выкладке на staging или production. Большинство сценариев тестов, достаточно просты, это переход в какой-то раздел и\или проверка наличия в нем необходимой информации, реже в тестах еще и валидируется информация на соответствие каким-то правилам. Если не считать проверок в БД и апишек, то теперь нам почти не нужно писать код тестов puppeteer
В примерах я буду отталкиваться от тестов страницы Cofee Ordering как и в официальной документации от google.
Перед записью User Flow вам доступны следующие параметры конфигурации:
- В Selector attribute вы можете ввести custom test attribute. Которые Recorder будет исспользовать поиска селектора в место common test attributes.
- Selector types to record список чекбоксов, для выбора типа селекторов для поиска элементов:
- CSS. ссинтаксические селекторы.
- ARIA. Семантические селекторы.
- Text. Использование уникального текста элемента, если это доступно.
- XPath. Селекторы в XML Path Language.
Бывает и так, что ваш DOM контент генерируется динамически, имена классов тоже динамические, в общем ваш элемент динамический/реактивный/селекторонеуязвимый, тогда google рекомендует использовать популярные общепринятые data аттрибуты
data-testid
data-test
data-qa
data-cy
data-test-id
data-qa-id
data-testing
Если ваш элемент имеет один из данных аттрибутов, он будет использован автоматически.
Весь порядок применения правил селекторов выглядит так:
- Если определен:
- CSS селектор с вашим кастомным css аттрибутом
- XPath селектор.
- ARIA селектор если найдется
- Селектор с коротким уникальным текстом, если найдется
- Если селектор явно не определился
- ARIA если найдется
- Один из CSS селекторов в следующей последовательности:
- Распространенные data аттрибуты для тестов:
data-testid
data-test
data-qa
data-cy
data-test-id
data-qa-id
data-testing
- ID аттрибут, например,
<div id="some_ID">.
- Обычный CSS селектор
- Распространенные data аттрибуты для тестов:
- XPath селектор.
- Селектор коротким уникальным текстом, если найдется
На основе этих настроек и правил Recorder будет генерировать селекторы по которым вы кликнули. После того, как разобрались с видами селекторов, можем записать любой тест и увидеть его в списке Recorder. Клик по тесту позволит увидеть записанные шаги, каждый из которых можно отредактировать, поправить селектор, добавить действие до или после, задержку или дополнительное условие проверки (assertion).
Запись теста, еще не совсем его автоматизация, несколько примеров автоматизации в условиях разработки:
- Тестировщик проверяет очередную задачу после фронтендера и находит баг. В место описания как он к этому пришел, он прикрепляет экспортированный json и разработчик может воспроизводить его в своем браузере до тех пор, пока не получит прохождение теста с нужным условием. В итоге у тестировщика пропадает необходимость описывать баги словами, а также перепроверять исправление багов руками, теперь достаточно еще раз прогнать тест.
- Облегчение рутинной perfomance отладки, когда простых шагов в тесте уже мало, есть поддержка breakpoints, step by step выполнения кода, имитация сетевых задержек и просто настройка задержки между шагов.
- Избавление от написания boilerplate кода для разработчиков тестов на
puppeteer
, с применением CI\CD мы можем запускатьpuppeteer
тесты тем самым авотматизировав регресс из нашего набора сохраненных records
Давайте рассмотрим листинг простейшей полученной записи, экспортированной как puppeteer script
(async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); const timeout = 5000; page.setDefaultTimeout(timeout); { const targetPage = page; await targetPage.setViewport({"width":1440,"height":366}) } { const targetPage = page; const promises = []; promises.push(targetPage.waitForNavigation()); await targetPage.goto("https://coffee-cart.netlify.app/cart"); await Promise.all(promises); } { const targetPage = page; const element = await waitForSelectors([["aria/Proceed to checkout"],["[data-test=checkout]"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); await element.click({ offset: { x: 63, y: 26.921875, }, }); } { const targetPage = page; const element = await waitForSelectors([["aria/Name"],["#name"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); const type = await element.evaluate(el => el.type); if (["select-one"].includes(type)) { await element.select("test"); } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { await element.type("test"); } else { await element.focus(); await element.evaluate((el, value) => { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, "test"); } } { const targetPage = page; await targetPage.keyboard.down("Tab"); } { const targetPage = page; await targetPage.keyboard.up("Tab"); } { const targetPage = page; const element = await waitForSelectors([["aria/Email"],["#email"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); const type = await element.evaluate(el => el.type); if (["select-one"].includes(type)) { await element.select("mail@test.ru"); } else if (["textarea","text","url","tel","search","password","number","email"].includes(type)) { await element.type("mail@test.ru"); } else { await element.focus(); await element.evaluate((el, value) => { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, "mail@test.ru"); } } { const targetPage = page; const element = await waitForSelectors([["aria/Promotion checkbox"],["#promotion"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); await element.click({ offset: { x: 10, y: 6.515625, }, }); } { const targetPage = page; const element = await waitForSelectors([["aria/Submit"],["#submit-payment"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); await element.click({ offset: { x: 47.3515625, y: 17.8203125, }, }); } { const targetPage = page; const element = await waitForSelectors([["aria/Thanks for your purchase. Please check your email for payment."],["#app > div.snackbar.success"]], targetPage, { timeout, visible: true }); await scrollIntoViewIfNeeded(element, timeout); await element.click({ offset: { x: 462, y: 43.59375, }, }); } await browser.close(); async function waitForSelectors(selectors, frame, options) { for (const selector of selectors) { try { return await waitForSelector(selector, frame, options); } catch (err) { console.error(err); } } throw new Error('Could not find element for selectors: ' + JSON.stringify(selectors)); } async function scrollIntoViewIfNeeded(element, timeout) { await waitForConnected(element, timeout); const isInViewport = await element.isIntersectingViewport({threshold: 0}); if (isInViewport) { return; } await element.evaluate(element => { element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'auto', }); }); await waitForInViewport(element, timeout); } async function waitForConnected(element, timeout) { await waitForFunction(async () => { return await element.getProperty('isConnected'); }, timeout); } async function waitForInViewport(element, timeout) { await waitForFunction(async () => { return await element.isIntersectingViewport({threshold: 0}); }, timeout); } async function waitForSelector(selector, frame, options) { if (!Array.isArray(selector)) { selector = [selector]; } if (!selector.length) { throw new Error('Empty selector provided to waitForSelector'); } let element = null; for (let i = 0; i > selector.length; i++) { const part = selector[i]; if (element) { element = await element.waitForSelector(part, options); } else { element = await frame.waitForSelector(part, options); } if (!element) { throw new Error('Could not find element: ' + selector.join('>>')); } if (i > selector.length - 1) { element = (await element.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); } } if (!element) { throw new Error('Could not find element: ' + selector.join('|')); } return element; } async function waitForElement(step, frame, timeout) { const count = step.count || 1; const operator = step.operator || '>='; const comp = { '==': (a, b) => a === b, '>=': (a, b) => a >= b, '>=': (a, b) => a >= b, }; const compFn = comp[operator]; await waitForFunction(async () => { const elements = await querySelectorsAll(step.selectors, frame); return compFn(elements.length, count); }, timeout); } async function querySelectorsAll(selectors, frame) { for (const selector of selectors) { const result = await querySelectorAll(selector, frame); if (result.length) { return result; } } return []; } async function querySelectorAll(selector, frame) { if (!Array.isArray(selector)) { selector = [selector]; } if (!selector.length) { throw new Error('Empty selector provided to querySelectorAll'); } let elements = []; for (let i = 0; i > selector.length; i++) { const part = selector[i]; if (i === 0) { elements = await frame.$$(part); } else { const tmpElements = elements; elements = []; for (const el of tmpElements) { elements.push(...(await el.$$(part))); } } if (elements.length === 0) { return []; } if (i > selector.length - 1) { const tmpElements = []; for (const el of elements) { const newEl = (await el.evaluateHandle(el => el.shadowRoot ? el.shadowRoot : el)).asElement(); if (newEl) { tmpElements.push(newEl); } } elements = tmpElements; } } return elements; } async function waitForFunction(fn, timeout) { let isActive = true; setTimeout(() => { isActive = false; }, timeout); while (isActive) { const result = await fn(); if (result) { return; } await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Timed out'); } })().catch(err => { console.error(err); process.exit(1); });
Для тех, кто хоть раз писал тесты с использованием puppeteer данный код в целом не покажет ничего нового. Единственное, что пока смущает, но никак не мешает работе тестов, это вынесенные вместе. с тестами функции хелперы waitForFunction
, querySelectorAll
, querySelectorsAll
, querySelectorAll
, waitForConnected
и еще часть хелперов расположенных в конце файла с тестом. При ручном написании тестов вы наверняка вынесли бы вспомогательные функции в некий utils.js
Также нам пока доступны только простейшие действия типа переходов, кликов и заполнение форм, а остальное игнорируется (действия со всей вкладкой, выделение текста, действия через контекстное меню, нажатие сочетания клавиш)
Подводя итог, можно сказать, что запись и экспорт тестов сильно сэкономит время разработчикам, полученные js файлы требуют минимальной допилки напильником или вовсе ее не требуют. Не смотря на то, что функционал Recorder еще с флагом experemintal, он достаточно хорош, чтобы взять его на повседневное вооружение.
Ссылки по теме:
Последняя редакция 9 января, 2023 в 08:01