Automating Cypress E2E Tests for Protected Routes in a React App Using GitHub Actions.

Emeka OkoliEmeka Okoli
5 min read

In a typical application with authentication and authorization, users must log in or create an account to access certain features. Once authenticated, users are assigned roles that determine their access levels and permissions within the app. This process is crucial for maintaining security and ensuring that users can only perform actions they are authorized to perform.

Maintaining code quality and consistency in a large team is challenging, particularly without a standardized code style. To prevent non-functional components from being pushed to the main branch or reintroducing past issues, implementing unit tests and end-to-end (e2e) tests is essential. E2E tests are crucial for verifying that the entire application flow, including authentication and role-based access, functions as expected. By automating these tests with tools like Cypress, you can ensure that every code change is thoroughly vetted before merging, thus enhancing the reliability and security of your application.

In this article, I assume you are familiar with React and Nodejs. So I will only discuss running an e2e test in GitHub Actions. The goal of this article is to guide you on how to run e2e tests in GitHub Actions and ensure that every code pushed to your repo must go through Cypress e2e before merging. You can also check out the Cypress documentation for an in-depth guide.

Cypress config

Cypress can automate the login process and append the token to the request headers for each test. It’s important to configure Cypress to make it easy to log in and go about other important tests

//cypress.config.ts
import { defineConfig } from "cypress";

export default defineConfig({
  component: {
    devServer: {
      framework: "create-react-app",
      bundler: "webpack"
    }// for CRA projects
  },
  e2e: {
    baseUrl: process.env.CYPRESS_BASE_URL || "http://localhost:3000",
    setupNodeEvents(on, config) {
      on("before:browser:launch", (browser, launchOptions) => {
        if (browser.name === "chrome" && browser.isHeadless) {
          launchOptions.args.push("--disable-gpu");
          launchOptions.args.push("--no-sandbox");
          launchOptions.args.push("--disable-dev-shm-usage");
          return launchOptions;
        }
      });
    },
    video: true,
    screenshotOnRunFailure: true
  }
});
// commands.ts
Cypress.Commands.add("login", (email, password) => {
  // use sessions, if you are setting token in the localstorage, it will not work in github actions
  cy.session([email, password], () => {
    cy.request({
      method: "POST",
      url: "/api/login", // login endpoint
      body: { email, password }
    }).then(response => {
      expect(response.status).to.eq(200);
      const token = response.body.token;
      expect(token).to.exist;

      cy.setCookie("user_auth_token", token);
      cy.wrap(token).as("authToken");
      expect(response.status).to.eq(200);
    });
  });
});

Perhaps you persist your login in the localStorage. Notice I did not use that in my login config; using sessions will save you from the headache using localStorage in GitHub Actions that will likely not go well with getting a token from localStorage and setting it to the headers, and for security reasons.

Login component

This is a minimal login component to highlight the essential aspects for testing.

// login.tsx
import { useState } from 'react';
import { useAuth } from './auth-context';

export function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      await login(email, password);
    } catch (err) {
      setError('Invalid credentials');
    }
  };

  return (
    <div>
      <h1>user Login</h1>
      {error && <p{error}</p>}
      <form onSubmit={handleSubmit}>
        <div>
          <label>Email:</label>
          <input
            name="email"
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />
        </div>
        <div>
          <label>Password:</label>
          <input
            name="password"
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />
        </div>
        <button type="submit">Sign In</button>
      </form>
    </div>
  );
}

The dashboard component.

A minimal dashboard component to focus on the key elements for Cypress testing.

// dashboard.tsx
import { useAuth } from './auth-context';

export function Dashboard() {
  const { token, logout } = useAuth();

  return (
    <div>
      <h1 data-testid='user'>John's Dashboard</h1>
      <p data-testid='welcome'>Welcome! john You're authenticated.</p>
      <button onClick={logout} data-testid='logout'>Logout</button>
      <div>
        <h3 data-testid='info'>Info:</h3>
        ...dashboard data
      </div>
    </div>
  );
}

E2E test

