Q37 of 48 · Cypress
How do you handle authentication for tests that need different user roles?
Short answer
Short answer: Define one `cy.loginAs(role)` custom command backed by `cy.session(role, ...)` so each role logs in once per spec (or once across the suite with `cacheAcrossSpecs`). Validate the cached session with a `validate` callback to refresh expired tokens. Seed users via `cy.task` so the test environment matches expectations.
Detail
A real-world suite has multiple personas: admin, manager, viewer, anonymous. Naive setups log in for every test, doubling or tripling suite runtime.
The clean architecture:
1. Seed users once per environment. A cy.task('seedUsers') call (or a one-off setup script) ensures admin@x.com, manager@x.com, viewer@x.com all exist with known passwords. Specs assume these users; they don't create them.
2. Single loginAs command keyed by role.
Cypress.Commands.add('loginAs', (role: 'admin' | 'manager' | 'viewer') => {
cy.session(
role,
() => {
cy.request('POST', '/api/login', {
email: Cypress.env(`${role}Email`),
password: Cypress.env('password'),
}).then(({ body }) => {
window.localStorage.setItem('token', body.token);
});
},
{
cacheAcrossSpecs: true,
validate() {
cy.request({ url: '/api/me', failOnStatusCode: false })
.its('status').should('eq', 200);
},
},
);
});
3. Cache across specs. cacheAcrossSpecs: true means each role logs in once across the entire suite, not once per spec. With four roles and 200 specs, that's four logins instead of 800.
4. Validate to handle expiry. Long suites can outlast token TTL. The validate callback checks /api/me on every restore; if it fails, Cypress re-runs setup transparently.
5. Different tokens for different tests. If a single spec needs to switch personas, just call cy.loginAs(otherRole) — the session command swaps the cached state. Each role's session is independent.
6. Avoid logging in via the UI — cy.request is faster and more reliable. Test the UI login flow in dedicated specs (login page tests); use API login for everything else.
Edge cases worth mentioning: SSO / OAuth flows can't always be hit via cy.request (some require interactive consent). The pattern then is to use a "test mode" backdoor in your auth service, or to log in manually once and reuse a captured token.
// EXAMPLE
support/commands.ts + spec
// support/commands.ts
type Role = 'admin' | 'manager' | 'viewer';
Cypress.Commands.add('loginAs', (role: Role) => {
cy.session(
role,
() => {
cy.request('POST', '/api/login', {
email: Cypress.env(`${role}Email`),
password: Cypress.env('password'),
}).then(({ body }) => {
window.localStorage.setItem('token', body.token);
});
},
{
cacheAcrossSpecs: true,
validate() {
cy.request({ url: '/api/me', failOnStatusCode: false })
.its('status').should('eq', 200);
},
},
);
});
declare global {
namespace Cypress {
interface Chainable {
loginAs(role: Role): Chainable<void>;
}
}
}
export {};
// Spec
describe('Permissions matrix', () => {
it('admin can delete a user', () => {
cy.loginAs('admin');
cy.visit('/users/u1');
cy.get('[data-test=delete]').should('be.visible').click();
});
it('viewer cannot see the delete button', () => {
cy.loginAs('viewer');
cy.visit('/users/u1');
cy.get('[data-test=delete]').should('not.exist');
});
});