В статье я разберу, как писать тесты, используя Playwright. И покажу все на собственном примере.
Мы разрабатываем PaaS-облако для простого хостинга веб-приложений Amvera. И когда наш продукт стал сложным, мы столкнулись с тем, что релизы стали затаскивать на прод баги. У нас можно произвести деплой через привязку репозитория или загрузку файлов в интерфейсе. В отличие от VPS, наш сервис должен из коробки делать сборку, настройку NGINX, доменов с SSL и много что еще, чтобы пользователь не думал об администрировании. А просто пушил обновления и получал работающий бот или сайт. И вот это “много что еще” должно работать и не ломаться. Чтобы не “тестировать на пользователях” и минимизировать человеческий фактор, мы решили автоматизировать самые частые пользовательские сценарии, используя Playwright.
Приложения с Playwright мы разместили в нашем же облаке Amvera, как обычные проекты. Они регулярно тестируют основную функциональность и дают сигнал, если что-то пошло не так. Теперь, если что-то и ломается, мы об этом быстро узнаем и чиним. В этой статье я приведу инструкцию, как использовать Playwright, чтобы автоматизировать тестирование.
Мы выбрали Kotlin в качестве языка разработки и Playwright в роли системы запуска браузеров в безголовом режиме. Выбор объясняется просто — это язык, на котором написан наш сервис. Надеюсь, статья поможет и Java-разработчикам.
Давайте перейдем к сути и начнем писать тесты на Playwright.
Как подключить Playwright
Для инициации наших флоу тестирования я создал сервисный класс FlowExecutorService. Он содержит логику инициации Playwright и некоторые сервисные методы, часто используемые в каждом флоу тестирования. В его конструктор мы будем передавать значения из переменных окружения:
@Serviceclass FlowExecutorService( private val context: ApplicationContext, @param:Value("\${playwright.headed}") private val headed: Boolean, @param:Value("\${amvera.test-project-name}") internal val amveraTestProjectName: String, @param:Value("\${amvera.username}") internal val amveraUsername: String, @param:Value("\${amvera.password}") internal val amveraPassword: String, @param:Value("\${amvera.host}") internal val amveraHost: String, @param:Value("\${amvera.isAmvera}") private val isAmvera: Boolean)
К ним относятся креды (логин и пароль пользователя, от лица которого мы будем работать) и хост ЛК (тест можно запускать как на продовом сервере, так и на тестовых стейджах) как в безголовом режиме, так и с отображением окна браузера.
Дальше мы инициализируем экземпляр PlayWright и стартуем экземпляр браузера Хром:
// Создаем область видимости для фоновых задачprivate val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)// Инициализируем один раз при старте сервиса. by lazy создаст объект при первом вызове.private val playwright by lazy { Playwright.create() }// Один экземпляр браузера для всего приложения.// Это экономит 100-200 МБ оперативной памяти на каждом запуске флоу.private val browser by lazy { playwright.chromium().launch( com.microsoft.playwright.BrowserType.LaunchOptions() .setHeadless(!headed) .setArgs(listOf("--start-maximized")) )}val activeTasks = ConcurrentHashMap<String, String>()
В качестве параметра запуска браузера передаем указание развернуть окно на весь экран. Так же подключаем библиотеку в зависимостях Gradle:
dependencies { implementation("com.microsoft.playwright:playwright:1.56.0")}
И инициируем задачу по закачке браузеров там же, в build.gradle.kts
// Создаем задачу для установки браузеровtasks.register("installPlaywright") { group = "verification" description = "Downloads Playwright browsers into the project directory" doLast { project.javaexec {// Используем classpath проекта, где уже есть playwright.jar classpath = sourceSets["main"].runtimeClasspath mainClass.set("com.microsoft.playwright.CLI") args = listOf("install")// Указываем путь внутри build/libs// Мы используем layout.buildDirectory, чтобы путь был корректным на любой ОС val browsersPath = File(project.layout.buildDirectory.asFile.get(), "libs/pw-browsers") environment("PLAYWRIGHT_BROWSERS_PATH", browsersPath.absolutePath) } }
При завершении исполнения нашего проекта используется graceful shutdown. Для этого указываю в application.yml инструкцию:
server: # Включаем грациозное завершение shutdown: graceful
И прописываем в нашем основном сервисе FlowExecutorService метод, который будет освобождать ресурсы перед завершением проекта через директиву @PreDestroy
@PreDestroyfun shutdownService() { log.info("Завершение работы сервиса: остановка всех запущенных корутин...") browser.close() playwright.close() serviceScope.cancel()
Как в Playwright отслеживать протекание каждого флоу?
Самым простым оказалось просто писать результаты прохождения каждого этапа теста в логи. Для каждого запуска флоу создается некоторый лог-файл с именем, включающим дату-время запуска и название флоу. Эти лог-файлы коллекционируются в специальной папке и их список формируется по содержанию этой папки для отображения в веб-странице по адресу эндпойнта главной страницы проекта “/”
Чтобы папка с логами не разрасталась безгранично со временем, при запуске проекта запускается метод очистки старых логов (старше 30 дней от текущей даты):
private fun cleanOldLogs() { // Определяем среду через переменную окружения val isAmvera = System.getenv("IS_AMVERA")?.toBoolean() ?: true val logDirPath = System.getenv("LOG_PATH")?: "test_results" log.warn("isAmvera $isAmvera") log.warn("logDirPath $logDirPath") val logDir = File(logDirPath) if (!logDir.exists()) return val monthAgo = Instant.now().minus(30, ChronoUnit.DAYS) var deletedCount = 0 logDir.listFiles()?.forEach { file -> if (file.isFile && Instant.ofEpochMilli(file.lastModified()).isBefore(monthAgo)) { if (file.delete()) { log.info("Удален старый лог-файл: ${file.name}") deletedCount++ } } } if (deletedCount > 0) { log.info("Очистка завершена. Удалено файлов: $deletedCount") } else { log.info("Старых логов для удаления не найдено.") }}
На заглавном эндпойнте мы отображаем список доступных для запуска флоу автотестов и под ними список логов — результатов запуска этих флоу в прошлом с итоговым статусом выполнения — “Успешно”, “С ошибкой” или “В процессе” – для свежезапущенных флоу. Статусы выполнения хранятся в пределах сессии работы проекта, при перезапуске для старых логов статусы отображаться не будут, но и потребности их видеть в этом списке обычно уже не возникает.
Для управления именами лог-файлов я использовал конфигурацию logback через logback-spring.xml с использованием аппендера SIFT для логгера с именем FLOW_FILE_LOGGER (и именно этот логгер я буду указывать в классах с декларированием методов самого флоу. Остальные логи не формируют файлов, идут просто в STDOUT):
<configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss} %highlight(%-4level) %-50msg - %-15logger{15}%n</pattern> </encoder> </appender> <appender name="SIFT" class="ch.qos.logback.classic.sift.SiftingAppender"> <discriminator> <key>logFileName</key> <defaultValue>application</defaultValue> </discriminator> <sift> <appender name="FILE-${logFileName}" class="ch.qos.logback.core.FileAppender"> <file>${LOG_PATH:-test_results}/${logFileName}.log</file> <append>true</append> <encoder> <pattern>%d{HH:mm:ss} %-4level %-50msg%n</pattern> </encoder> </appender> </sift> </appender> <root level="INFO"> <appender-ref ref="STDOUT" /> </root> <logger name="FLOW_FILE_LOGGER" level="INFO" additivity="false"> <appender-ref ref="STDOUT" /> <appender-ref ref="SIFT" /> </logger></configuration>
Аутентификация пользователя Playwright
Для Playwright система аутентификации не принципиальна, поскольку мы просто находим и заполняем нужные поля на странице логина и затем нажимаем кнопку “Войти”. Для этого я реализовал в сервисе FlowExecutorService метод pushLogin:
internal suspend fun pushLogin(page: Page) { // Начинаем навигацию с https://cloud.amvera.ru // Playwright автоматически перейдет на https://id.amvera.ru/ log.warn("ТЕСТИРУЮ $amveraHost") page.navigate(amveraHost) // Вводим логин и пароль log.info("Используем логин: $amveraUsername") page.fill("#username", amveraUsername) page.fill("#password", amveraPassword) // Нажимаем кнопку "Войти" page.click("#kc-login") // Ждем переадресации на дашборд // Ждем, пока URL перестанет быть id.amvera.ru и вернется на cloud.amvera.ru page.waitForURL("${amveraHost}/projects") log.info("Успешно залогинились. Текущая страница: ${page.title()}") takeScreenshot(page, "login_success.png") log.info("Скриншот логина сохранен как login.png")
В метод передаем переменную типа Page, которую мы создаем в методе executeFlow() нашего основного класса FlowExecutorService таким образом:
/*** Запускаем основные флоу тестирования* */fun executeFlow(flow: IFlow): Job { // Создаем диспетчер, который привязан к одному конкретному потоку для этого флоу return serviceScope.launch { val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("MM-dd-HH-mm-ss")) val fileName = "${timestamp} ${flow.name}.log" activeTasks[fileName] = "Init" val contextOptions = Browser.NewContextOptions().apply { if (headed) { // В режиме с окном — разрешаем браузеру самому решать размер (подхватит maximized) setViewportSize(null) } else { // В фоновом режиме — жестко задаем Full HD, чтобы верстка не поехала setViewportSize(1920, 1080) } } val context = browser.newContext(contextOptions) // Начинаем запись трейса context.tracing().start(Tracing.StartOptions() .setScreenshots(true) .setSnapshots(true) .setSources(true)) val page: Page = context.newPage() // Устанавливаем таймаут для всех действий (click, fill, и т.д.) page.setDefaultTimeout(MAX_WAIT_TIME_MS) // Устанавливаем таймаут для всех ожиданий (waitForSelector и т.д.) page.setDefaultNavigationTimeout(MAX_WAIT_TIME_MS) try { withContext(MDCContext(mapOf("logFileName" to fileName.removeSuffix(".log")))) { log.info("--- СТАРТ ФЛОУ в фоне: ${flow.name} ---") activeTasks[fileName] = "Running" flow.execute(this@FlowExecutorService, page) activeTasks[fileName] = "Success" log.info("--- ФЛОУ ЗАВЕРШЕН УСПЕШНО ---") } } catch (e: Throwable) { withContext(MDCContext(mapOf("logFileName" to fileName.removeSuffix(".log")))) { activeTasks[fileName] = "Error" log.error("Критическая ошибка ${e.message}") log.error("Делаю скрин fatalerror.jpg") takeScreenshot(page,"fatalerror-${flow.name}.jpg") } } finally { page.close() context.close() } }}
IFlow в нашем случае служебный интерфейс, который должны имплементировать все классы flow, которые можно будет запустить методом executeFlow:
interface IFlow { val name: String suspend fun execute(runner: FlowExecutorService, page: Page)}
Там хранится название и есть метод запуска флоу execute
Также в основной класс FlowExecutorService я вынес функции делающие скриншоты происходящего в браузере безобразия, чтобы даже в безголовом режиме мы могли понимать что вызывает падение теста. Без них, например, пришлось бы долго ломать голову – почему тест падает в самых неочевидных местах где казалось бы, ничего не мешает нормальному протеканию флоу. Например, причиной могут быть всплывашки с уведомлениями, которые закрывают собой часть кнопки, что будет выбивать Playwright по таймаутам. Иногда они появляются, иногда нет, и это может сильно все запутать.
В этом случае вам пригодится функция, создающая скриншот всей страницы:
internal fun takeScreenshot(page: Page, filename: String) { val screenshotPath = Paths.get(getScreenshotsDir(), filename) // Создаем директорию 'screenshots', если она не существует Files.createDirectories(screenshotPath.parent) // Делаем скриншот, используя опцию setFullPage val fullPage = true page.screenshot( Page.ScreenshotOptions() .setPath(screenshotPath) .setFullPage(fullPage) // Скриншот всей длинной страницы ) log.info("Скриншот сохранен (FullPage: $fullPage): ${screenshotPath.toAbsolutePath()}")
Так же в FlowExecutorService можно вынести часто используемые во флоу методы. Например, для меня оказался важным метод, ожидающий отображение определенного статуса текущего проекта в цикле waitForStatus():
/** * В цикле ждем нужный статус и уведомляем об изменениях * */ internal suspend fun waitForStatus(needStatus: ProjectStatus, page: Page) { log.info("Ожидаем статус ${needStatus.text}") val startTime = System.currentTimeMillis() val timeoutLimit = MAX_WAIT_TIME_MS*10 page.waitForTimeout(5000.0) // Даем 5 секунд на начало процесса и смену статуса while (System.currentTimeMillis() - startTime < timeoutLimit) { // Считываем текущий статус val currentStatus = getCurrentStatus(page) if (currentStatus == needStatus) { log.info("✅ Успех: Достигнут статус '${needStatus.text}'") return } if (currentStatus == null) { log.error("Не удалось считать статус") delay(1000)// throw RuntimeException("Статус не читается") } log.info("Текущий статус: ${currentStatus?.text}. Ждем...") // освобождаем поток, пока спим delay(POLL_INTERVAL_MS) } // Ошибка кидается ТОЛЬКО если вышли из цикла по времени throw RuntimeException("Таймаут: Статус '${needStatus.text}' не достигнут за ${timeoutLimit/1000} сек.") }
Класс выполняющий тестирование интерфейса
Задумка изначально была в том, чтобы иметь возможность проводить тестирование различных сценариев пользовательского опыта. Поэтому для каждого сценария нужно создать свой класс флоу, и для каждого такого флоу будет создаваться свой лог-файл с именем этого сценария. Поскольку сценарии в ТЗ довольно сложные, то пока они называются просто flow1, flow2 и т.д. а что именно делается под капотом – описано в специальном гугл-док файле.
В начале каждого класса флоу идет анонсирование логгера FLOW_FILE_LOGGER
private val log = KotlinLogging.logger("FLOW_FILE_LOGGER")
Как мы помним, именно для этого логгера создаются лог-файлы в специальном каталоге для коллекции. Все остальные логгеры пишут просто в STDOUT
Дальше мы обьявляем сам класс флоу и задаем название, которое будет участвовать в формировании имени файла логов по этому флоу:
@Componentclass Flow1: IFlow { override val name = "Flow1" val projectName = "Flow1" //не использовать нижнее подчеркивание, будет стопиться на этапе ввода названия
Так же там определяются различные служебные переменные, например я определил projectName — имя проекта, над которым будет работать наш тест. Ну и конечно же реализуем метод execute() нашего имплементированного интерфейса IFlow:
override suspend fun execute(runner: FlowExecutorService, page: Page) { log.info("Вводим креды") runner.pushLogin(page) log.info("Закроем все уведомления если есть") delay(5000) runner.closeAllNotifications(page)
Тут мы залогиниваемся от лица Playwright и закрываем всевозможные всплывашки уведомлений, чтобы они нам не мешали в дальнейшем. В качестве runner мы используем тот самый FlowExecutorService, куда мы вынесли часто используемые стандартные методы работы:
log.info("Ждем перехода на страницу приложений")// Ждем перехода на страницу проекта/сборкиpage.waitForURL("${runner.amveraHost}/projects/applications") // Ждем URLlog.info("-->Перешли в список проектов")log.info("Проверим, существует ли проект в списке, если да - перейдем в него")val projectLocator2 = runner.getProjectLocator(projectName, page)require(projectLocator2.count() > 0) { "ℹ️ Проект '$projectName' не создался."}projectLocator2.click()log.info("Проект успешно найден и открыт")//обработаем редкий статус Приложение не развертывалось сразу после созданияval status = runner.getCurrentStatus(page)if(ProjectStatus.NOTDEPLOYED==status) { delay(1000) val status2 = runner.getCurrentStatus(page) if(ProjectStatus.NOTDEPLOYED==status) { log.warn("Статус Приложение не развертывалось после создания проекта!") log.warn("Принудительно запустим пересборку!") runner.pushBuildButton(page) }}//подождем пока проект соберется и запустится, замерим времяwaitSuccessWithMeasureTime(page, runner)
Мы пытаемся найти его в общем списке проектов в личном кабинете и пытаемся запустить и измерить время запуска проекта с помощью waitSuccessWithMeasureTime.
Затем мы идем по нашей схеме работы пользователя — загружаем файлы в репозиторий проекта, в постоянное хранилище, проверяем что файлы после загрузки отображаются в интерфейсе, загружаем ошибочный файл конфигурации и смотрим как отрабатывает анализатор ошибок, что всплывают все необходимые уведомления об ошибках валидации, что они попадают в лог сборки проекта:
//загрузим дополнительный файл в репозиторийuploadAdditionalFileToRepository(page)//загрузим файл в постоянное хранилищеuploadAdditionalFileToWarehouse(page, runner)//проверим что загруженные файлы отображаются в репозитории и пост. хранилищеcheckFilesArePresent(page)//загрузим ошибочный файл конфигурации, проверим сообщения об ошибках валидации// и ошибку при сборке при лишних полях (в т.ч. уведомление в логе)uploadWrongConfigToRepositoryAndTestAlerts(page,runner)//теперь загрузим верную конфигурацию и пересоберем проект, убедимся что запустилсяuploadGoodConfigToRepositoryAndRebuild(page,runner)
В конце мы загружаем заведомо правильный файл конфигурации проекта Амвера и проверяем что с ним проект успешно собирается и запускается.
На последок мы проверяем что проект получается успешно перезапустить, проверяем работу статического анализатора и останавливаем собранный тестовый проект, чтобы он не расходовал ресурсы после завершения автотеста:
// Перезапустим проектrunner.pushRerunBtton(page)//Проверим работу статического анализатораcheckStaticAnalizator(page)//Остановим проектrunner.pushStopButton(page)
В статанализаторе, как я уже писал, мы проверяем появление уведомлений о хранении токенов в теле файла с кодом (мы рекомендуем выносить их в переменные окружения с типом Секрет в разделе Переменные проекта), а также уведомлений о типовой ошибке многих наших клиентов — писать изменяемые данные, например БД, мимо нашего постоянного хранилища.
Важные моменты при Playwright тестировании
С полным кодом этого проекта вы можете ознакомиться в нашем гитхаб репозитории по ссылке https://github.com/amvera-academy/playwright-autotests, покажу отдельные моменты которые показались мне достойными вашего внимания. Например, так мы заполняем поля конфигурации и валидируем некоторые проверки на неверные пути:
fun fillAndCheckConfigStepInMaster(page: Page) { log.info("Выбираем Runtime: Python") // кликаем по Combobox для открытия списка // Находим Python в появившемся списке val runtimeCombobox = page.getByText("Выберите окружение").locator("~ button[role=combobox]") runtimeCombobox.click() val pythonOptionLocator = page.getByRole(AriaRole.OPTION, Page.GetByRoleOptions().setName("Python")) pythonOptionLocator.click() //проверим заполнение дефолтных полей конфига checkDefaultConfigFieldsFillingInMaster(page) //проверим валидацию полей конфига на неверные значения checkValidationConfigInMaster(page) //пропишем нужные поля в конфиге чтобы все собралось fillConfigInMaster(page)
А так мы проверяем на введение неверных данных в конфиге:
fun checkValidationConfigInMaster(page: Page){ log.info("-->Проверяем валидацию полей в мастере нового проекта на этапе заполнения конфигурации") // Путь к зависимостям val requirementsLocator = page.locator("input[name='build.requirementsPath']") val scriptNameLocator = page.locator("input[name='run.scriptName']") val actualRequirements = requirementsLocator.inputValue() val wrongRequirements = "missing-requirements.txt" val actualScriptName = scriptNameLocator.inputValue() val wrongScriptName = "missing_script.py" log.info("Вводим неверное имя requirements.txt: $wrongRequirements") requirementsLocator.fill(wrongRequirements) // Ищем сообщение об ошибке. В HTML оно имеет id, заканчивающийся на -form-item-message // Мы можем найти его по тексту, так как он уникален для этого блока val requirementsError = page.locator("p:has-text('В коде не найден указанный файл, измените название или проверьте путь. Создать файл requirements.txt можно на этапе загрузки файлов Upload files или по Инструкции по созданию requirements.txt')") scriptNameLocator.click() // Ждем появления ошибки (на случай задержки валидации) requirementsError.waitFor(Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)) require(requirementsError.isVisible) { "ОШИБКА: Сообщение о неверном requirements.txt не появилось" } log.info(" Проверка валидации для requirementsPath пройдена") requirementsLocator.fill(actualRequirements) // Имя скрипта scriptNameLocator.fill(wrongScriptName) log.info("Вводим неверное имя скрипта: $wrongScriptName") val scriptError = page.locator("p:has-text('В коде не найден указанный файл, измените название или проверьте путь')") .filter(Locator.FilterOptions().setHasNotText("Инструкция по созданию")) // Исключаем первое сообщение, если оно вдруг совпадет по тексту requirementsLocator.click() scriptError.waitFor(Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)) require(scriptError.isVisible) { "ОШИБКА: Сообщение о неверном имени скрипта не появилось" } log.info(" Проверка валидации для имени скрипта пройдена!") scriptNameLocator.fill(actualScriptName)
Здесь мы загружаем файлы в репозиторий через перетаскивание. На самом деле мы немного схитрили:
private suspend fun loadFilesInMaster(page: Page) { log.info("Активируем вкладку Загрузить через интерфейс") val interfaceTab = page.getByRole( AriaRole.TAB, Page.GetByRoleOptions().setName("Через интерфейс") ) // Проверяем, что вкладка видна перед кликом interfaceTab.waitFor(Locator.WaitForOptions().setState(WaitForSelectorState.VISIBLE)) // Кликаем по вкладке и ждем, пока вкладка станет активной. interfaceTab.click() // Ждем, пока атрибут data-state станет "active" (это надежнее, чем waitForLoadState) assertThat(interfaceTab).hasAttribute("data-state", "active", LocatorAssertions.HasAttributeOptions().setTimeout(5000.0)) //Загружаем файлы // ВАЖНО: PlayWright нужен АБСОЛЮТНЫЙ путь к файлу. val folderToUpload = Paths.get("src/main/resources/load_data") val absolutePath = folderToUpload.toAbsolutePath().toString() val filePaths = java.nio.file.Files.walk(folderToUpload, 1) // 1 - глубина (только файлы в корне) .filter { java.nio.file.Files.isRegularFile(it) } // Исключаем папки .map { it.toAbsolutePath() } // Преобразуем Path в абсолютный путь .toList() .toTypedArray() if (filePaths.isEmpty()) { log.warn("В папке 'load_data' не найдено файлов для загрузки.") return // Прекращаем выполнение, если файлов нет } log.info("Загружаем файлы из $absolutePath") page.setInputFiles("input[type=\"file\"]", filePaths) //Создаем селектор для зеленой иконки успеха (SVG с нужными классами) val successIconSelector = "svg.lucide-file-check.text-status-success" log.info("Ожидаем появления иконки успешной загрузки.") page.waitForSelector( successIconSelector, Page.WaitForSelectorOptions().setState(WaitForSelectorState.VISIBLE) ) log.info("Загрузка файлов завершена. Иконка успеха появилась.")
На самом деле мы не эмулируем тут процесс перетаскивания drag-and-drop, а просто вызываем метод page.setInputFiles(«input[type=\»file\»]», filePaths) которому передаем список путей к файлам которые надо загрузить. Но, поскольку мы тестируем не способность браузера отлавливать драг-н-дроп, а наш интерфейс, эта типовая хитрость не должна сказываться на функциональной работоспособности нашего теста.
Как выглядит hезультат тестирования на Playwright
Мне кажется, достаточно кода. Здесь хочу показать вам пару скринов, как это все выглядит в интерфейсе.
Так выглядит главное окно проекта. Вверху кнопки с названиями доступных для запуска флоу, ссылка на табличку с описаниями деталей флоу, а ниже собственно итоги запусков — ссылки на файлы логов. Имя логов состоит из даты-времени и названия флоу, а по клику на любой лог открывается его просмотрщик:
В просмотрщике лог отображается вверх ногами, самые последние записи сверху. Это сделано специально, потому что туда можно зайти когда лог еще в процессе формирования и флоу в статусе Работает. Лог динамически обновляется каждые 10 секунд, и свежая информация появляется на верху экрана а остальное уплывает все ниже и ниже. Без этого приходилось бы скроллить экран колесиком или перематывать скриптом каждый раз, на длинных логах смотрелось бы немного моргающе, как мне кажется.
Подведем итоги
Мы в Amvera довольны результатом применения Playwright. Будем писать новые флоу тестирований для других сценариев. Самым сложным оказалось поддерживать привязку локаторов к постоянно изменяющемуся тексту на странице уведомлений и предупреждений. Сначала хочется изменить немного формулировку текстов на сайте, чтобы клиентам яснее донести свой посыл. А потом приходится отлавливать, почему в этом месте начал тест падать.
Но зато в процессе можно выловить несколько неочевидных нюансов и багов, так что процесс во благо.
Надеюсь, вам настолько же окажется полезной эта информация об использовании Playwright в тестировании, на сколько нам было интересно в этом разбираться.
ссылка на оригинал статьи https://habr.com/ru/articles/1068330/