Самая затратная часть миграции с Selenium на Playwright: не переписывание селекторов, а отладка флакующих тестов которые возникают при прямом переносе вызовов Thread.sleep() и WebDriverWait в Playwright. Авто-ожидание встроено в каждое действие Playwright, поэтому явные ожидания почти никогда не нужны в новом сьюте, а копирование их скрывает реальные проблемы вместо решения. Гайд охватывает когда миграция оправдана финансово, подход strangler fig который сохраняет зелёный CI на протяжении перехода, маппинг локаторов и page object, и проблему изоляции тестов которая вызывает случайные падения когда параллельность Playwright по умолчанию встречается с последовательными допущениями о состоянии из Selenium.

Когда мигрировать, а когда остаться

Миграция имеет реальную стоимость. Прежде чем писать хоть строчку Playwright-кода, честно проведи этот расчёт.

Для Selenium-команд миграция оправдана когда:

  • Управление драйверами сжигает время инженеров. Если несоответствия версий ChromeDriver регулярно ломают CI: это повторяющийся налог который Playwright устраняет полностью.
  • Библиотека утилит WebDriverWait + ExpectedConditions разрослась и всё равно порождает флакующие тесты.
  • Нужна мобильная эмуляция, перехват сети или мультиконтекстное тестирование которые Selenium не может предоставить чисто.
  • Команда пишет на TypeScript и хочет типизацию везде.

Миграция не оправдана когда сьют на Java или Python и команда не переходит на TypeScript (у Playwright есть биндинги для Java и Python, но глубина экосистемы и примеры сообщества слабее), когда тестируешь в браузерах которые Playwright не поддерживает (Internet Explorer, legacy Edge), или когда сьют стабилен, CI быстрый и у команды нет острой боли.

Для Cypress-команд расчёт другой. Cypress и Playwright решают одну задачу примерно на одном уровне абстракции. Мигрируй когда:

  • Нужно покрытие Safari/WebKit. WebKit-движок Playwright: единственный способ получить реальный рендеринг в браузере на Windows без macOS-машины.
  • Упираешься в платную стену параллелизации и нужен бесплатный шардинг между CI-раннерами.
  • Регулярно пишешь тесты с несколькими вкладками или кросс-доменными переходами требующие обходных решений в Cypress.
  • Нужно API-тестирование в том же фреймворке, в том же тест-рание.

Оставайся на Cypress если сьют работает и ни одной из этих стен ты не достиг. Мигрировать 300 работающих Cypress-тестов ради функции которая не нужна, чистые затраты без отдачи.

Разумное правило: если потери инженерного времени на боли текущего фреймворка превышают две недели зарплаты в год, миграция окупится в первый год.

Сдвиг ментальной модели: протокол, ожидание и выполнение

Понимание почему Playwright ведёт себя иначе ускоряет всю остальную миграцию.

Selenium взаимодействует с браузерами через протокол WebDriver: HTTP-запросы от процесса тестов к процессу браузерного драйвера, который пересылает команды браузеру. Каждое действие: туда и обратно. Вот почему Selenium медленный и почему ожидание явное: фреймворк не видит что браузер делает между командами. Playwright использует Chrome DevTools Protocol (CDP) для Chromium и аналогичные низкоуровневые протоколы для Firefox и WebKit. Соединение: постоянный WebSocket, а не HTTP на каждую команду. Процесс Playwright тесно связан с браузером и может напрямую наблюдать состояние браузера. Именно это делает авто-ожидание возможным.

При вызове await page.getByRole('button', { name: 'Submit' }).click() Playwright не кликает немедленно. Он опрашивает состояние пока элемент не окажется в DOM, не станет видимым, не перестанет перекрываться другим элементом и не будет включён, и только тогда кликает. Проверка actionability встроена в каждое взаимодействие. Явные ожидания почти никогда не нужны.

