Login Strategies — UI Login vs API Login vs Session Caching

9 min read

Login is the most-repeated action in any test suite. A fifty-spec project might log in a hundred times — once per test, sometimes twice when a beforeEach and an it both visit a guarded route. The choice of how you log in is the single biggest performance dial in a Cypress codebase. This lesson lays out the three strategies — UI login, API login, session caching — and the rule every team eventually adopts: keep one dedicated UI-login spec; use API or cy.session for everything else.

Strategy 1 — UI login

Type the email, type the password, click submit:

beforeEach(() => {
  cy.visit("/login");
  cy.get("[data-testid='email']").type("alice@test.com");
  cy.get("[data-testid='password']").type("Sup3rS3cret!");
  cy.get("[data-testid='submit']").click();
  cy.url().should("include", "/dashboard");
});

Three to five seconds per test. Realistic — exactly what a user would do. But the cost compounds: a hundred tests pay 5–8 minutes per run for setup that has nothing to do with what the tests are about.

Use UI login for the one or two specs that exist to verify the login form itself. Every other spec has paid for the coverage already — repeating it wastes CI minutes without adding signal.

Strategy 2 — API login

Skip the form. Call the auth endpoint directly with cy.request, then plant the token where the app expects it:

declare global {
  namespace Cypress {
    interface Chainable {
      loginViaApi(email: string, password: string): Chainable<void>;
    }
  }
}
 
Cypress.Commands.add("loginViaApi", (email: string, password: string) => {
  cy.request("POST", "/api/login", { email, password }).then((response) => {
    cy.setCookie("auth_token", response.body.token);
    // or: window.localStorage.setItem("authToken", response.body.token);
  });
});
 
export {};
beforeEach(() => {
  cy.loginViaApi("alice@test.com", "Sup3rS3cret!");
  cy.visit("/dashboard");
});

Each test costs about 200 ms instead of four seconds. The auth flow is exercised at the contract level — your test breaks the moment the login API changes shape, but it doesn't break when a designer renames a button. That's usually the right trade.

API login also bypasses CSRF tokens, captcha widgets, and other UI-only friction that exists for human users but isn't part of your test contract.

Strategy 3 — Session caching with cy.session

The third strategy goes one step further. cy.session runs the login once, snapshots cookies + localStorage + sessionStorage, and restores them on every subsequent call with the same key:

Cypress.Commands.add("loginWithSession", (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit("/login");
    cy.get("[data-testid='email']").type(email);
    cy.get("[data-testid='password']").type(password);
    cy.get("[data-testid='submit']").click();
    cy.url().should("include", "/dashboard");
  });
});
beforeEach(() => {
  cy.loginWithSession("alice@test.com", "Sup3rS3cret!");
  cy.visit("/dashboard");
});

The first test runs the full setup (UI or API — both work inside cy.session). Every subsequent test with the same [email, password] key restores in a few milliseconds. Pair cy.session with API login and you get the best of both worlds: contract-level setup the first time, instant restore from then on.

cy.session is covered in depth in the next lesson; for now, just know it exists and is the right default for any suite of more than a handful of tests.

The three strategies side by side

UI vs API vs session caching

UI login

  • Drives the real form — type, click, navigate

  • Speed: ~3–5 seconds per test

  • Realistic; covers the same ground real users hit

  • Use only for the dedicated login spec — too slow elsewhere

API login

  • POST /api/login via cy.request, then plant the token

  • Speed: ~200 ms per test

  • Skips CSRF, captcha, and UI-only friction

  • Default for every spec that doesn't test login itself

cy.session

  • Runs login once, snapshots cookies/storage, restores on demand

  • Speed: ~10–50 ms per restored test

  • Wraps either UI or API login — pick what you want inside

  • Default for any suite of more than ~10 tests

A timing comparison on the same suite

A 30-spec suite that logs in once per test:

StrategyPer-test setup30 specs total
UI login~4 s~120 s
API login~250 ms~8 s
cy.session + API~30 ms after first~1 s

The cy.session line isn't a typo — restoring stored cookies costs roughly the same as one DOM query. On a real CI suite of 200 specs, that's the difference between a 12-minute run and a 5-minute run.

