storageState захватывает куки и localStorage после реального логина и записывает их в JSON-файл. Каждый последующий тестовый контекст загружает этот файл вместо того чтобы проходить через UI логина, экономя 2–4 секунды на тест в сьютах где большинство тестов требуют аутентификации. Эта статья разбирает глобальную настройку для генерации файла авторизации, паттерн setup-проекта который делает падения логина видимыми в HTML-отчёте, storageState на роль для нескольких типов пользователей, фикстуры с областью видимости worker для тестов которые меняют состояние авторизации, и API-логин когда UI-логин становится узким местом.
Что именно сохраняет storageState
Когда ты аутентифицируешься в браузере, сервер подтверждает личность через один из двух механизмов: куки (обычно session ID или JWT в HTTP-only cookie) или токен в localStorage / sessionStorage. Иногда оба сразу.
Playwright storageState захватывает всё это. Вызов context.storageState() возвращает JSON-объект содержащий все куки в контексте и снимок localStorage и sessionStorage для каждого origin. Этот JSON записывается на диск, и когда Playwright создаёт новый браузерный контекст с storageState: './auth.json', он предзагружает все эти данные до первой навигации. Для сервера запрос выглядит идентично запросу из оригинальной аутентифицированной сессии.
// Как выглядит сохранённый файл (сокращённо)
{
"cookies": [
{
"name": "session",
"value": "eyJhbGciOi...",
"domain": "lab.becomeqa.com",
"path": "/",
"expires": 1748000000,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
],
"origins": [
{
"origin": "https://lab.becomeqa.com",
"localStorage": [
{ "name": "auth_token", "value": "eyJhbGciOi..." }
]
}
]
}Файл обычный JSON. Его можно изучать, коммитить в тестовую ветку, или перегенерировать по требованию. Большинство команд добавляют его в .gitignore и перегенерируют в начале каждого CI-прогона.
Настройка global-setup.ts
Стандартный паттерн: файл global-setup.ts который запускается один раз перед всем тест-сьютом. Он запускает браузер, выполняет реальный UI-логин и сохраняет результирующее состояние в файл. Все тестовые воркеры затем читают этот файл вместо логина.
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://lab.becomeqa.com');
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 page.getByText('My Travel Items').waitFor({ state: 'visible' });
// Сохраняем куки + localStorage в файл
await context.storageState({ path: 'playwright/.auth/admin.json' });
await browser.close();
}
export default globalSetup;Создай директорию до запуска тестов, иначе Playwright бросит ошибку о несуществующем файле:
mkdir -p playwright/.authДобавь директорию в .gitignore чтобы токены авторизации не попали в версионный контроль:
# .gitignore
playwright/.auth/Подключение в playwright.config.ts
В конфиге нужно сделать два действия. Первое: указать Playwright где находится global-setup.ts. Второе: настроить каждый тестовый проект использовать сохранённое состояние как начальный контекст.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
baseURL: 'https://lab.becomeqa.com',
storageState: 'playwright/.auth/admin.json',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});Этого достаточно чтобы убрать логин из каждого теста. storageState в use применяется глобально, и каждый браузерный контекст который создаёт Playwright будет стартовать уже аутентифицированным.
const ADMIN_AUTH = 'playwright/.auth/admin.json'.Паттерн setup-проекта (рекомендуется для больших сьютов)
Хук globalSetup работает, но у него есть недостаток: он запускается вне системы проектов и репортёров Playwright. Падения в global-setup.ts дают минимальный вывод, и настройка не появляется в HTML-отчёте.
Рекомендуемая альтернатива, появившаяся в Playwright 1.31: выделенный setup-проект. Он запускается перед другими проектами и использует весь пайплайн отчётности.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
baseURL: 'https://lab.becomeqa.com',
},
projects: [
// Setup-проект запускается первым, создаёт файлы авторизации
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Тестовые проекты зависят от завершения setup
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['setup'],
},
],
});Сам setup-файл обычный тестовый файл Playwright:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate as admin', 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 page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});Теперь шаг логина отображается в HTML-отчёте, логика ретраев применяется если страница логина флакует, и скриншоты при падении снимаются автоматически.
storageState на роль для нескольких типов пользователей
В большинстве приложений больше одной роли, и тестировать их нужно независимо. Администратор видит панель управления. Обычный пользователь нет. Если запустить тесты администратора с сессией обычного пользователя, они упадут по неправильной причине.
Добавь один шаг setup на роль, один файл авторизации на роль, и один тестовый проект на роль:
// tests/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate as admin', 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 page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/admin.json' });
});
setup('authenticate as regular user', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('user@becomeqa.com');
await page.getByLabel('Password').fill('userpass456');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const ADMIN_AUTH = 'playwright/.auth/admin.json';
const USER_AUTH = 'playwright/.auth/user.json';
export default defineConfig({
use: {
baseURL: 'https://lab.becomeqa.com',
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
// Тесты администратора
{
name: 'admin-chromium',
testMatch: /.*admin.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: ADMIN_AUTH,
},
dependencies: ['setup'],
},
// Тесты обычного пользователя
{
name: 'user-chromium',
testMatch: /.*user.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
storageState: USER_AUTH,
},
dependencies: ['setup'],
},
// Тесты без авторизации (лендинг, тесты флоу логина)
{
name: 'public',
testMatch: /.*public.*\.spec\.ts/,
use: { ...devices['Desktop Chrome'] },
},
],
});Каждый проект загружает свой файл авторизации, и тесты администратора никогда случайно не запустятся с сессией обычного пользователя.
public и использовать сырую фикстуру page без storageState. Смысл этих тестов в том чтобы пройти через UI логина.Фикстуры с областью видимости worker для storageState (продвинутый паттерн)
В playwright.config.ts storageState применяется к браузерному контексту. Если тест делает что-то изменяющее состояние авторизации (обновляет профиль пользователя, меняет email, или в худшем случае разлогинивается), изменённый контекст может протечь в следующий тест в том же воркере.
Решение: создавать свежий контекст на каждый тест, загруженный из статического файла авторизации, а не делить один контекст на все тесты. Фикстура с областью видимости worker решает это чисто:
// fixtures/auth.fixture.ts
import { test as base, BrowserContext } from '@playwright/test';
import path from 'path';
const ADMIN_AUTH = path.resolve('playwright/.auth/admin.json');
type AuthFixtures = {
// Worker-scoped: путь к файлу авторизации, загружается один раз на воркер
adminStorageState: string;
};
type TestFixtures = {
// Test-scoped: свежий контекст на каждый тест, загруженный из файла
adminContext: BrowserContext;
};
export const test = base.extend<TestFixtures, AuthFixtures>({
// Фикстура воркера просто хранит путь, один раз проверяет что файл есть
adminStorageState: [
async ({}, use) => {
await use(ADMIN_AUTH);
},
{ scope: 'worker' },
],
// Фикстура теста создаёт свежий контекст из сохранённого состояния
adminContext: async ({ browser, adminStorageState }, use) => {
const context = await browser.newContext({
storageState: adminStorageState,
});
await use(context);
await context.close();
},
});
export { expect } from '@playwright/test';Тесты использующие эту фикстуру получают изолированный браузерный контекст который стартует аутентифицированным, но изменения состояния внутри теста не влияют на другие тесты:
// tests/admin-items.spec.ts
import { test, expect } from '../fixtures/auth.fixture';
test('admin can see management panel', async ({ adminContext }) => {
const page = await adminContext.newPage();
await page.goto('/');
await expect(page.getByRole('link', { name: 'Admin Panel' })).toBeVisible();
});
test('admin can delete any item', async ({ adminContext }) => {
const page = await adminContext.newPage();
await page.goto('/items');
await page.getByTestId('item-row-1').getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByTestId('item-row-1')).not.toBeVisible();
});Каждый тест получает собственный BrowserContext свежеинициализированный из файла авторизации. Удаление во втором тесте не затрагивает общее состояние.
Сочетание storageState с API-логином (более быстрая настройка)
auth.setup.ts из примеров выше выполняет полный UI-логин: навигация, клики, заполнение форм, ожидание. Это работает, но занимает несколько секунд. На медленном CI-раннере или когда форма логина содержит анимации это становится узким местом.
Если в приложении есть API-эндпоинт логина, можно вызвать его напрямую из шага setup, полностью пропустить UI и вручную записать полученный токен в storage state. Обычно это в 5–10 раз быстрее чем UI-подход:
// tests/auth.setup.ts (версия через API)
import { test as setup, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const AUTH_FILE = 'playwright/.auth/admin.json';
setup('authenticate as admin via API', async ({ request }) => {
// Вызываем эндпоинт логина напрямую
const response = await request.post('https://lab.becomeqa.com/api/auth/login', {
data: {
email: 'admin@becomeqa.com',
password: 'testpass123',
},
});
const { token, sessionCookie } = await response.json();
// Строим структуру storageState вручную
const storageState = {
cookies: [
{
name: 'session',
value: sessionCookie,
domain: 'lab.becomeqa.com',
path: '/',
expires: Math.floor(Date.now() / 1000) + 86400, // 24 часа
httpOnly: true,
secure: true,
sameSite: 'Lax' as const,
},
],
origins: [
{
origin: 'https://lab.becomeqa.com',
localStorage: [
{ name: 'auth_token', value: token },
],
},
],
};
// Убеждаемся что директория существует
fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
fs.writeFileSync(AUTH_FILE, JSON.stringify(storageState, null, 2));
});Компромисс: для этого подхода нужно знать точную структуру хранилища авторизации приложения (какие куки выставляются, какие ключи localStorage читаются). UI-подход работает вне зависимости от деталей реализации: просто логинишься и сохраняешь всё что браузер накопил. Начни с UI-подхода и переходи на API только если логин становится измеримым узким местом.
Когда storageState перестаёт работать
storageState не магия. Это снимок состояния браузера в конкретный момент времени. Несколько ситуаций сломают его.
Истечение срока токена. Если приложение использует короткоживущие JWT (15 минут, 1 час), сохранённый токен истечёт к тому времени как запустятся более поздние тесты. Решение: перегенерировать файл авторизации в начале каждого CI-прогона (что стоит делать в любом случае), или перейти на API-логин который всегда выдаёт свежий токен.
Инвалидация сессий на стороне сервера. Некоторые приложения инвалидируют сессии при обнаружении аномальных паттернов. Несколько одновременных запросов из «одной и той же» сессии в разных воркерных процессах как раз такой паттерн. Если видишь случайные 401 в тестах которые должны быть аутентифицированы, проверь есть ли в приложении защита от session fixation которая воспринимает параллельные воркеры как подозрительные.
Двухфакторная аутентификация. 2FA полностью ломает UI-настройку storageState. Флоу логина требует TOTP-кода или SMS-верификации которые нельзя автоматизировать через Playwright в общем виде. Практические решения: использовать выделенный тестовый аккаунт с отключённой 2FA (если приложение это позволяет), использовать API-логин который выдаёт токены без 2FA в тестовых окружениях, или добавить переменную окружения которая обходит 2FA когда NODE_ENV=test.
Сессии привязанные к браузеру. Некоторые приложения привязывают сессии к fingerprint браузера, TLS-клиентским сертификатам или device ID. Если куки сессии содержат атрибуты ограничивающие их конкретными характеристиками устройства, сохранение и восстановление их между разными экземплярами браузера не сработает. В веб-приложениях это редкость, но стоит иметь в виду.
// Проверка что сохранённое состояние ещё действительно. Добавь в auth.setup.ts.
setup('authenticate as admin', async ({ page }) => {
// Сначала пробуем загрузить существующее состояние
const AUTH_FILE = 'playwright/.auth/admin.json';
if (fs.existsSync(AUTH_FILE)) {
// Проверяем что существующий токен ещё действителен
const checkContext = await browser.newContext({ storageState: AUTH_FILE });
const checkPage = await checkContext.newPage();
await checkPage.goto('/');
const isAuthenticated = await checkPage.getByText('My Travel Items').isVisible();
await checkContext.close();
if (isAuthenticated) {
console.log('Existing auth state is valid, skipping login');
return; // Переиспользуем существующий файл
}
}
// Состояние недействительно или отсутствует, выполняем полный логин
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 page.getByText('My Travel Items').waitFor({ state: 'visible' });
await page.context().storageState({ path: AUTH_FILE });
});playwright/.auth/*.json содержит реальные токены сессий дающие доступ к тестовым аккаунтам. Добавь директорию в .gitignore и регулярно меняй пароли тестовых аккаунтов. Если используешь переменные окружения для учётных данных (что нужно делать в CI), убедись что эти переменные не попадают в вывод пайплайна.storageState даёт самый высокий ROI из всех изменений которые можно внести в медленный Playwright-сьют. Настройка займёт около 30 минут, а общее время тестов в сьютах где большинство тестов требуют аутентификации сократится на 20–30%.
→ See also: Кастомные фикстуры в Playwright: паттерн, который делает тесты читаемыми | Файл конфигурации Playwright: все опции, которые нужно знать | API-тестирование с Playwright APIRequestContext (без Postman) | Изоляция тестов: почему каждый тест Playwright должен быть stateless | Глобальная настройка и очистка в Playwright