Cypress использует другой подход: запускает JavaScript внутри самого процесса браузера (а не как внешний клиент). Его модель очереди команд упорядочивает операции без await потому что очередь сама управляет порядком. Cypress кажется синхронным хотя таковым не является. Playwright убирает эту абстракцию и использует стандартный async/await: более явный, интегрируется со стандартным JavaScript-инструментарием и проще для понимания когда что-то идёт не так.

Практический вывод: при миграции удаляй утилиты ожидания. Не переноси их. Если находишь себя добавляющим явные ожидания в Playwright для исправления флакинга: это сигнал что у локатора или структуры теста есть более глубокая проблема.

Маппинг локаторов

Класс By в Selenium и API локаторов в Playwright пересекаются по возможностям но различаются по философии. Локаторы Playwright по умолчанию семантические. Они побуждают находить элементы так как их ищет пользователь: по роли и видимому тексту, а не по деталям CSS-реализации.

// Selenium: CSS-селектор
driver.findElement(By.cssSelector("button[data-testid='submit-btn']")).click();

// Selenium: XPath
driver.findElement(By.xpath("//button[contains(text(), 'Submit')]")).click();

// Playwright: семантический локатор
await page.getByRole('button', { name: 'Submit' }).click();

// Playwright: test ID (когда владеешь разметкой)
await page.getByTestId('submit-btn').click();

// Playwright: CSS как запасной вариант (валидно, но используй в последнюю очередь)
await page.locator("button[data-testid='submit-btn']").click();

Таблица маппинга распространённых паттернов:

// By.id("username")
await page.locator('#username');
await page.getByLabel('Username');  // лучше если есть лейбл

// By.name("email")
await page.locator('[name="email"]');
await page.getByLabel('Email');

// By.linkText("Forgot password?")
await page.getByRole('link', { name: 'Forgot password?' });

// By.partialLinkText("Forgot")
await page.getByRole('link', { name: /forgot/i });

// By.tagName("h1")
await page.locator('h1');

// By.className("error-message")
await page.locator('.error-message');
// или семантически:
await page.getByRole('alert');

При встрече XPath в Selenium-тестах не копируй строку XPath в page.locator(). XPath работает в Playwright, но привязывает тесты к структуре реализации. Используй миграцию как возможность заменить хрупкий XPath на getByRole или getByLabel. Тесты на семантических локаторах переживают рефакторинг значительно лучше.

Запусти npx playwright codegen https://your-app.com против своего приложения. Генератор кода пишет локаторы для каждого элемента с которым взаимодействуешь и по умолчанию выбирает getByRole и getByLabel где возможно. Используй его чтобы узнать какие семантические локаторы доступны прежде чем писать маппинги вручную.

Миграция Page Object

Page Object напрямую переводится из Selenium в Playwright. Паттерн тот же. Различия: конструктор получает Page вместо WebDriver, всё await, и локаторы определяются как объекты Locator Playwright вместо дескрипторов By.

Класс Selenium POM:

// Selenium (TypeScript-биндинги)
import { WebDriver, By, WebDriverWait, until } from 'selenium-webdriver';

export class LoginPage {
  private driver: WebDriver;
  private wait: WebDriverWait;

  constructor(driver: WebDriver) {
    this.driver = driver;
    this.wait = new WebDriverWait(driver, 10000);
  }

  async navigate() {
    await this.driver.get('https://lab.becomeqa.com/login');
  }

  async login(email: string, password: string) {
    const emailInput = await this.wait.until(
      until.elementLocated(By.cssSelector('input[name="email"]'))
    );
    await emailInput.sendKeys(email);

    const passwordInput = await this.driver.findElement(
      By.cssSelector('input[type="password"]')
    );
    await passwordInput.sendKeys(password);

    const submitBtn = await this.driver.findElement(
      By.cssSelector('button[type="submit"]')
    );
    await submitBtn.click();
  }

  async getErrorMessage(): Promise<string> {
    const errorEl = await this.wait.until(
      until.elementLocated(By.cssSelector('.error-message'))
    );
    return errorEl.getText();
  }
}

Эквивалент на Playwright:

