Table of Contents


ReactJS E2E Testing with Cypress: Step-by-Step Tutorial

End-to-End (E2E) testing is an essential step in validating that your application behaves correctly under real-world conditions. While unit tests focus on verifying small, isolated pieces of logic, E2E tests simulate complete user workflows such as logging into an application, creating tasks, and verifying expected outcomes.

In this tutorial, we will build a simple full-stack application and implement E2E tests to ensure it functions as intended. Specifically, we will cover:

Developing a React frontend with a login feature and a Todo application.

Building a Node.js backend API to manage tasks.

Writing Cypress E2E tests that validate login and Todo operations, including both positive and negative scenarios.

By the end, you will have a clear understanding of how to set up E2E testing for a modern web application and ensure reliability across critical user flows.

Project Structure

Here’s how the full project will look:


todo-app/
├── backend/               # Node.js + Express backend
│   ├── server.js
│   └── package.json
├── frontend/              # React frontend (Vite)
│   ├── src/
│   │   ├── App.jsx
│   │   └── App.css 
│   ├── index.html
│   └── package.json
└── cypress/               # Cypress E2E tests
│   ├── e2e/
│    │   └── todo.cy.js
│    ├── support/
│    └── cypress.config.js						
						

Step 1: Backend Setup (Node.js + Express)

Create a backend/ folder and install dependencies:


mkdir backend && cd backend
npm init -y
npm install express cors body-parser						
						

server.js – A simple Express server with in-memory storage:


const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');

const app = express();
app.use(cors());
app.use(bodyParser.json());

const PORT = 5000;

let users = [{ username: 'test', password: '1234' }];
let todos = [];

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const user = users.find(u => u.username === username && u.password === password);
  if (user) {
    return res.json({ success: true, token: 'fake-jwt-token' });
  }
  res.status(401).json({ success: false, message: 'Invalid credentials.' });
});

app.get('/todos', (req, res) => {
  res.json(todos);
});

app.post('/todos', (req, res) => {
  const { task } = req.body;
  todos.push({ id: Date.now(), task });
  res.status(201).json({ success: true });
});

// Delete todo
app.delete('/todos/:id', (req, res) => {
  const id = parseInt(req.params.id);
  todos = todos.filter(todo => todo.id !== id);
  res.json({ success: true });
});

// Update todo
app.put('/todos/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const { task } = req.body;
  const todo = todos.find(todo => todo.id === id);
  if (todo) {
    todo.task = task;
    return res.json(todo);
  }
  res.status(404).json({ success: false, message: 'Todo not found' });
});

app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));						
						

Explanation:

Users & Todos are stored in memory for simplicity.

The login route checks credentials and returns a fake JWT token on success.

CRUD operations are provided for todos (GET, POST, DELETE, PUT).

Step 2: Frontend Setup (React + Vite)

Create a React app:


npm create vite@latest frontend --template react
cd frontend
npm install
npm install axios
npm run dev						
						

src/App.jsx – React frontend with login and todo functionality:


import { useState, useEffect } from "react";
import axios from "axios";
import "./App.css";

const API = "http://localhost:5000";

