page.getByText('Success').isVisible() возвращает булево значение проверяя DOM ровно один раз: если элемент ещё не отрендерился, получаешь false даже если он появится через 200 мс. expect(page.getByText('Success')).toBeVisible() повторяет попытки до 5 секунд перед тем как упасть. Это поведение с повторными попытками и есть основа дизайна ассёртов в Playwright, а большинство ошибок у начинающих происходит из незнания у каких методов оно есть. Гайд охватывает все типы ассёртов, отрицание через not, мягкие ассёрты для сбора всех сбоев, и паттерны которые молча отключают авто-повтор.

Как работают ассёрты в Playwright

Ассёрты Playwright используют функцию expect() из @playwright/test. Это не то же самое что expect в Jest. Версия Playwright асинхронная и имеет встроенную логику повторных попыток.

import { test, expect } from '@playwright/test';

Ключевое отличие от большинства тест-фреймворков: ассёрты Playwright автоматически повторяют проверку. Когда пишешь:

await expect(page.getByText('Welcome')).toBeVisible();

Playwright не проверяет один раз. Он проверяет многократно до 5 секунд (дефолтный expect timeout), ожидая пока условие не станет истинным. Это устраняет необходимость ручных вызовов waitFor в 90% случаев.

Если условие не становится истинным в рамках таймаута, тест падает с понятным сообщением: что ожидалось и что реально существовало.

Ассёрты элементов (на основе локаторов)

Проверяют свойства конкретного элемента на странице.

toBeVisible / toBeHidden

// Элемент отрендерен и виден пользователю
await expect(page.getByText('Dashboard')).toBeVisible();

// Элемент отсутствует или присутствует но скрыт (display:none, visibility:hidden, opacity:0)
await expect(page.getByRole('dialog')).toBeHidden();

toBeHidden() истинен если элемент не существует ИЛИ существует но невидим. Используй not.toBeAttached() если нужно конкретно подтвердить что элемента нет в DOM вообще.

toHaveText / toContainText

// Точное совпадение текста (пробелы обрезаются автоматически)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('My Travel Items');

// Частичное совпадение
await expect(page.getByRole('heading')).toContainText('Travel');

// Массив: проверяем текст нескольких элементов
await expect(page.getByRole('listitem')).toHaveText(['Tokyo', 'Paris', 'London']);

// Regex: совпадение по паттерну
await expect(page.getByTestId('price')).toHaveText(/\$\d+\.\d{2}/);

toHaveText с массивом проверяет полный текст каждого элемента по порядку. Очень полезно для проверки строк таблиц или отсортированных списков.

toHaveValue

Для элементов <input>, <textarea>, <select>:

// Текстовый инпут
await expect(page.getByLabel('Email')).toHaveValue('admin@becomeqa.com');

// Пустой инпут
await expect(page.getByLabel('Search')).toHaveValue('');

// Выпадающий список
await expect(page.getByLabel('Status')).toHaveValue('completed');

toBeChecked / not.toBeChecked

Для чекбоксов и радиокнопок:

await expect(page.getByLabel('Remember me')).toBeChecked();
await expect(page.getByLabel('Subscribe to newsletter')).not.toBeChecked();

toBeEnabled / toBeDisabled

// Кнопка доступна для нажатия
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();

// Кнопка задизейблена или имеет атрибут disabled
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();

Используй для проверки что кнопка submit формы становится активной только после заполнения обязательных полей.

toBeEditable / toBeReadOnly

await expect(page.getByLabel('Username')).toBeEditable();
await expect(page.getByLabel('Created At')).toBeReadOnly();

toHaveAttribute

Проверяет любой HTML-атрибут:

