Testing login flows sounds simple until you try to automate them at scale. A login test is not just “type username, type password, click submit.” It is a mix of UI behavior, session handling, redirects, cookies, local storage, error states, rate limits, and sometimes MFA. If you only cover the happy path, your suite will pass while users still get blocked at sign-in.

In this article, I’ll show how I approach a test login flows with Playwright setup as an SDET. We’ll cover a straightforward Playwright login test, how to verify authentication state, how to reuse auth across tests, and where teams usually make the suite flaky. I’ll also point out when a no-code or AI-assisted platform, such as Endtest, can be a simpler option for teams that want maintainable login tests without owning all the framework plumbing.

The goal of a login test is not to prove that typing into a form works. The goal is to prove that a user can authenticate, receive the right session state, and land in the right place with the right permissions.

What you should test in a login flow

Before writing code, decide what the test is actually protecting. Login flows usually have several layers:

  • The form itself, inputs, labels, and validation messages
  • The submit action, button state, loading state, and request handling
  • Successful authentication, redirect, session cookie, or token storage
  • Failure cases, wrong password, locked account, expired session, and rate limiting
  • Post-login access, role-based content, and logout behavior

For most teams, I recommend separating these into three groups:

  1. UI login smoke test, verifies the form works end to end
  2. Authentication state setup, creates a reusable authenticated browser state
  3. Access control assertions, checks that authenticated users can see protected pages

This keeps the suite small and useful. It also avoids wasting time repeating the same expensive UI login in every test.

A basic Playwright login test

Let’s start with a simple Playwright login test. The example below assumes a typical username and password form.

import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
  await page.goto('https://example.com/login');

await page.getByLabel(‘Email’).fill(‘user@example.com’); await page.getByLabel(‘Password’).fill(‘correct-horse-battery-staple’); await page.getByRole(‘button’, { name: ‘Sign in’ }).click();

await expect(page).toHaveURL(/dashboard/); await expect(page.getByRole(‘heading’, { name: ‘Dashboard’ })).toBeVisible(); });

A few things matter here:

  • getByLabel is usually more stable than CSS selectors for form fields
  • getByRole makes the test closer to how a user sees the page
  • toHaveURL waits for navigation, so you do not need a manual sleep
  • The final assertion should verify a post-login signal, not just the absence of an error

If your app uses an SPA and the URL does not change, assert on a protected element, a user menu, or a known session-dependent value.

Choosing locators that survive UI changes

Login pages change often, especially when product teams tweak branding, copy, or accessibility. The most fragile test I see is one that depends on a long CSS chain or a translated button text that changes every quarter.

Prefer these in order:

  1. getByRole
  2. getByLabel
  3. getByPlaceholder only when the app lacks accessible labels
  4. getByTestId for elements with no stable semantic hook

For example:

typescript

await page.getByTestId('email-input').fill('user@example.com');
await page.getByTestId('password-input').fill('correct-horse-battery-staple');
await page.getByTestId('login-submit').click();

I usually recommend data-testid for login forms if the app has complex or dynamic markup. That is especially true when there are multiple sign-in options, like email, SSO, or social login.

Asserting the right outcome after login

A common mistake is to assert only that the login form disappeared. That tells you almost nothing. A broken login flow can still hide the form after a redirect to an error page or a blank state.

Useful post-login assertions include:

  • URL contains /dashboard, /account, or another protected route
  • A profile avatar or user menu becomes visible
  • A welcome message contains the expected name or role
  • A request to /api/me succeeds and returns the logged-in user
  • A cookie or storage item exists for the session

Here is a practical example that checks both the page and the backend state:

import { test, expect } from '@playwright/test';
test('login creates an authenticated session', async ({ page, context }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: 'Sign in' }).click();

await expect(page).toHaveURL(/dashboard/);

const cookies = await context.cookies(); expect(cookies.some(cookie => cookie.name === ‘session’)).toBeTruthy(); });

If your app stores the auth token in local storage instead of cookies, you can inspect the storage state too. Just be careful not to over-assert internal implementation details unless they matter to your product risk.

