Picture a security guard who asks for your ID every time you walk past his desk. Not once a day. Every single time. You'd lose your mind by lunch.
That's exactly what our tests do right now. Every test that needs a logged-in user types a username, types a password, clicks Login, and waits. Ten tests, ten logins. A hundred tests, a hundred logins. The tests aren't wrong. They're just tired, and so is your build server.
Today we fix that. We'll lock a page behind a login, prove the lock works, and then teach Cypress to log in once and carry that login from test to test with cy.session().
Catching Up? Here's the Road So Far
This is part 7 of the Cypress Tutorial Series. Each part builds on the last, so if you've just arrived, start here:
- A Beginner's Guide to Cypress: End-to-End Testing Made Easy
- Understanding Cypress Basics: Core Features and Syntax Explained
- Cypress Stubs, Spies, and Clocks: Take Control of Your Tests
- Mocking Network Requests in Cypress with cy.intercept()
- Why Your Cypress Tests Break: Selectors and Test Design Best Practices
- Cypress Custom Commands: Write Less, Test More
We'll use the same demo app, along with the getByCy, login, and mockLogin commands we built in part 6.
Why Does Every Test Log In Again?
Blame a good habit. Cypress wipes cookies, local storage, and the page before each test, so no test can lean on another (we met this idea, test isolation, back in part 2). Clean tests, yes. But the login gets wiped too.
cy.session() gives us the best of both. Tests stay isolated, while the login is saved once and handed back to every test that asks for it.
Building a Door Worth Locking
A login only matters if there's something behind it. So let's give our app a private room: a Dashboard that only logged-in users can enter. Four small changes, and we're there.
Step 1: Bring In a Router
npm install react-router-domStep 2: Keep the Key After Login
Right now, our LoginForm forgets everything the server tells it. Let's make it hold on to the token. In src/components/LoginForm.tsx, update the success part of handleSubmit:
// The server said yes
const { token, user } = data as LoginResponse;
localStorage.setItem('token', token); // New: keep the key
localStorage.setItem('username', user.username); // New: remember who
alert(`Welcome, ${user.username}!`);
setMessage(`Logged in as ${user.username}`);Nothing visible changes on the login page, so every test from earlier parts stays green.
Step 3: Build the Private Room
Create src/components/Dashboard.tsx:
import { useNavigate } from 'react-router-dom';
const Dashboard = () => {
const navigate = useNavigate();
const username = localStorage.getItem('username');
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('username');
navigate('/');
};
return (
<div>
<h1 data-cy="dashboard-title">Welcome back, {username}!</h1>
<button data-cy="logout-button" onClick={handleLogout}>
Logout
</button>
</div>
);
};
export default Dashboard;Step 4: Hire a Doorman
Create src/components/ProtectedRoute.tsx. Its whole job fits in one line: no token, no entry.
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
const ProtectedRoute = ({ children }: { children: ReactNode }) => {
const token = localStorage.getItem('token');
return token ? <>{children}</> : <Navigate to="/" replace />;
};
export default ProtectedRoute;Then wire everything together in src/App.tsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import LoginForm from './components/LoginForm';
import Dashboard from './components/Dashboard';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<LoginForm />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
export default App;Try to sneak in: open http://localhost:5173/dashboard in your browser. The doorman sends you straight back to the login page. Good. Now let's make Cypress check that for us.
Rattle the Lock First
Before testing what logged-in users see, test what logged-out users don't see. A private page that anyone can open isn't a missing feature. It's a security hole. Create cypress/e2e/auth.cy.ts:
describe('Protected Routes', () => {
it('should redirect logged-out users to the login page', () => {
cy.visit('/dashboard');
cy.location('pathname').should('eq', '/');
cy.getByCy('login-button').should('be.visible');
});
});Two lines do the real work here. cy.location('pathname') reads the path from the address bar and keeps retrying until the redirect lands. Then getByCy('login-button') makes sure we ended up on an actual login page, not a blank screen that just happens to live at /.
Small test, big job. Keep it forever.
One Check-In, Many Visits
Think of a hotel. You queue at the front desk once, show your passport, and walk away with a key card. For the rest of your stay, you just tap and walk in. That key card is cy.session().
cy.session(id, setup, { validate });- id is the name on the key card, usually the username. A different id means a different card.
- setup is the front desk. It logs in, and it runs only the first time.
- validate taps the card on the door before you rely on it. If the card stopped working, Cypress quietly goes back to the front desk.
Behind the scenes, Cypress saves the cookies, local storage, and session storage after setup, then restores that saved copy in every later test.
Step 1: Turn It Into a Command
Open cypress/support/commands.ts and add one line to the Chainable interface:
loginSession(username?: string): Chainable<void>;Then add the command just above export {};:
Cypress.Commands.add('loginSession', (username = Cypress.expose('username')) => {
cy.session(
username,
() => {
// The front desk: runs once per username
cy.visit('/');
cy.mockLogin();
cy.login(username);
cy.wait('@login');
},
{
// Tap the card: is this login still good?
validate() {
cy.visit('/dashboard');
cy.getByCy('dashboard-title').should('be.visible');
},
}
);
});See how little setup has to say? Part 6 already did the heavy lifting with mockLogin and login. Good commands keep paying you back.
Step 2: Put It to Work
Add a second block to auth.cy.ts:
describe('Dashboard', () => {
beforeEach(() => {
cy.loginSession();
cy.visit('/dashboard');
});
it('should welcome the logged-in user', () => {
cy.getByCy('dashboard-title').should('have.text', 'Welcome back, testuser!');
});
it('should log the user out', () => {
cy.getByCy('logout-button').click();
cy.location('pathname').should('eq', '/');
});
});Why the extra cy.visit()? Because cy.session() hands back your key, not your room. It restores storage and leaves you on a blank page, so you still walk to the door yourself.
Run the file and watch the Command Log. The first test shows the session being created. The second shows it restored: no typing, no clicking, no waiting.
One more thing worth noticing. The logout test deletes the token, yet the next test still begins logged in. That's because Cypress restores its saved copy every time, and nothing a single test does can scratch that copy. validate exists for trouble from outside, like a real server expiring the token.
Where Does the Key Live?
Every app keeps its login somewhere. Ours keeps a token in local storage. Plenty of others hide it in a cookie. Cypress can peek into both.
Peeking Into Local Storage
Add these two tests to the Dashboard block:
it('should store the token in local storage', () => {
cy.window()
.its('localStorage')
.invoke('getItem', 'token')
.should('equal', 'fake-jwt-token-123');
});
it('should remove the token on logout', () => {
cy.getByCy('logout-button').click();
cy.window().its('localStorage').invoke('getItem', 'token').should('be.null');
});Read the first chain like a sentence: take the window, open its local storage, ask for the item called token, and expect our fake token from login-success.json. The second test makes sure logout truly throws the key away, instead of just hiding the page.
If Your App Uses Cookies
Same idea, different drawer. Cypress has built-in commands for it:
cy.getCookie('session').should('exist'); // Logged in
cy.getCookie('session').should('have.property', 'value', 'abc123'); // The right value
cy.getCookie('session').should('not.exist'); // Logged outAnd you don't need a second command for cookie apps. cy.session() saves and restores cookies too, so cy.loginSession() just works.
Tip: With a real backend, setup can skip the form entirely by calling the login API with cy.request() and saving the token it returns. It's even faster. Just keep at least one test that logs in through the real form, so the form itself never goes untested.
The Whole Picture
Here's cypress/e2e/auth.cy.ts with every piece in place:
describe('Protected Routes', () => {
it('should redirect logged-out users to the login page', () => {
cy.visit('/dashboard');
cy.location('pathname').should('eq', '/');
cy.getByCy('login-button').should('be.visible');
});
});
describe('Dashboard', () => {
beforeEach(() => {
cy.loginSession();
cy.visit('/dashboard');
});
it('should welcome the logged-in user', () => {
cy.getByCy('dashboard-title').should('have.text', 'Welcome back, testuser!');
});
it('should store the token in local storage', () => {
cy.window()
.its('localStorage')
.invoke('getItem', 'token')
.should('equal', 'fake-jwt-token-123');
});
it('should log the user out', () => {
cy.getByCy('logout-button').click();
cy.location('pathname').should('eq', '/');
});
it('should remove the token on logout', () => {
cy.getByCy('logout-button').click();
cy.window().its('localStorage').invoke('getItem', 'token').should('be.null');
});
});Five tests, four of them logged in, and the login form gets filled exactly once. That's the whole trick.
Traps to Step Around
- Expecting
cy.loginSession()to open the page. It restores the key, not the room. Follow it withcy.visit(). - Sharing one id between different users. An admin and a regular user need separate cards, like
cy.loginSession('admin'). Otherwise Cypress hands the admin the wrong one. - Leaving out
validate. A dead session then fails every test, with an error that points everywhere except the real cause. - Testing only the happy side of the door. Always keep the logged-out redirect test.
Before You Go
Our security guard can finally relax. We locked a page behind a login, proved the lock holds, and taught Cypress to check in once and reuse that key for every test that needs it. Along the way, we peeked at tokens in local storage and cookies, and learned why validate is worth its few extra lines.
Until now, every test has started the whole app in a browser, routes and all. But sometimes you only want to test one piece, like our LoginForm, on its own bench. That's the idea behind Cypress Component Testing and cy.mount(), and it's where we're heading next. Stay tuned, and happy testing!