// href у ссылки
await expect(page.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');

// data-testid
await expect(page.getByTestId('status-badge')).toHaveAttribute('data-status', 'active');

// aria-label
await expect(page.getByRole('button', { name: 'Close' })).toHaveAttribute('aria-label', 'Close dialog');

// Совпадение значения по regex
await expect(page.getByRole('img')).toHaveAttribute('src', /\/images\/.+\.svg/);

toHaveClass

// Элемент имеет этот CSS-класс
await expect(page.getByTestId('alert')).toHaveClass(/error/);

// Точный список классов
await expect(page.getByTestId('button')).toHaveClass('btn btn-primary active');

toHaveClass проверяет что класс присутствует, не что он единственный. Используй regex для частичных совпадений.

toHaveCount

Для коллекций элементов:

// Таблица: заголовок + 5 строк данных
await expect(page.getByRole('row')).toHaveCount(6);

// Выпадающий список: 4 варианта
await expect(page.getByRole('option')).toHaveCount(4);

// Нет сообщений об ошибках
await expect(page.getByRole('alert')).toHaveCount(0);

toHaveCSS

Проверяет вычисленные CSS-свойства:

await expect(page.getByTestId('error-message')).toHaveCSS('color', 'rgb(220, 38, 38)');
await expect(page.getByRole('dialog')).toHaveCSS('display', 'flex');

Используй вычисленные значения (rgb(...)) а не CSS-переменные или сокращённые свойства.

Ассёрты страницы

Проверяют свойства всей страницы, а не конкретного элемента.

toHaveURL

// Точный URL
await expect(page).toHaveURL('https://lab.becomeqa.com/dashboard');

// Частичное совпадение через regex
await expect(page).toHaveURL(/\/dashboard/);

// С baseURL настроенным в playwright.config.ts
await expect(page).toHaveURL('/dashboard');

Используй после навигации чтобы подтвердить что попал куда нужно.

toHaveTitle

await expect(page).toHaveTitle('My Travel Items | BecomeQA Lab');
await expect(page).toHaveTitle(/BecomeQA/);

toHaveScreenshot (визуальная регрессия)

// Первый запуск создаёт референсный скриншот
// Последующие сравнивают с ним
await expect(page).toHaveScreenshot('dashboard.png');

// С опциями
await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixels: 100,   // допустимые незначительные пиксельные отличия
    threshold: 0.2,       // шкала 0–1 допустимого различия цветов
});

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

Ассёрты API-ответов

При использовании фикстуры request для API-тестирования:

test('GET /api/items returns valid data', async ({ request }) => {
    const response = await request.get('/api/items');
    
    // Код статуса
    expect(response.status()).toBe(200);
    expect(response.ok()).toBeTruthy(); // true для 200–299
    
    // Тело ответа
    const items = await response.json();
    expect(items).toHaveLength(5);
    expect(items[0]).toHaveProperty('id');
    expect(items[0]).toHaveProperty('title');
    expect(items[0].status).toBe('planned');
    
    // Заголовки
    expect(response.headers()['content-type']).toContain('application/json');
});

Встроенный метод response.ok() (не ассёрт) возвращает true для 2xx статус-кодов.

Ассёрты обычных значений

Для не-элементных значений: переменных, API-ответов, вычисленных значений:

// Равенство
expect(items.length).toBe(5);
expect(user.role).toBe('admin');

// Неравенство
expect(errorCode).not.toBe(0);

// Truthy/falsy
expect(isVisible).toBeTruthy();
expect(errorMessage).toBeFalsy();

// Null/undefined
expect(result).toBeNull();
expect(result).not.toBeNull();
expect(result).toBeDefined();
expect(result).toBeUndefined();

// Числа
expect(count).toBeGreaterThan(0);
expect(price).toBeGreaterThanOrEqual(9.99);
expect(discount).toBeLessThan(100);

// Массивы
expect(statuses).toContain('completed');
expect(items).toHaveLength(3);
expect(tags).toEqual(['qa', 'automation', 'playwright']); // точное совпадение массива

// Объекты
expect(user).toMatchObject({ email: 'admin@becomeqa.com', role: 'admin' }); // частичное совпадение
expect(user).toEqual({ id: 1, email: 'admin@becomeqa.com', role: 'admin' }); // точное совпадение

// Строки
expect(message).toContain('success');
expect(slug).toMatch(/^[a-z0-9-]+$/);

Отрицание: not

Любой ассёрт можно отрицать через .not:

await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('Error')).not.toBeAttached();
expect(response.status()).not.toBe(404);

Мягкие ассёрты: сбор всех сбоев

По умолчанию первый упавший ассёрт останавливает тест. Мягкие ассёрты продолжают выполнение даже после сбоя и сообщают обо всех в конце:

test('dashboard data is correct', async ({ page }) => {
    await page.goto('/dashboard');
    
    // Не останавливаются на первом сбое
    await expect.soft(page.getByRole('heading')).toHaveText('My Travel Items');
    await expect.soft(page.getByRole('row')).toHaveCount(6);
    await expect.soft(page).toHaveURL('/dashboard');
    
    // Выбрасывает исключение если любой мягкий ассёрт выше упал
    expect(test.info().errors).toHaveLength(0);
});

Используй мягкие ассёрты когда нужно увидеть всё что сломано за один прогон, а не только первый сбой.

Кастомные сообщения ассёртов

Когда ассёрт падает, Playwright показывает что ожидалось и что было найдено. Можно добавить кастомное сообщение чтобы сбои легче читались:

await expect(page.getByRole('heading'), 'Страница должна показывать дашборд после входа')
    .toHaveText('My Travel Items');