// Playwright
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  private readonly page: Page;
  private readonly emailInput: Locator;
  private readonly passwordInput: Locator;
  private readonly submitButton: Locator;
  private readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  async navigate() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async getErrorMessage(): Promise<string> {
    return this.errorMessage.textContent() ?? '';
  }
}

Что исчезло: все вызовы WebDriverWait, обёртки until.elementLocated и метод .sendKeys (заменён на .fill). Версия Playwright короче потому что авто-ожидание которое было ручным в Selenium теперь неявное.

Одно структурное улучшение которое стоит сделать при миграции: определяй локаторы как свойства класса в конструкторе, а не инлайново в методах. Это делает локаторы легко обозримыми и обновляемыми, а объекты Locator в Playwright ленивые. Они не обращаются к DOM пока не вызовешь на них действие, поэтому определение в конструкторе не несёт затрат по производительности.

Миграция с Cypress

Миграции с Cypress на Playwright короче, но требуют отучения от модели очереди команд.

Цепочки команд vs async/await

// Cypress: без await, очередь команд
describe('Login', () => {
  it('logs in successfully', () => {
    cy.visit('/login')
    cy.get('input[name="email"]').type('user@example.com')
    cy.get('input[name="password"]').type('password123')
    cy.get('button[type="submit"]').click()
    cy.url().should('include', '/dashboard')
    cy.get('h1').should('contain', 'Welcome')
  })
})

// Playwright: стандартный async/await
import { test, expect } from '@playwright/test';

test('logs in successfully', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.getByRole('heading', { level: 1 })).toHaveText('Welcome');
});

Перехват сети

// Cypress
cy.intercept('GET', '/api/items', { fixture: 'items.json' }).as('getItems')
cy.visit('/items')
cy.wait('@getItems')

// Playwright
await page.route('**/api/items', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Item One' }]),
  });
});
await page.goto('/items');
// Явное ожидание не нужно. Авто-ожидание справляется само.

Фикстуры и настройка

// Cypress beforeEach
beforeEach(() => {
  cy.login('admin@example.com', 'password')
})

// Playwright: используй фикстуры для общей настройки
import { test as base } from '@playwright/test';

const test = base.extend<{ authenticatedPage: Page }>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

test('admin dashboard loads', async ({ authenticatedPage }) => {
  await expect(authenticatedPage.getByRole('heading')).toHaveText('Dashboard');
});

Фикстуры Playwright компонуются лучше чем паттерн beforeEach в Cypress. Их можно стекать, делать зависимыми друг от друга и скоупить на один тест или весь файл. При миграции конвертируй блоки beforeEach с логином в фикстуру storageState. Она сериализует куки и localStorage браузера после одного логина и переиспользует их между тестами без повторения UI-потока входа.

Стратегия миграции: параллельный запуск и strangler fig

Не мигрируй всё сразу. Такой подход создаёт многонедельный период когда ничего не работает и у CI нет зелёного сигнала. Используй паттерн strangler fig: запускай Playwright и существующий фреймворк рядом, мигрируя одну область функциональности за раз.

Шаг 1: Установи Playwright рядом с существующим фреймворком

npm init playwright@latest

Выбери «TypeScript», помести тесты в playwright-tests/ (не в tests/ если это директория Selenium/Cypress), и пропусти файл GitHub Actions пока.

Шаг 2: Настрой CI на запуск обоих сьютов. Пайплайн запускает Selenium (или Cypress) и Playwright. Оба должны проходить. Это сохраняет зелёный сигнал пока идёт миграция. Шаг 3: Выбери стартовый модуль. Выбери область функциональности с чёткими page object и стабильными тестами, например поток логина или процесс оформления заказа. Мигрируй этот модуль полностью: page object, тесты, тест-данные. Шаг 4: Удали старые тесты для этого модуля. Как только Playwright-версия зелёная две недели, удали Selenium/Cypress-аналоги. Не оставляй оба запущенными бесконечно. Дублирующие тесты удваивают время CI и создают накладные расходы на обслуживание. Шаг 5: Повторяй модуль за модулем пока в старом фреймворке не останется тестов. Удали его из package.json и CI-пайплайна.
Веди трекер миграции: простая таблица в Excel или Notion с именами тестовых файлов, статусом миграции (не начато / в процессе / готово / удалено) и ответственным инженером. Без него наполовину мигрированное состояние молча сохраняется месяцами.

