skip to content

An end-to-end suite signs in through the login form at the start of every test, and a full run now takes twenty minutes. Why is UI login per test a poor default, and what do end-to-end suites do instead?

level: juniorimportance: must knowfreq 74%

answer

  1. login is setup, not the assertion
  2. pay the cost once per run
  3. capture cookies and origin storage
  4. seed the context, then navigate
  5. one test still drives the real form

basics

~20 s

Driving the login form repeats a slow, flake-prone flow that adds no coverage after the first test. Authenticate once through the app's auth API instead, save the resulting cookies and browser storage as a session, and start every test already signed in.

solid answer

~40 s

UI login is a fixed cost paid by every test — page load, form fill, submit, redirect, token exchange — and it is one of the most common sources of flake, yet after the first test it proves nothing new. The usual fix is to authenticate out of band: call the application's own login endpoint with a test user's credentials, capture the session the server hands back (cookies, plus any token the app keeps in `localStorage`), and persist it. Each test then opens a fresh browser context seeded with that saved state and navigates straight to an authenticated page. Playwright exposes this as `storageState`; Cypress wraps the same idea in `cy.session()`. You keep exactly one test that drives the real sign-in form, so the flow itself is still covered.

code

typescript · 14 lines
typescript
import { chromium } from '@playwright/test';

export async function saveSession(statePath: string): Promise<void> {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const res = await context.request.post('https://app.example.com/api/login', {
    data: { email: '[email protected]', password: process.env.E2E_PASSWORD },
  });
  if (!res.ok()) {
    throw new Error(`programmatic login failed: ${res.status()}`);
  }
  await context.storageState({ path: statePath });
  await browser.close();
}

go deeper

for a junior

Be able to say plainly that logging in through the form in every test is slow and repeats coverage, and that suites instead sign in once via the auth API and reuse the saved session.

for a middle

Explain what a saved session actually contains — cookies plus origin storage — why a cookie-only restore can leave the app looking logged out, and why the session must match the origin under test.

for a senior

Show judgment about the seam: the session is real and issued by the server, never a stubbed-out auth check, and you deliberately retain one test that drives the real sign-in form so the flow is not orphaned.

for a principal

Own the tradeoff between suite speed and fidelity: which paths must be exercised end to end at least once, what a shared setup step costs in coupling and debuggability, and how the team stops the injected session from quietly becoming an unverified assumption.

## Why per-test UI login hurts Every end-to-end test needs an authenticated browser. The obvious way to get one is to do what a user does: open the login page, type an email and password, submit, wait for the redirect. For the first test that is exactly right — it *is* the test of login. For the other two hundred tests it is overhead, and the overhead is not small. - **Time.** A login round trip is usually several seconds: document load, framework boot, form interaction, network call, redirect, second page load. Multiply by the number of tests and it dominates the suite. Many teams find that half their end-to-end runtime is logging in. - **Flakiness.** The login screen touches everything fragile at once — a form, a network call, a redirect, sometimes a rate limiter or a bot check. A flake there fails a test that was about something else entirely, and the failure report points at login rather than at the feature under test. - **False coupling.** If the login page is redesigned, every test in the suite breaks, even though none of them are about login. That is a sign the step belongs in setup, not in the test body. The principle underneath: **a test should drive the UI only for the behaviour it is asserting.** Everything else — getting into the right state — should be established by the cheapest reliable means available, which is almost always the application's own API. ## What "programmatic login" means Programmatic login means obtaining a real session the way the app's backend issues it, without rendering the login screen: 1. Send a request to the app's real authentication endpoint with a test user's credentials. 2. The server responds with whatever constitutes a session — most commonly a `Set-Cookie` for a session or refresh cookie, sometimes a token in the response body that the app stores in `localStorage`. 3. Capture that browser state and reuse it. The critical word is **real**. You are not bypassing authentication or stubbing out the authorization check; the server still issued the session, the app still sends it, and the backend still enforces permissions on every request. That is what separates this from the anti-pattern of adding a `TEST_MODE` flag to production code that skips auth — which changes what you are testing and can ship to production by accident. ## Saving and restoring the session Most runners have a name for the saved artifact. Playwright serialises a browser context's cookies and per-origin `localStorage` into a *storage state* JSON file, and a new context created with `storageState` starts with all of it in place. Cypress's `cy.session()` caches the cookies, `localStorage` and `sessionStorage` produced by a setup function and restores them for later tests keyed by an id. Different APIs, same shape: run the expensive step once, snapshot the browser's auth state, replay the snapshot. Two details bite people: - **Cookies are not always enough.** If the app keeps an access token in `localStorage`, a cookie-only restore leaves the app looking logged out. Capture the whole origin state, not just cookies. Note also that Playwright's `storageState` covers cookies and `localStorage` but not `sessionStorage`, so an app that keeps auth there needs it seeded explicitly. - **The state must match the origin.** A cookie captured for one host will not be sent to another, so a session recorded against a local dev server is not reusable against staging. ## What you still owe Injecting a session removes the login flow from every test — including from the tests that should have covered it. The deliberate answer is to keep one (or a small handful of) test(s) that drives the real form: correct credentials land on the dashboard, wrong credentials show an error, the post-login redirect returns the user to where they were headed. That test is the only place login is exercised, which is fine: you need to know it works, not to re-prove it two hundred times. A sketch of the setup step: ```ts const context = await browser.newContext(); const res = await context.request.post('https://app.example.com/api/login', { data: { email: user, password: pass }, }); if (!res.ok()) throw new Error(`login failed: ${res.status()}`); await context.storageState({ path: 'state/admin.json' }); ``` Note the explicit failure check. A setup step that silently fails produces a suite where every test fails at the first assertion with a confusing message; failing loudly in setup tells you in one line that authentication broke. ## When UI login is still correct Do not over-apply the rule. If the thing under test *is* the authenticated entry path — remember-me behaviour, session expiry warnings, the redirect after login, or a first-run onboarding wizard triggered by sign-in — the UI flow is the subject and must be driven. The rule is that login is setup *unless it is the assertion*.

  • The app keeps its access token in localStorage rather than in a cookie — does that change the approach?
    Not the approach, only what you capture. The session now lives in origin storage instead of the cookie jar, so the saved state must include the `localStorage` entry and be restored before the app's first render, otherwise the app boots as anonymous. Snapshot mechanisms that serialise whole-origin state handle both; a helper that only sets cookies will silently produce a logged-out app.
  • Where should the programmatic login actually run — once for the whole run, or before each test?
    Perform the real authentication once per run (per role), and restore the cheap saved state per test. Restoring is milliseconds and gives each test a fresh, isolated browser context; re-authenticating per test puts the network call back on the critical path and hammers the auth endpoint, which some environments rate-limit.
  • Is there a case where you should still log in through the UI?
    Yes — whenever login is the subject rather than the setup: valid and invalid credential handling, the redirect back to the originally requested page, remember-me, session-expiry prompts, or onboarding that only triggers on first sign-in. The rule is that the flow under assertion gets driven through the UI; everything else gets injected.

saying these in an interview costs you the question

  • Says every test must log in through the UI to be realistic
  • Adds a test-only flag to app code that skips authentication
  • Restores only cookies for an app that stores tokens in localStorage
  • Deletes all login coverage once sessions are injected
  • Believes injecting a session bypasses server-side authorization

context