expect(items.length, `Ожидалось 5 элементов, получено ${items.length}`)
    .toBe(5);

Настройка таймаутов

Дефолтный таймаут ассёрта: 5 секунд. Можно переопределить для конкретного ассёрта или глобально:

// Для конкретного ассёрта (10 секунд для медленной операции)
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 10000 });

// Глобально в playwright.config.ts
export default defineConfig({
    expect: {
        timeout: 10000,  // все ассёрты ждут до 10 секунд
    },
});

Не увеличивай таймауты чтобы исправить нестабильные тесты. Нестабильный тест с большим таймаутом остаётся нестабильным. Просто падает медленнее. Исправляй первопричину.

Типичные ошибки

Использование page.locator().isVisible() вместо expect().toBeVisible()

// Неправильно: проверяет один раз, нет повторных попыток, возвращает булево
const visible = await page.getByText('Success').isVisible();
expect(visible).toBe(true);

// Правильно: повторяет попытки до появления или таймаута
await expect(page.getByText('Success')).toBeVisible();

Первый вариант может нестабильно падать потому что проверяет ровно один раз. Второй повторяет попытки.

Ассёрты устаревших локаторов

// Не сохраняй локаторы до навигации и не ассёрти их после
const heading = page.getByRole('heading');
await page.goto('/new-page');
await expect(heading).toHaveText('New Page'); // может быть устаревшим

// Лучше: создавай локатор рядом с ассёртом
await page.goto('/new-page');
await expect(page.getByRole('heading')).toHaveText('New Page');

Использование expect(await locator.textContent()).toBe(...) вместо toHaveText

// Неправильно: вычисляется один раз, нет повторных попыток
expect(await page.getByRole('heading').textContent()).toBe('Dashboard');

// Правильно: повторяет попытки с авто-ожиданием
await expect(page.getByRole('heading')).toHaveText('Dashboard');

Проверка количества на динамических списках

Если список загружается асинхронно, ассёрти количество после того как список стал видимым:

await expect(page.getByRole('list')).toBeVisible(); // ждём появления списка
await expect(page.getByRole('listitem')).toHaveCount(5); // затем считаем элементы

Полный тест с несколькими типами ассёртов

import { test, expect } from '@playwright/test';

test('user can add and view a travel item', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('button', { name: 'Login' }).click();
    await page.getByLabel('Username').fill('admin@becomeqa.com');
    await page.getByLabel('Password').fill('testpass123');
    await page.getByRole('button', { name: 'Submit' }).click();

    // Ассёрт страницы: проверяем навигацию
    await expect(page).toHaveURL('/dashboard');

    // Ассёрт видимости
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible();

    // Ассёрт текста
    await expect(page.getByRole('heading', { level: 1 })).toHaveText('My Travel Items');

    // Добавляем новый элемент
    await page.getByRole('button', { name: 'Add Item' }).click();
    await page.getByLabel('Destination').fill('Tokyo');
    await page.getByRole('button', { name: 'Save' }).click();

    // Ассёрт количества: в таблице должно стать на одну строку больше
    const rows = page.getByRole('row');
    await expect(rows).toHaveCount(7); // заголовок + 6 элементов

    // Ассёрт содержимого новой строки
    await expect(page.getByRole('cell', { name: 'Tokyo' })).toBeVisible();

    // Ассёрт статуса нового элемента
    const tokyoRow = page.getByRole('row').filter({ hasText: 'Tokyo' });
    await expect(tokyoRow.getByRole('cell').last()).toHaveText('Planned');
});

FAQ

Почему ассёрт проходит локально но падает в CI?

Таймингии. Машины CI медленнее. Элемент существует но дольше появляется. Увеличь таймаут ассёрта в playwright.config.ts или разберись почему элемент медленно загружается в CI.

В чём разница между toEqual и toBe?

toBe проверяет равенство по ссылке (один и тот же объект в памяти, или идентичные примитивы). toEqual проверяет глубокое равенство (одинаковая структура и значения, работает для объектов и массивов). Для сравнения объектов и массивов используй toEqual. Для строк, чисел и булевых: toBe.

Когда использовать toMatchObject вместо toEqual?

toMatchObject: частичное совпадение. Реальный объект может иметь больше свойств чем указано. toEqual требует точного совпадения. Для API-ответов где нужно проверить ключевые поля без перечисления всех используй toMatchObject.

toHaveText падает из-за лишних пробелов в реальном тексте. Как исправить?

toHaveText автоматически обрезает ведущие и завершающие пробелы. Для внутренних пробелов (множественные пробелы, переносы строк) используй regex: toHaveText(/destination:\s+Tokyo/i).

→ См. также: Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Нестабильные тесты: почему они возникают и как их устранить