I prefer UI assertions for user-visible behavior and storage assertions for diagnosing session issues. Do both when the login flow is business-critical.

Handling authentication state with storageState

If you run a full UI login in every test, your suite will become slow and noisy. The usual fix in Playwright is to authenticate once, save the browser state, then reuse it for all tests that need an authenticated user.

This is where Playwright auth state becomes very useful.

Save auth state after login

You can create a setup project or a one-time script that logs in and saves the session state:

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

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();

await expect(page).toHaveURL(/dashboard/); await page.context().storageState({ path: ‘playwright/.auth/user.json’ }); });

Then reuse that state in tests:

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

test.use({ storageState: ‘playwright/.auth/user.json’ });

test('authenticated user can open billing', async ({ page }) => {
  await page.goto('/billing');
  await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});

This pattern is excellent for speed, but do not treat it as a universal replacement for login tests. You still need at least one real login test in CI to prove the authentication flow works end to end.

When auth state breaks

Auth state is convenient, but it can fail in a few subtle ways:

  • Session expiration is shorter than your test lifetime
  • Tokens are tied to device fingerprinting or IP address
  • The app stores additional anti-CSRF or challenge state
  • The backend invalidates sessions on deploys
  • Tests reuse a stale file in local development

A good rule is to regenerate auth state often and keep one dedicated login smoke test that does not depend on a cached session file.

Testing negative login cases

Most production bugs in login flows are not happy-path failures. They are failures in the error path, and those are often badly tested.

You should verify at least these cases:

  • Wrong password shows a useful error
  • Invalid email format is rejected
  • Empty fields show validation messages
  • Locked or suspended accounts are blocked
  • Password reset flows return users to a sane state

Example:

import { test, expect } from '@playwright/test';
test('shows an error for invalid credentials', async ({ page }) => {
  await page.goto('/login');

await page.getByLabel(‘Email’).fill(‘user@example.com’); await page.getByLabel(‘Password’).fill(‘wrong-password’); await page.getByRole(‘button’, { name: ‘Sign in’ }).click();

await expect(page.getByRole(‘alert’)).toHaveText(/invalid credentials/i); await expect(page).toHaveURL(‘/login’); });

Try to assert the message the user sees, not a backend-specific error string that can change when the auth provider changes.

Dealing with MFA, SSO, and third-party auth

Login flows are straightforward only when you own the entire form. In real systems, you may have OAuth, SAML, enterprise SSO, or MFA.

For these cases, I usually split testing into layers:

  • Application-owned login page, test the redirect, state handling, and callback
  • Identity provider flow, test only if you own the system or can safely automate it
  • MFA step, often better covered by a dedicated integration or admin test account

If the auth provider is third-party and unstable in CI, the cleanest approach is often to mock the callback or pre-seed a valid session cookie through test fixtures. That keeps your application tests focused on your app, not the provider’s UI.

Playwright can also intercept network calls when you need to isolate the app from an auth backend during a smoke test:

typescript

await page.route('**/api/login', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({ success: true })
  });
});

Use this carefully. If you mock too much, you are no longer testing the real login flow, just a facsimile of it.

Making login tests stable in CI

Login tests often become flaky because they depend on timing, third-party systems, or test data that changes underneath them. In CI, a login flow can fail for reasons that have nothing to do with the UI.

The main stabilizers are:

1. Use isolated test accounts

Do not share one account across dozens of parallel jobs if the application tracks active sessions, rate limits, or lockouts. Create dedicated test users per environment or per suite.

2. Wait for visible state, not arbitrary time

Never solve login flakiness with waitForTimeout. Wait for a page signal instead.

typescript

await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

3. Keep authentication data out of source control

Store credentials as environment variables or secret manager entries, not in the test code.

4. Watch for redirects and slow hydration

A test can click Sign in while the page is still hydrating, or assert too early while the redirect is still in flight. Prefer Playwright’s built-in auto-waiting, plus explicit post-login assertions.

5. Capture traces on failure

Playwright trace viewer is one of the best debugging tools for login failures. If a test fails in CI, trace playback usually shows whether the form never submitted, the request failed, or the redirect went somewhere unexpected.

