Authentication is the trickiest part of E2E testing — most tests require a logged-in user, but logging in via the UI for every test is slow and fragile. The recommended approach is to log in once per spec file using cy.session() (which restores the session cache for subsequent tests), or to call the login API directly and inject the resulting token into the Angular application using cy.window().
Authentication in Cypress Tests
// ── commands.ts — login via API (faster than UI) ──────────────────────────
Cypress.Commands.add('loginAsAdmin', () => {
cy.session('admin-session', () => {
// Call the API directly — faster than navigating through the login UI
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/api/auth/login`,
body: {
email: Cypress.env('ADMIN_EMAIL') ?? 'admin@test.com',
password: Cypress.env('ADMIN_PASSWORD') ?? 'Admin!123Test',
},
}).then(response => {
expect(response.status).toBe(200);
const { accessToken } = response.body;
// Visit a page to establish the Angular app context
cy.visit('/');
// Inject the token into the Angular AuthService
// This sets the in-memory signal without going through the login form
cy.window().then(win => {
// Get the Angular service from the DI container
const injector = (win as any).ng?.getInjector?.();
if (injector) {
const authService = injector.get(AuthService);
authService._setSession({ accessToken, expiresIn: 900, roles: ['Admin'] });
}
});
// Wait for auth to settle
cy.getByCy('user-avatar').should('be.visible');
});
});
});
// ── Testing the login flow itself ────────────────────────────────────────
describe('Login flow', () => {
beforeEach(() => {
cy.clearAllSessionStorage();
cy.clearAllCookies();
cy.visit('/auth/login');
});
it('shows error for wrong password', () => {
cy.getByCy('email-input').type('admin@test.com');
cy.getByCy('password-input').type('WrongPassword123!');
cy.getByCy('login-button').click();
cy.getByCy('login-error')
.should('be.visible')
.and('contain', 'Invalid email or password');
});
it('redirects to the returnUrl after login', () => {
// Visit a protected page — redirected to login with returnUrl
cy.visit('/admin/posts');
cy.url().should('include', 'returnUrl=%2Fadmin%2Fposts');
cy.getByCy('email-input').type('admin@test.com');
cy.getByCy('password-input').type(Cypress.env('ADMIN_PASSWORD'));
cy.getByCy('login-button').click();
// Should be redirected back to /admin/posts after login
cy.url().should('include', '/admin/posts');
});
it('redirects to login when accessing protected route unauthenticated', () => {
cy.visit('/admin/posts');
cy.url().should('include', '/auth/login');
cy.getByCy('login-form').should('be.visible');
});
});
// ── Using cy.intercept() to stub API responses ────────────────────────────
describe('Post list with stubbed API', () => {
it('shows error state when API is unavailable', () => {
cy.intercept('GET', '/api/posts*', {
statusCode: 503,
body: { title: 'Service Unavailable', status: 503 },
}).as('failedPosts');
cy.visit('/');
cy.wait('@failedPosts');
cy.getByCy('error-state').should('be.visible');
cy.getByCy('retry-button').should('be.visible');
});
it('shows loading skeleton before posts load', () => {
let resolve: () => void;
// Delay the API response to capture the loading state
cy.intercept('GET', '/api/posts*', (req) => {
req.continue(res => {
res.setDelay(1000);
});
}).as('slowPosts');
cy.visit('/');
cy.getByCy('post-card-skeleton').should('have.length.gt', 0);
cy.wait('@slowPosts');
cy.getByCy('post-card-skeleton').should('not.exist');
cy.getByCy('post-card').should('have.length.gt', 0);
});
});
window.ng.getInjector() is an advanced technique that requires the Angular application to be built in a mode that exposes the injector globally. In development mode, Angular exposes this API. In production mode, it is not available. This approach is appropriate for Cypress tests that run against a development build (the Angular dev server) — which is the standard setup for E2E tests in development and CI.cy.intercept() to simulate error conditions that are difficult to trigger naturally — API downtime (503), rate limiting (429), network timeouts, and validation conflicts (409). Testing how the application handles these conditions requires the E2E test to control the API response. cy.intercept() can completely replace the real request with a fixed response, or modify the real response (change status code, add delay, modify body) while still hitting the real server.cy.session() cache persists across tests in the same spec file but is cleared between spec files (different test files). If multiple spec files require admin authentication, each file’s loginAsAdmin() command runs the API login once per file — not once per test, but not once globally either. Configure a shared session with a consistent name to maximise reuse across a test run. The session cache is also cleared whenever the session setup function changes.Common Mistakes
Mistake 1 — Logging in via UI for every test (slow, fragile)
❌ Wrong — no cy.session(); login form interaction in every beforeEach; each test adds 3+ seconds; form changes break all auth tests.
✅ Correct — cy.session() caches login state; restores instantly for subsequent tests in the spec file.
Mistake 2 — Not clearing session between tests that test the logged-out state
❌ Wrong — test for “login required” redirect runs after a logged-in test; session is cached; redirect doesn’t happen.
✅ Correct — call cy.clearAllSessionStorage(); cy.clearAllCookies() in beforeEach for tests requiring a clean state.