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