Для больших Selenium-сьютов (1000+ тестов) рассмотри комбинированный подход: используй встроенный codegen Playwright для записи новых тестов на основные ценные потоки, и напиши скрипт миграции для механического конвертирования простых Selenium-тестов (click, fill, assert text) которые следуют предсказуемым паттернам. Механическое конвертирование не даст идиоматического Playwright, но создаст рабочую базу которую можно чистить постепенно.

Частые ловушки при миграции

Хардкоженые ожидания. Самая частая ошибка при миграции Selenium-тестов: копировать Thread.sleep() или await driver.sleep(2000) в Playwright. Эти ожидания скрывают реальные проблемы: элементы которые не actionable, анимации которые не завершились, сетевые запросы которые не разрешились. В Playwright page.waitForTimeout(2000) существует, но почти никогда не должен появляться в тестовом коде. Заменяй каждое хардкоженое ожидание явным assertion что нужный элемент находится в ожидаемом состоянии:

// Неправильно: копируем привычку из Selenium
await page.waitForTimeout(2000);
await page.getByRole('button', { name: 'Submit' }).click();

// Правильно: ждём конкретного условия
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();

Хрупкие селекторы. Миграция Selenium-теста который использовал By.xpath("//div[3]/button") с XPath скопированным дословно в page.locator() переносит хрупкость вместе с ним. Любое структурное изменение DOM ломает тест. Используй миграцию как вынуждающую функцию для замены нестабильных селекторов на семантические. Допущения о порядке тестов. Selenium-сьюты часто разделяют состояние между тестами: тест создаёт пользователя, следующий тест входит под этим пользователем. Playwright запускает тесты параллельно по умолчанию с несколькими воркерами, поэтому разделяемое состояние между тестами вызывает случайные падения которые трудно воспроизвести. Каждый тест должен создавать собственные данные и убирать за собой, или использовать storageState для повторного использования аутентификации без разделения изменяемого состояния.

// Неправильно: зависит от того что предыдущий тест создал пользователя
test('user can update profile', async ({ page }) => {
  await page.goto('/profile'); // предполагает состояние входа из предыдущего теста
  // ...
});

// Правильно: каждый тест самодостаточен
test('user can update profile', async ({ page, context }) => {
  await context.addCookies(/* auth cookies from storageState */);
  await page.goto('/profile');
  // ...
});

Неправильная работа с iframe. Паттерн driver.switchTo().frame() в Selenium имеет прямой аналог в Playwright, но достаточно другой чтобы вызвать путаницу:

// Selenium
driver.switchTo().frame(driver.findElement(By.cssSelector('iframe#payment')));
driver.findElement(By.cssSelector('input[name="card"]')).sendKeys('4242...');
driver.switchTo().defaultContent();

// Playwright
const frame = page.frameLocator('iframe#payment');
await frame.locator('input[name="card"]').fill('4242...');
// Возвращаться не нужно. frameLocator в Playwright автоматически ограничен по скоупу.

Миграция CI: обновление пайплайна

Замена Selenium Grid или Cypress Cloud на Playwright в CI прямолинейна. Playwright устанавливает браузеры как часть своей настройки и работает без отдельного процесса драйвера.

От Selenium Grid

# До: Selenium Grid с Docker
services:
  selenium-hub:
    image: selenium/hub:4
  chrome:
    image: selenium/node-chrome:4

steps:
  - name: Run Selenium tests
    run: mvn test -Dwebdriver.hub.url=http://selenium-hub:4444

