You can test login flows in Cypress without repeating UI steps by creating a custom Cypress command that handles the login process programmatically.

// cypress/support/commands.js

Cypress.Commands.add('login', (username, password) => {
  // Use cy.request for a faster, more reliable login that bypasses UI
  cy.request({
    method: 'POST',
    url: '/api/auth/login', // Adjust this to your actual login API endpoint
    body: {
      username: username,
      password: password,
    },
    // If your API requires specific headers like Content-Type, add them here
    // headers: {
    //   'Content-Type': 'application/json',
    // },
  }).then((response) => {
    // Assuming your API returns a token in the response body
    // You might need to inspect your API response to get the correct token name
    const authToken = response.body.token;
    expect(response.status).to.eq(200); // Ensure login was successful
    // Store the token in localStorage or sessionStorage for subsequent requests
    // This simulates a user being logged in after a successful API call
    window.localStorage.setItem('authToken', authToken);
  });
});

// cypress/support/commands.js (continued for a second approach)

// If you absolutely need to interact with the UI for some reason (e.g., handling MFA),
// you can still make it repeatable and less brittle.
Cypress.Commands.add('loginViaUI', (username, password) => {
  cy.visit('/login'); // Navigate to the login page
  cy.get('[data-testid="username-input"]').type(username); // Use data-testid for robustness
  cy.get('[data-testid="password-input"]').type(password);
  cy.get('[data-testid="login-button"]').click();
  // Add an assertion to ensure login was successful, e.g., check for a welcome message
  cy.get('[data-testid="welcome-message"]').should('be.visible');
});

Now, in your test files, you can use these commands like any other Cypress command:

// cypress/e2e/dashboard.cy.js

describe('Dashboard', () => {
  beforeEach(() => {
    // Use the programmatic login before each test
    // This avoids the UI steps and makes tests faster
    cy.login('testuser', 'password123');
    // If you used loginViaUI, it would be:
    // cy.loginViaUI('testuser', 'password123');
  });

  it('should display dashboard elements', () => {
    cy.visit('/dashboard'); // Visit the protected page
    cy.get('[data-testid="dashboard-title"]').should('contain', 'Welcome, testuser');
  });

  it('should allow navigation to settings', () => {
    cy.visit('/dashboard');
    cy.get('[data-testid="settings-link"]').click();
    cy.url().should('include', '/settings');
  });
});

The core idea behind cy.request for login is that it directly interacts with your backend API to authenticate the user. Instead of clicking buttons and typing into fields, Cypress sends a POST request to your login endpoint with the provided credentials. The backend validates these credentials and, if successful, returns a token (like a JWT). Cypress then captures this token and stores it, typically in localStorage or sessionStorage. This effectively simulates a logged-in state without ever touching the browser’s UI for the login action itself. This is significantly faster and more reliable because it bypasses the DOM, CSS, and JavaScript rendering of your login form, which can be prone to flaky tests due to timing issues or UI changes.

When you use cy.request for login, you’re essentially creating a shortcut. For every test that requires authentication, cy.request performs the login once, and then crucially, it stores the authentication token. Subsequent cy.visit commands or cy.request calls made within that same test or beforeEach block will automatically include this token (if you’ve set it up correctly in the then callback to be added to headers or if the application automatically picks it up from localStorage). This means the application treats the subsequent requests as if they were made by an authenticated user, allowing you to immediately test protected routes and functionalities without re-authenticating.

If you must use the UI for login (perhaps due to complex multi-factor authentication flows or third-party login providers that can’t be easily mocked via API), the loginViaUI approach is still a significant improvement over raw cy.visit and cy.type calls scattered throughout your tests. By encapsulating the UI login steps into a reusable custom command, you centralize that logic. If your login form’s selectors change, you only need to update them in one place (commands.js) rather than in every test file. Furthermore, using data-testid attributes for your selectors makes them much more resistant to changes in CSS classes or element nesting, which are common culprits for breaking UI-based tests.

The most surprising true thing about testing login flows programmatically is that you’re not really testing the UI of the login process at all; you’re testing the authentication contract between your frontend and backend. You’re asserting that when valid credentials are sent to /api/auth/login, a 200 status code is returned and a usable token is provided. This shifts your focus from brittle DOM interactions to the core security and functionality of your authentication system, leading to more robust and faster tests.

The next concept you’ll likely encounter is managing different user roles and permissions after a successful login.

Want structured learning?

Take the full Cypress course →