// login.cy.ts
describe("Sign-in", () => {
  const loginUrl = "/user/login";
  const dashboardUrl = "/user/dashboard";

  beforeEach(() => {
    cy.visit(loginUrl);
  });

  it("should log in successfully and store token", () => {
    cy.get('input[name="email"]').type(Cypress.env("USER_EMAIL"));
    cy.get('input[name="password"]').type(Cypress.env("USER_PASSWORD"));
    cy.get('button[type="submit"]').click();

    cy.url().should("include", dashboardUrl);

    cy.getCookie("user_auth_token").then(cookie => {
      expect(cookie.value).to.match(
        /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/
      );
    });
  });
});
// dashboard.cy.ts
describe("User Dashboard", () => {
  const loginUrl = "/user/login";
  const dashboardUrl = "/user/dashboard";

  beforeEach(() => {
    //login before each test
    cy.login(Cypress.env("USER_EMAIL"), Cypress.env("USER_PASSWORD"));

    cy.getCookie("user_auth_token").then(cookie => {
      const token = cookie.value;

      // intercrpt API response
      cy.intercept(
        {
          method: "GET",
          url: "/api/v1/users",
          headers: { Authorization: `Bearer ${token}` }
        },
        {
          statusCode: 200,
          body: { user: { name: "john doe", email: "johndoe@mail.com" } }
        }
      ).as("getUsers");

      cy.visit(dashboardUrl);
    });

    it("should access protected dashboard", () => {
      cy.url().should("include", dashboardUrl);

      cy.wait("@getUsers").then(({ response }) => {
        expect(response.statusCode).to.eq(200);
        const user = response.body.user;

        const { name } = user;

        cy.get('[data-testid="user"]').should("contain", `${name}'s Dashboard`);
        cy.get('[data-testid="welcome"]').should(
          "contain",
          `Welcome! ${name} You're authenticated.`
        );
      });
    });

    it("should logout successfully", () => {
      cy.get('[data-testid="logout"]').click();

      cy.url().should("include", loginUrl);
      cy.getCookie("user_auth_token").should("not.exist");
    });
  });
});

When the login component is pushed to the repo, we test that through the GitHub action CI

Before then, you need to create an environment variable in your GitHub repo settings, click on secrets and variables, then click on “actions”

Add all the critical environment variables like:-
REACT_APP_DEV_API_URL:’’,
CYPRESS_USER_EMAIL,
CYPRESS_USER_PASSWORD

// test.ci.yml
name: CI Pipeline

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PNPM and Node.js
        uses: pnpm/action-setup@v4
        with:
          version: '10'
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'

      - name: Cache dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.pnpm-store
            node_modules
          key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}

      - name: Install dependencies
        run: pnpm install

      - name: Lint and Format
        run: |
          pnpm lint
          pnpm format

      - name: Install Chrome
        uses: browser-actions/setup-chrome@latest
        with:
          chrome-version: stable

      - name: Cypress E2E Tests
        uses: cypress-io/github-action@v6
        with:
          start: pnpm dev & # start the app in the background
          wait-on: 'https://app.your_url.com'
          wait-on-timeout: 600  # timeout to ensure app is ready
          browser: chrome
          config: '{"retries":{"runMode":2,"openMode":0}}'
        env:
          CI: false
          PORT: 3000
          CYPRESS_BASE_URL: 'https://app.your_url.com'
          CYPRESS_USER_EMAIL: ${{ secrets.CYPRESS_USER_EMAIL }}
          CYPRESS_USER_PASSWORD: ${{ secrets.CYPRESS_USER_PASSWORD }}
          REACT_APP_DEV_API_URL: ${{ secrets.REACT_APP_DEV_API_URL }}
          NODE_ENV: development

      - name: Upload Cypress screenshots
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: |
            cypress/screenshots
            cypress/videos
          retention-days: 1
          if-no-files-found: ignore

…and voilà! Your e2e tests are running.

Automating Cypress end-to-end tests for protected routes in GitHub Actions significantly enhances the security and reliability of your CI/CD pipeline. By ensuring that every code change is thoroughly tested before merging, you can maintain code quality and prevent errors into the main branch. Setting up environment variables and configuring Cypress to handle authentication seamlessly allows for efficient and effective testing. This approach streamlines the development process and also fosters a culture of quality assurance within your team. Embracing these practices will lead to more robust applications and a smoother development workflow.

How do you go about your E2E test? Let me know in the comments section.

Follow me on X, let’s connect.

1
Subscribe to my newsletter

Read articles from Emeka Okoli directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Emeka Okoli
Emeka Okoli

I am a skilled full-stack software developer who takes pride in crafting innovative, user-friendly products and services that enhance people's lives. Let me help bring your ideas to life and create meaningful solutions that make a difference.