A practical setup for auth-heavy suites

If your app has many authenticated tests, I like this structure:

  • auth.setup.ts, logs in once and stores auth state
  • login.spec.ts, covers the real login page and failure cases
  • protected-routes.spec.ts, uses stored auth state for faster coverage
  • logout.spec.ts, confirms session invalidation and post-logout behavior

That keeps each responsibility narrow. It also prevents one giant auth spec from becoming a debugging nightmare.

You can wire it into Playwright config like this:

import { defineConfig } from '@playwright/test';

export default defineConfig({ testDir: ‘./tests’, use: { baseURL: ‘http://localhost:3000’ }, projects: [ { name: ‘setup’, testMatch: /.*.setup.ts/ }, { name: ‘chromium’, dependencies: [‘setup’], use: { storageState: ‘playwright/.auth/user.json’ } } ] });

Logout matters too

A complete login story includes logout. If logout is broken, auth state can leak between users or make security checks meaningless.

A simple logout test can confirm that the protected route is no longer accessible:

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

test.use({ storageState: ‘playwright/.auth/user.json’ });

test('user can log out', async ({ page }) => {
  await page.goto('/dashboard');
  await page.getByRole('button', { name: 'Account menu' }).click();
  await page.getByRole('menuitem', { name: 'Sign out' }).click();

await expect(page).toHaveURL(‘/login’); await page.goto(‘/dashboard’); await expect(page).toHaveURL(/login/); });

This checks both the UI action and the server-side session invalidation. If the dashboard remains visible after logout, something is wrong with the session lifecycle.

Common mistakes I see in Playwright login tests

Here are the mistakes that waste the most time:

  • Using brittle selectors like div:nth-child(3) > button
  • Asserting only that the button was clicked, not that login succeeded
  • Reusing the same account in parallel test runs
  • Storing auth state forever, then wondering why CI breaks after password rotation
  • Mocking the auth endpoint so heavily that the test no longer covers real behavior
  • Skipping negative cases because they are “harder to automate”

If you only take one lesson from this article, make it this: a login test is valuable only when it proves real session behavior, not just form interaction.

When Playwright is the right tool, and when it is not

For code-first teams, Playwright is an excellent fit for login testing. It gives you control over browser state, network interception, assertions, and CI integration. If your engineers are comfortable in TypeScript, it is often the cleanest choice.

But not every team wants to own a framework. Some teams need login tests that product managers, manual testers, or QA analysts can edit without learning Playwright configuration, browser setup, or fixture orchestration. In those cases, a managed platform can be a better fit.

If you want a simpler alternative to Playwright, Endtest is worth a look because it uses agentic AI and editable no-code steps to build end-to-end tests, including login flows, without forcing the whole team to maintain code. Its AI Assertions can also help when the post-login state is better described in plain English than with a fragile selector, especially for pages that change often.

I would not switch a mature Playwright suite just to chase novelty. But if your team is struggling with test ownership, maintenance, or CI setup, it is reasonable to compare a framework-first approach with a managed option before the suite becomes a liability.

A sane checklist for login coverage

If you are building login automation from scratch, I would start with this checklist:

  • One successful login test through the real UI
  • One invalid password test
  • One empty-field validation test
  • One test that verifies authenticated access to a protected route
  • One logout test
  • Auth state reuse for the rest of the suite
  • Secrets stored in CI variables, not in code
  • Trace or screenshot artifacts enabled on failure

That is usually enough to catch real regressions without overbuilding the suite.

Final thoughts

To test login flows with Playwright, focus on the whole auth journey, not just the form. Validate the UI, the redirect, the session, and the access that follows. Use storageState when you need speed, but keep at least one real login path in CI. Prefer resilient locators and state-based assertions. And when login behavior is heavily tied to business risk, make sure your suite checks both success and failure paths.

If your team prefers a framework, Playwright gives you excellent control. If your team wants something more accessible, a managed, AI-assisted platform like Endtest can be a practical alternative for building login tests with editable steps instead of code.

Either way, the measure of a good login test is simple: when authentication breaks, the test should fail for the right reason, quickly, and with enough context to fix the problem.