Чек-лист тестирования регистрации и логина — и автотест на Playwright под каждый пункт
Логин — первый экран, который видит юзер, и одновременно любимое место продакшн-инцидентов: «не могу войти» — это всегда p1, потому что за дверью остаётся вся ценность продукта. При этом тестируют auth обычно по остаточному принципу: «ну логин же работает». Продолжаю формат прошлого поста про поиск: каждый пункт чек-листа — и сразу как его автоматизировать на Playwright. Auth для этого формата подходит идеально: флоу стабильный, меняется редко, гоняется на каждый релиз — идеальный кандидат в автоматизацию.
1. Валидация формы: границы и мусор
Классика из чек-листа форм применительно к регистрации: пустые поля, пробелы вместо email, email без домена, пароль короче минимума ровно на один символ, юникод и эмодзи в пароле (допустимы? одинаково валидируются при регистрации и логине?).
Автоматизируется параметризацией — один тест, массив кейсов:
const invalid = ['', ' ', 'no-at-sign', 'a@b', '<script>@x.com'];
for (const email of invalid) {
test(`регистрация отклоняет email: "${email}"`, async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Пароль').fill('Valid-Pass-123');
await page.getByRole('button', { name: 'Создать аккаунт' }).click();
await expect(page.getByRole('alert')).toBeVisible();
await expect(page).toHaveURL(/signup/); // не пустили дальше
});
}
2. Ошибка логина не выдаёт, что именно неверно
Если на несуществующий email система говорит «пользователь не найден», а на существующий с неверным паролем — «неверный пароль», вы подарили атакующему перебор базы юзеров (user enumeration). OWASP Authentication Cheat Sheet прямо требует одинаковый ответ в обоих случаях. То же на регистрации («email уже занят» — тоже enumeration, тут обычно компромисс) и на password reset («если аккаунт существует, мы отправили письмо»).
Автотест — сравнение двух сообщений:
test('логин не раскрывает существование аккаунта', async ({ page }) => {
const msgFor = async (email: string) => {
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Пароль').fill('definitely-wrong');
await page.getByRole('button', { name: 'Войти' }).click();
return page.getByRole('alert').textContent();
};
const existing = await msgFor('[email protected]');
const missing = await msgFor('[email protected]');
expect(existing).toBe(missing); // одинаковый текст — нечего перебирать
});
3. Password reset: токен работает один раз
Самый недотестированный флоу в auth — потому что в нём участвует почта. Проверить надо: письмо приходит на существующий аккаунт, токен открывает форму смены, использованный токен недействителен (открыть ссылку второй раз), истёкший токен отклоняется, старый пароль после смены не подходит, новый — подходит, а по правилам OWASP Forgot Password Cheat Sheet активные сессии после смены пароля стоит завершать.
В e2e почту не тащим — на тестовом окружении письма шлются в перехватчик (MailHog/Mailpit), у которого есть API. Тест забирает ссылку оттуда:
test('reset-токен одноразовый', async ({ page, request }) => {
await page.goto('/forgot');
await page.getByLabel('Email').fill(user.email);
await page.getByRole('button', { name: 'Сбросить пароль' }).click();
const mail = await request.get('http://mailpit:8025/api/v1/messages');
const link = extractResetLink(await mail.json());
await page.goto(link);
await page.getByLabel('Новый пароль').fill('New-Pass-456');
await page.getByRole('button', { name: 'Сохранить' }).click();
await expect(page).toHaveURL(/login/);
await page.goto(link); // вторая попытка тем же токеном
await expect(page.getByText(/ссылка недействительна/i)).toBeVisible();
});
4. Logout: сессия умирает по-настоящему
Кнопка «выйти» есть у всех, а вот проверяют ли, что сессия реально инвалидирована? Два обязательных кейса: после logout кнопка «назад» в браузере не показывает приватные данные (из кэша или, хуже, по живой сессии), и приватный URL напрямую редиректит на логин.
test('после logout приватное недоступно', async ({ page }) => {
await login(page);
await page.goto('/settings');
await page.getByRole('button', { name: 'Выйти' }).click();
await expect(page).toHaveURL(/login/);
await page.goBack(); // кнопка «назад»
await expect(page).toHaveURL(/login/);
await page.goto('/settings'); // прямой заход
await expect(page).toHaveURL(/login/);
});
5. Сессии: две вкладки, remember me, истечение
Поведение сессий между вкладками и устройствами — место, где «работает у меня» особенно коварно. Проверяем: логин во второй вкладке не убивает первую (или убивает — если такова политика, но осознанно); remember me переживает закрытие браузера, его отсутствие — нет; истечение сессии посреди работы ведёт на логин с понятным сообщением и, в идеале, возвратом туда же после входа.
Две вкладки в Playwright — это два контекста или две страницы одного контекста, в зависимости от того, что моделируем (контексты не делят cookies — это «два браузера»; страницы одного контекста делят — это «две вкладки»):
test('логин во второй вкладке не ломает первую', async ({ context }) => {
const tab1 = await context.newPage();
await login(tab1);
const tab2 = await context.newPage();
await tab2.goto('/dashboard'); // уже залогинен — сессия общая
await expect(tab2.getByTestId('user-menu')).toBeVisible();
await tab1.reload();
await expect(tab1.getByTestId('user-menu')).toBeVisible(); // первая жива
});
А протухшую сессию детерминированно устраиваем сами — чистим cookies и смотрим, как UI переживает следующий запрос:
test('истёкшая сессия → логин, не белый экран', async ({ page, context }) => {
await login(page);
await context.clearCookies(); // «сессия истекла»
await page.getByRole('button', { name: 'Сохранить' }).click();
await expect(page).toHaveURL(/login/);
await expect(page.getByText(/сессия истекла/i)).toBeVisible();
});
6. Блокировка перебора: UI при 429
Сам rate limiting в e2e лучше не тестировать — реальные N попыток делают тест медленным и флаки (лимит общий на окружение, параллельные воркеры друг друга блокируют). Разделяем: что лимит существует — проверяется на уровне API-тестов или конфига стенда; что UI вменяемо показывает блокировку — мокаем ответ, как в посте про поиск:
test('UI показывает блокировку, а не молчит', async ({ page }) => {
await page.route('**/api/login', r => r.fulfill({
status: 429,
json: { error: 'too_many_attempts', retryAfter: 300 },
}));
await page.goto('/login');
await page.getByLabel('Email').fill(user.email);
await page.getByLabel('Пароль').fill('wrong');
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByText(/слишком много попыток/i)).toBeVisible();
});
7. Cookies и мелкая гигиена безопасности
Не пентест, но дешёвые проверки, которые ловят регрессии конфигурации: сессионная cookie с HttpOnly (JS её не читает), Secure, SameSite; пароль не оказывается в URL и не логируется; поле пароля разрешает paste (запрет paste — антипаттерн, ломает менеджеры паролей) и имеет show/hide.
Атрибуты cookie автотест достаёт напрямую:
test('сессионная cookie защищена', async ({ page, context }) => {
await login(page);
const session = (await context.cookies()).find(c => c.name === 'session');
expect(session?.httpOnly).toBe(true);
expect(session?.secure).toBe(true);
expect(session?.sameSite).toBe('Lax');
});
8. Бонус формата: не логиньтесь в каждом тесте
Паттерн, который окупает статью: логин через UI должен быть тестом, а не сетапом всех тестов. Прогонять полный UI-логин перед каждым из двухсот тестов — медленно и флаки. У Playwright для этого есть штатный механизм storageState: отдельный setup-проект логинится один раз и сохраняет состояние, остальные тесты стартуют уже залогиненными:
// auth.setup.ts — выполняется один раз
setup('авторизация', async ({ page }) => {
await login(page);
await page.context().storageState({ path: '.auth/user.json' });
});
// playwright.config.ts: use: { storageState: '.auth/user.json' }
Сам логин-флоу при этом остаётся покрыт своими явными тестами — из пунктов выше.
Что в auth НЕ стоит тащить в e2e
- Реальную почтовую доставку — дойдёт ли письмо до Gmail и не попадёт ли в спам, e2e не проверит; это мониторинг доставляемости, отдельная дисциплина.
- Сторонний OAuth («войти через Google/Apple») — их UI вы не контролируете, тесты через реальные провайдеры отлично флакают и упираются в их анти-бот защиту. Провайдер мокается на границе; живой прогон — руками перед релизом.
- CAPTCHA — она существует ровно затем, чтобы вас не автоматизировали. На тестовом окружении отключается флагом, а вот что на проде она включена — проверяется руками (и этот пункт забывают чаще, чем кажется).
- Timing-атаки и брутфорс — инструменты безопасности, не Playwright.
Полный чек-лист
Регистрация: границы и мусор в полях · дубликат email → без enumeration (или осознанный компромисс) · письмо подтверждения и его повторная отправка · пароль: paste разрешён, show/hide, unicode. Логин: верные/неверные креды · одинаковая ошибка для «нет юзера» и «не тот пароль» · регистр и пробелы в email · redirect на исходную страницу после входа. Password reset: токен одноразовый · истёкший отклоняется · старый пароль умирает, сессии завершаются · «письмо отправлено» одинаково для любых email. Сессии: logout убивает сессию (back + прямой URL) · две вкладки/устройства · remember me · истечение посреди действия — с сообщением. Безопасность-минимум: cookies HttpOnly/Secure/SameSite · пароль не в URL/логах · rate limit существует (API-уровень) и UI его показывает (мок 429) · CAPTCHA на проде включена. Инфраструктура тестов: storageState вместо логина в каждом тесте · почта через перехватчик с API.
Auth — редкий случай, когда почти весь чек-лист честно автоматизируется: флоу детерминированный, меняется редко, а цена регрессии максимальная. Если в проекте есть автотесты вообще — логин должен быть покрыт первым.