function App() {
  const [token, setToken] = useState(null);
  const [task, setTask] = useState("");
  const [todos, setTodos] = useState([]);
  const [loginInfo, setLoginInfo] = useState({ username: "", password: "" });
  const [editingId, setEditingId] = useState(null);
  const [editingTask, setEditingTask] = useState("");
  const [message, setMessage] = useState("");

  const login = async () => {
    if (!loginInfo.username.trim() && !loginInfo.password.trim()) {
      setMessage("Username and Password are required.");
      return;
    }
    try {
      const res = await axios.post(`${API}/login`, loginInfo);
      setToken(res.data.token);
    } catch (err) {
      console.log("error", err.response.data);
      setMessage(err.response.data.message);
    }
  };

  const fetchTodos = async () => {
    const res = await axios.get(`${API}/todos`);
    setTodos(res.data);
  };

  const addTodo = async () => {
    if (!task.trim()) return;
    await axios.post(`${API}/todos`, { task });
    setTask("");
    fetchTodos();
  };

  const deleteTodo = async (id) => {
    await axios.delete(`${API}/todos/${id}`);
    fetchTodos();
  };

  const startEdit = (todo) => {
    setEditingId(todo.id);
    setEditingTask(todo.task);
  };

  const cancelEdit = () => {
    setEditingId(null);
    setEditingTask("");
  };

  const saveEdit = async () => {
    if (!editingTask.trim()) return;
    await axios.put(`${API}/todos/${editingId}`, { task: editingTask });
    setEditingId(null);
    setEditingTask("");
    fetchTodos();
  };

  useEffect(() => {
    if (token) fetchTodos();
  }, [token]);

  if (!token) {
    return (
      <div className="container">
        <div className="card">
          <h2>Login</h2>
          <input
            type="text"
            placeholder="Username"
            value={loginInfo.username}
            className="input1"
            onChange={(e) =>
              setLoginInfo({ ...loginInfo, username: e.target.value })
            }
          />
          <input
            type="password"
            placeholder="Password"
            value={loginInfo.password}
            className="input2"
            onChange={(e) =>
              setLoginInfo({ ...loginInfo, password: e.target.value })
            }
          />
          {message && (
            <div id="err-msg" className="err-msg">
              {message}
            </div>
          )}
          <button id="login-btn" className="btn" onClick={login}>
            Login
          </button>
        </div>
      </div>
    );
  }

  return (
    <div className="container">
      <div className="card">
        <h2>Todo App</h2>
        <div className="todo-form">
          <input
            value={task}
            onChange={(e) => setTask(e.target.value)}
            placeholder="Enter task..."
          />
          <button onClick={addTodo} className="btn">
            Add
          </button>
        </div>
        <ul className="todo-list">
          {todos.map((todo, index) => (
            <li key={todo.id} id={`task-${index}`} className="todo-item">
              {editingId === todo.id ? (
                <>
                  <input
                    value={editingTask}
                    id="edit-int"
                    onChange={(e) => setEditingTask(e.target.value)}
                  />
                  <div className="todo-actions">
                    <button className="btn small" onClick={saveEdit}>
                      Save
                    </button>
                    <button className="btn small cancel" onClick={cancelEdit}>
                      Cancel
                    </button>
                  </div>
                </>
              ) : (
                <>
                  <span>{todo.task}</span>
                  <div className="todo-actions">
                    <button
                      className="btn small"
                      onClick={() => startEdit(todo)}
                    >
                      Edit
                    </button>
                    <button
                      className="btn small delete"
                      onClick={() => deleteTodo(todo.id)}
                    >
                      Delete
                    </button>
                  </div>
                </>
              )}
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

export default App;		
						

Explanation:

login() calls the backend login API and sets a token if successful.

fetchTodos() fetches the list of todos from the server.

addTodo(), deleteTodo(), startEdit(), and saveEdit() manage CRUD operations.

Conditional rendering: shows login form if not authenticated, else shows the Todo App.

src/App.css- Css file for styling


body {
  margin: 0;
  font-family: "Segoe UI", sans-serif;
  background: #f2f2f2;
}

.container {
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 1rem;
}

.card {
  background: #fff;
  padding: 2rem;
  border-radius: 12px;
  width: 100%;
  max-width: 500px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
}

h2 {
  margin-bottom: 1.5rem;
  text-align: center;
  color: #333;
}

input {
  width: 100%;
  padding: 0.75rem 1rem;
  border: 1px solid #ccc;
  border-radius: 8px;
  font-size: 1rem;
  box-sizing: border-box;
}

.btn {
  background: #007bff;
  color: #fff;
  border: none;
  padding: 0.75rem 1.5rem;
  border-radius: 8px;
  cursor: pointer;
  font-size: 1rem;
  transition: background 0.2s ease;
}

.btn:hover {
  background: #0056b3;
}

.todo-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: #f8f9fa;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  margin-bottom: 0.75rem;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
}

.todo-actions {
  display: flex;
  gap: 1rem; /* creates space between Edit, Delete, Save, Cancel */
  flex-shrink: 0;
}

.todo-item input {
  flex: 1;
  margin-right: 1rem; /* space between input and buttons when editing */
}

.todo-item.editing {
  align-items: flex-start;
  flex-wrap: wrap;
}

.todo-form {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}

.todo-form input {
  flex: 1;
}

.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.todo-list li {
  background: #f8f9fa;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  margin-bottom: 0.75rem;
  font-size: 1rem;
  color: #333;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
  transition: background 0.2s;
}

.todo-list li:hover {
  background: #e9ecef;
}

.input1 {
  margin-bottom: 1rem;
}

.input2 {
  margin-bottom: 1rem;
}

.err-msg {
  color: red;
  margin-bottom: 10px;
}						
						

UI screenshots,

Step 3: Cypress Setup


npm install --save-dev cypress(This will create cypress folder)
npx cypress open						
						

cypress.config.js:


export default defineConfig({
  e2e: { baseUrl: "http://localhost:5173" },
});						
						

Step 4: Cypress E2E Tests

cypress/e2e/todo.cy.js:


/* eslint-env cypress */

describe("React Todo App - Full Flow", () => {
  const username = "test";
  const password = "1234";
  const visitUrl = "http://localhost:5173";

  Cypress.Commands.add("login", (u = username, p = password) => {
    cy.visit(visitUrl);
    cy.get('input[placeholder="Username"]').type(u);
    cy.get('input[placeholder="Password"]').type(p);
    cy.get("#login-btn").click();
  });

  //Login Success
  it("logs in successfully with correct credentials", () => {
    cy.login();
    cy.contains("Todo App").should("exist");
  });

  //Invalid credentials
  it("shows error on invalid credentials", () => {
    cy.login("wronguser", "wrongpass");
    cy.get("#err-msg").should("contain", "Invalid credentials.");
  });

  //Empty credentials
  it("prevents login with empty credentials", () => {
    cy.visit("http://localhost:5173");
  cy.get("#login-btn").click();
  cy.get("#err-msg").should("contain", "Username and Password are required.");
  });

  //Add task
  it("adds a new task", () => {
    cy.login();
    cy.get('input[placeholder="Enter task..."]').type("test1");
    cy.contains("Add").click();
    cy.get("#task-0").should("contain", "test1");
  });

  // Edit task
  it("edits a task", () => {
    cy.login();
    cy.get("#task-0").should("contain", "test1");

    cy.contains("Edit").click();
    cy.get("#edit-int").clear().type("test1 updated");
    cy.contains("Save").click();
    cy.get("#task-0").should("contain", "test1 updated");
  });

  //Delete task
  it("deletes a task", () => {
    cy.login();
    cy.get('input[placeholder="Enter task..."]').type("test2");
    cy.contains("Add").click();
    cy.get("#task-1");

    cy.contains("Delete").click();
    cy.get("ul").should("not.contain", "test1 updated");
  });
});						
						

Explanation:

Custom command cy.login() simplifies logging in for tests.

Each test checks a single functionality: login, invalid credentials, add, edit, and delete tasks.

Assertions like should("contain", ...) validate that UI reflects the expected state.

Step 5: Run Everything


# Terminal 1 - Backend
cd backend && node server.js

# Terminal 2 - Frontend
cd frontend && npm run dev

# Terminal 3 - Cypress
npx cypress open						
						

here you can screenshot of all the succeed test cases

Ready to Build Something Amazing?

Get in touch with Prishusoft – your trusted partner for custom software development. Whether you need a powerful web application or a sleek mobile app, our expert team is here to turn your ideas into reality.

image