← All posts
Best practices

Testing login, sessions, and everything auth

Auth is the first thing every user hits and the first thing every test suite gets wrong. Most flaky end-to-end suites are flaky because of how they handle login: they re-authenticate in every test, share one account across parallel workers, or drive a third-party OAuth screen that was never built for automation. Here are the patterns that keep auth tests fast and honest.

Use dedicated test identities, never real ones

Give your suite its own accounts. Not a developer's personal login, not a shared admin, not a customer record you found in staging. A dedicated test identity has three properties that matter:

The credentials for those identities are secrets. They belong in your CI secret store or an environment variable, never hardcoded in a spec and never committed. In Playwright, read them at runtime:

await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);

Session reuse versus fresh login per test

The single biggest lever on suite speed is not logging in for every test. Logging in is slow, it's network-bound, and it exercises the same three fields a thousand times to tell you nothing new. Authenticate once, save the resulting session, and reuse it.

Playwright models this cleanly with a setup project that logs in and writes storageState (cookies plus localStorage) to disk. Every other project depends on it and starts already authenticated.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';

const authFile = path.join(__dirname, '../.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  // Wait for something that only exists once you're in.
  await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
  await page.context().storageState({ path: authFile });
});

Now every test in chromium opens with a live session and skips the login form entirely.

The one place you should still log in for real is the login test itself. Keep a small, explicit suite that fills the form, checks bad-password errors, verifies the redirect, and confirms logout clears the session. Force that suite to start signed-out so it's testing what it claims to:

test.use({ storageState: { cookies: [], origins: [] } });

The rule of thumb: one test proves login works; every other test assumes it does.

Handle expiry and refresh deliberately

Sessions end. Tokens expire, refresh tokens rotate, and "remember me" changes the rules. If you never test the boundary, you'll ship the bug where an expired session dumps the user on a blank page instead of the login screen.

Test the transition, not the clock. Clear the session mid-flow and assert the app recovers:

test('expired session sends the user back to login', async ({ page, context }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Simulate the session dying between requests.
  await context.clearCookies();

  await page.getByRole('link', { name: 'Settings' }).click();
  await expect(page).toHaveURL(/\/login/);
  await expect(page.getByText('Please sign in to continue')).toBeVisible();
});

If you use short-lived access tokens with silent refresh, add a test that waits past the access-token lifetime and confirms an in-page action still succeeds — that's the difference between a working refresh loop and a silent logout your users will hate. Keep token lifetimes configurable in your test environment so this test takes seconds, not the production 15 minutes.

OAuth in tests: don't drive the provider

The "Continue with Google" and "Continue with GitHub" buttons are a trap for browser automation. The provider's consent screen is designed to resist bots: it changes markup without notice, throws CAPTCHAs and device checks, and enforces MFA you can't script. A test that clicks through the real Google login is slow, flaky, and one UI redesign away from red.

Two patterns keep you sane:

  1. Bypass the provider for functional tests. Seed the authenticated session directly — via a test-only login endpoint, an API call that mints a session, or a captured storageState — so your app opens already signed in. Your tests then exercise your app, which is what you actually own.
  2. Keep one real smoke test, isolated and allowed to be slow. If you must prove the OAuth round-trip works end to end, use a provider that supports test accounts, run it separately from the main suite, and don't let it gate every deploy. Accept that it will occasionally need a human.

The principle: test your authorization logic (what a logged-in user can see and do), not the identity provider's login screen. That screen is their job to test.

Lockout traps that quietly break suites

Auth systems fight back against exactly the traffic a test suite generates, and the failures look like flakiness:

The common thread is separation: negative tests, parallel workers, and verification flows each want their own isolated identity so one test's side effect never poisons another's.

Where TestVibe fits

When you describe an auth flow in plain language, TestVibe generates the Playwright test by driving a live browser session against your app, then you run that generated test to see it pass — the behavior is verified by execution, not asserted on faith. Credentials stay out of the test text: store them in your project's Variables & Secrets and reference them from Gherkin with {{secret:TEST_USER_PASSWORD}} tokens that resolve at run time and never appear in the feature file or run logs.

The Variables & Secrets settings panel
The Variables & Secrets settings panel

Because a signed-in flow is reused constantly, you can model login once as a prerequisite feature and let dependent tests assume it instead of repeating the sign-in steps everywhere.

Auth testing rewards discipline over cleverness: dedicated identities, log in once, test the expiry boundary on purpose, and keep the third-party provider out of your critical path. Get those four right and most of the flakiness that plagues auth-heavy suites goes away. Get early access to try it against your own login, or read more in the TestVibe docs.

Early access

Ready for tests that write themselves?