# После: Playwright (внешние сервисы не нужны)
steps:
  - name: Install dependencies
    run: npm ci

  - name: Install Playwright browsers
    run: npx playwright install --with-deps chromium

  - name: Run Playwright tests
    run: npx playwright test

От Cypress Cloud с параллелизацией

# До: Cypress с платной параллелизацией Cloud
- name: Cypress run
  uses: cypress-io/github-action@v6
  with:
    record: true
    parallel: true
  env:
    CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

# После: Playwright с бесплатным встроенным шардингом
strategy:
  matrix:
    shard: [1, 2, 3, 4]

steps:
  - name: Install Playwright
    run: npm ci && npx playwright install --with-deps chromium

  - name: Run shard
    run: npx playwright test --shard=${{ matrix.shard }}/4

  - name: Upload report
    uses: actions/upload-artifact@v4
    with:
      name: playwright-report-${{ matrix.shard }}
      path: playwright-report/

Для объединения отчётов шардов в один HTML-отчёт после завершения всех шардов:

  - name: Merge reports
    run: npx playwright merge-reports --reporter html ./all-blob-reports

На этапе параллельного запуска миграции настрой CI на запуск обоих сьютов с отдельной отчётностью о падениях. Тогда флакующий Selenium-тест не блокирует прогресс миграции на Playwright, и у тебя есть чистый сигнал какой фреймворк вызывает какие падения.

Не удаляй Selenium Grid или Cypress Cloud из пайплайна пока каждый тест который запускался в старом фреймворке не мигрирован в Playwright или намеренно удалён. Удаление инфраструктуры до миграции: именно так команды остаются с непокрытой функциональностью.

FAQ

Сколько реально занимает миграция?

Для Selenium-сьюта из 500 тестов с одним выделенным инженером ожидай 4–6 недель на работу по миграции плюс ещё 2 недели стабилизации (исправление флакинга в новом фреймворке). Cypress-сьюты того же размера занимают 2–4 недели потому что паттерны локаторов и JavaScript-ментальная модель ближе. Большие сьюты (2000+ тестов) без командного владения тестами могут растянуться на месяцы. Планируй на 20–30% дольше начальной оценки.

Нужно ли переписывать каждый тест или можно что-то автоматизировать?

Механические части можно автоматизировать: замена cy.get( на page.locator(, конвертация cy.visit в await page.goto, оборачивание всего в async. Это покрывает примерно 30% работы и создаёт что-то компилирующееся. Оставшиеся 70% (замена хрупких селекторов на семантические, удаление хардкоженых ожиданий, исправление проблем с порядком тестов) требуют человеческого суждения.

Что делать с существующими классами Page Object?

Сохрани паттерн, замени импорты и конструктор. Структурная инвестиция в POM не пропадает зря. Смотри пример до/после в разделе о миграции Page Object выше. Рефакторинг механический для большинства методов.

Стоит ли одновременно мигрировать на компонентное тестирование Playwright?

Нет. Сначала мигрируй E2E-сьют. Компонентное тестирование Playwright: отдельный инструмент с отдельной кривой обучения. Попытка мигрировать два инструмента одновременно замедляет оба.

Что если некоторые тесты реально не поддаются миграции?

Оставь их в старом фреймворке. Запускай на отдельном CI-джобе. Не давай совершенному блокировать хорошее. Сьют мигрированный на 90% на Playwright значительно лучше чем сьют мигрированный на 0% потому что ждёшь переноса трёх граничных тестов.

Что происходит после миграции?

Как только сьют запущен на Playwright, появляются возможности для улучшений: добавь мокирование сети для ускорения тестов которые бьют реальные API, введи storageState для устранения повторяющихся потоков логина, включи параллельное выполнение с workers: 'auto', добавь API-покрытие через фикстуру request. Миграция: пол, а не потолок.

→ See also: Playwright в 2026 году: почему он стал фреймворком номер один | Page Object Model в Playwright: от хаоса к поддерживаемым тестам | Авторизация в Playwright через storageState (без логина в каждом тесте) | Параллельное выполнение в Playwright: workers, шарды и шардирование для ускорения