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:
| Strategy | Per-test setup | 30 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.sessionwrapping API login). - API login (strategy 2 alone) for short-lived suites where the
cy.sessionoverhead 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 yourcy.apiLoginplants a cookie but the app readslocalStorage, the nextcy.visitlands 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.sessioncache keys must be unique per credential pair.cy.session("admin", () => ...)for two different admins (admin@a.comandadmin@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.
- In your scaffolded project (Sauce Demo as the target), implement the three typed commands
cy.uiLogin,cy.apiLogin, andcy.sessionLoginincypress/support/commands.ts. Sauce Demo's "API" is the form post — usecy.request("POST", ...)against the inventory page or simulate it by reading the cookie set by a programmatic visit. - Create three nearly-identical specs
cypress/e2e/login-{ui,api,session}.cy.ts, each with fiveitblocks doing trivial dashboard assertions. The only difference between the three files should be which login command runs inbeforeEach. - 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. - Check the cookie or storage location by adding
cy.getAllCookies().then(cy.log)andcy.window().its("localStorage")after each login. Confirmcy.apiLoginsets the same artefactcy.uiLogindoes. - Force a session-cache miss — change one character of the password between two tests. Confirm
cy.sessionLoginre-runs the full login on the second test (the cache key changed) instead of restoring stale state. - Stretch: add a typed
cy.logoutcommand that clears cookies, localStorage, and any in-memory store the app uses. Wire it into anafterEachfor 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.