Which to use when — the rule

Most teams settle on this:

  • One dedicated UI-login spec (sometimes two: success path + error states) using strategy 1.
  • Every other spec uses strategy 3 (cy.session wrapping API login).
  • API login (strategy 2 alone) for short-lived suites where the cy.session overhead isn't worth setting up, or when the login endpoint is so simple you don't gain from caching.

The login form gets exactly the coverage it deserves — one or two real UI specs that exercise every branch users hit. Everything else gets to the page under test in milliseconds and tests what it's actually about.

A complete typed e-commerce login command set

Putting all three commands in one place so the rest of the suite can pick the right tool:

declare global {
  namespace Cypress {
    interface Chainable {
      uiLogin(email: string, password: string): Chainable<void>;
      apiLogin(email: string, password: string): Chainable<void>;
      sessionLogin(email: string, password: string): Chainable<void>;
    }
  }
}
 
Cypress.Commands.add("uiLogin", (email, password) => {
  cy.visit("/login");
  cy.get("[data-testid='email']").type(email);
  cy.get("[data-testid='password']").type(password);
  cy.get("[data-testid='submit']").click();
  cy.url().should("include", "/dashboard");
});
 
Cypress.Commands.add("apiLogin", (email, password) => {
  cy.request("POST", "/api/login", { email, password })
    .its("body.token")
    .then((token) => cy.setCookie("auth_token", token));
});
 
Cypress.Commands.add("sessionLogin", (email, password) => {
  cy.session([email, password], () => {
    cy.apiLogin(email, password);
  });
});
 
export {};

The login spec uses cy.uiLogin. The dashboard, settings, billing, admin, and every other spec uses cy.sessionLogin. The contract is explicit; the per-spec choice is one line.

⚠️ Common mistakes

  • Using UI login for every test "to be realistic." Fifty seconds of login on a 30-spec suite is fifteen extra minutes of CI per push. The only spec that needs to drive the real login UI is the one whose job is to verify the login UI works. Every other spec is paying for coverage it doesn't add.
  • Plant-the-token API login that doesn't actually authenticate the request. Different apps store auth in different places: a cookie, localStorage, an in-memory Zustand store. If your cy.apiLogin plants a cookie but the app reads localStorage, the next cy.visit lands logged-out and confusion ensues. Inspect what the real login does (network tab + Application tab in devtools) and replicate that exact storage step.
  • Forgetting that cy.session cache keys must be unique per credential pair. cy.session("admin", () => ...) for two different admins (admin@a.com and admin@b.com) collides — the second test restores the first's cookies. Use [email, password] (or any tuple that's unique per identity) as the key, never a static string.

🎯 Practice task

Wire up all three login strategies and time them. 25-30 minutes.

  1. In your scaffolded project (Sauce Demo as the target), implement the three typed commands cy.uiLogin, cy.apiLogin, and cy.sessionLogin in cypress/support/commands.ts. Sauce Demo's "API" is the form post — use cy.request("POST", ...) against the inventory page or simulate it by reading the cookie set by a programmatic visit.
  2. Create three nearly-identical specs cypress/e2e/login-{ui,api,session}.cy.ts, each with five it blocks doing trivial dashboard assertions. The only difference between the three files should be which login command runs in beforeEach.
  3. Run each spec headlessly (npm run cy:run --spec "...") three times and record the average run time. Confirm the order of magnitude matches the table in the lesson.
  4. Check the cookie or storage location by adding cy.getAllCookies().then(cy.log) and cy.window().its("localStorage") after each login. Confirm cy.apiLogin sets the same artefact cy.uiLogin does.
  5. Force a session-cache miss — change one character of the password between two tests. Confirm cy.sessionLogin re-runs the full login on the second test (the cache key changed) instead of restoring stale state.
  6. Stretch: add a typed cy.logout command that clears cookies, localStorage, and any in-memory store the app uses. Wire it into an afterEach for one of your specs and confirm the next test still has to log in fresh.

The next lesson takes cy.session — the strategy this lesson hand-waved at — and dives into the validation, cross-spec, and multi-role patterns that turn it from a useful tool into the default for every serious Cypress suite.

// tip to track lessons you complete and pick up where you left off across devices.