Introduction

In this tutorial, we’ll create a simple Todo app using React.js and TypeScript, initialized with Vite, and then we’ll write tests for it using Playwright. We will use playwright to do an end to end test which will simulate the user browser interaction with our todo app like adding new todos, deleting todos, upadtinhg todos e.t.c. By the end of this guide, you’ll have a solid understanding of how to use Playwright to test your React applications.

Prerequisites

Before starting, ensure you have the following installed on your machine:

  • Node.js (>= 14.x)
  • npm or yarn
  • Playwright

Excited? So let’s get to it.

Initialize the React App with Vite

First, let’s create our React application using Vite.

Create the project:

npm create vite@latest todo-app-playwright-test --template react-ts
cd todo-app-playwright-test

Install dependencies

npm install

Start the development server:

npm run dev

Your new Vite project should now be running on http://localhost:5173 if there are no other vite apps running on your system. Otherwise, observe the url in your console.


Build the Todo App

Next, let’s build a simple Todo app. We’ll create components for displaying the list of todos and adding new todos.


Create a Todo interface:

Create a file named types.ts in the src directory:

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

Create the TodoItem component:

src/components/TodoItem.tsx

import React from "react";
import { Todo } from "../types";

interface Props {
  todo: Todo;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
}

const TodoItem: React.FC<Props> = ({ todo, toggleTodo, deleteTodo }) => {
  return (
    <li>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => toggleTodo(todo.id)}
        />
        {todo.text}
      </label>
      <button onClick={() => deleteTodo(todo.id)}>Delete</button>
    </li>
  );
};

export default TodoItem;

Create the TodoList component:

src/components/TodoList.tsx

import React from "react";
import { Todo } from "../types";
import TodoItem from "./TodoItem";

interface Props {
  todos: Todo[];
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
}

const TodoList: React.FC<Props> = ({ todos, toggleTodo, deleteTodo }) => {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
        />
      ))}
    </ul>
  );
};

export default TodoList;

Create the AddTodo component:

src/components/AddTodo.tsx

import React, { useState } from "react";

interface Props {
  addTodo: (text: string) => void;
}

const AddTodo: React.FC<Props> = ({ addTodo }) => {
  const [text, setText] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    addTodo(text);
    setText("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button type="submit">Add Todo</button>
    </form>
  );
};

export default AddTodo;

Update the main App component:

src/App.tsx

import React, { useState } from "react";
import { Todo } from "./types";
import TodoList from "./components/TodoList";
import AddTodo from "./components/AddTodo";

const App: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = (text: string) => {
    const newTodo: Todo = {
      id: todos.length + 1,
      text,
      completed: false,
    };
    setTodos([...todos, newTodo]);
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <AddTodo addTodo={addTodo} />
      <TodoList todos={todos} toggleTodo={toggleTodo} deleteTodo={deleteTodo} />
    </div>
  );
};

export default App;

Add Filter Todos Functionality

src/components/Filter.tsx

import React from "react";

interface Props {
  filter: string;
  setFilter: (filter: string) => void;
}

const Filter: React.FC<Props> = ({ filter, setFilter }) => {
  return (
    <div>
      <button onClick={() => setFilter("all")} disabled={filter === "all"}>
        All
      </button>
      <button
        onClick={() => setFilter("completed")}
        disabled={filter === "completed"}
      >
        Completed
      </button>
      <button
        onClick={() => setFilter("active")}
        disabled={filter === "active"}
      >
        Active
      </button>
    </div>
  );
};

export default Filter;

Update the App component to handle filtering todos:

src/App.tsx

import React, { useState } from "react";
import { Todo } from "./types";
import TodoList from "./components/TodoList";
import AddTodo from "./components/AddTodo";
import Filter from "./components/Filter";

const App: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [filter, setFilter] = useState<string>("all");

  const addTodo = (text: string) => {
    const newTodo: Todo = {
      id: todos.length + 1,
      text,
      completed: false,
    };
    setTodos([...todos, newTodo]);
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const filteredTodos = todos.filter((todo) => {
    if (filter === "completed") {
      return todo.completed;
    }
    if (filter === "active") {
      return !todo.completed;
    }
    return true;
  });

  return (
    <div>
      <h1>Todo List</h1>
      <AddTodo addTodo={addTodo} />
      <Filter filter={filter} setFilter={setFilter} />
      <TodoList
        todos={filteredTodos}
        toggleTodo={toggleTodo}
        deleteTodo={deleteTodo}
      />
    </div>
  );
};

export default App;

Setup Playwright

Install Playwright Testing Framework:

npm install --save-dev @playwright/test

Add Playwright scripts to package.json:

"scripts": {
  "test:e2e": "playwright test",
  "test:e2e:headed": "playwright test --headed",
  "test:e2e:debug": "playwright test --debug"
}

This adds a script to run Playwright tests using the command npm run test:e2e.

Initialize Playwright:

npx playwright install

This will download some browser related libraries for chrome, firefox e.t.c.

Create Playwright configuration file:

Create a file named playwright.config.ts in the root directory:

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",
  use: {
    baseURL: "http://localhost:5173",
    trace: "on-first-retry",
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
  ],
});

Write Playwright Tests

Let’s write tests to ensure our Todo app works as expected.

Create a test file:

Create a directory named tests in the root of your project and add a file named todo.spec.ts:

tests/todo.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Todo App', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/');
  });

  test('should display the correct title', async ({ page }) => {
    // Here, the test tests that the title header 'Todo list' exists in the document
    await expect(page.locator('h1')).toHaveText('Todo List');
  });

  test('should add a new todo', async ({ page }) => {
    // In this test, we will fill the new todo input and submit the for,
    // tjis creates a new todo in the list
    //The test will check and confirm that  a new todo with the text entered above has
    //be added to the list of todos
    await page.fill('input[type="text"]', 'New Todo');
    await page.click('button[type="submit"]');
    await expect(page.locator('li')).toContainText('New Todo');
  });

  test('should toggle a todo', async ({ page }) => {
    // This test shows that whenever we click on a todo items checkbox
    // the state of the todo will change its state from active to completed
    // We will confirm the completed state by checking if the checkbox is checked
    // A second test is done by clicking on the checked checkbox again to move its
    // state from completed to active, and the next test will confirm that now
    // the checkbox is no longer checked
    await page.fill('input[type="text"]', 'Toggle Todo');
    await page.click('button[type="submit"]');
    const todoItem = page.locator('li').filter({ hasText: 'Toggle Todo' });
    const checkbox = todoItem.locator('input[type="checkbox"]');
    await checkbox.check();
    await expect(checkbox).toBeChecked();
    await checkbox.uncheck();
    await expect(checkbox).not.toBeChecked();
  });

  test('should delete a todo', async ({ page }) => {
    // This test tests that when we click the text of 'Delete Todo' next to a
    // todo item that todo will no longer be shown in the list by default
    // because it is deleted
    await page.fill('input[type="text"]', 'Delete Todo');
    await page.click('button[type="submit"]');
    const todoItem = page.locator('li').filter({ hasText: 'Delete Todo' });
    await todoItem.locator('button').click();
    await expect(todoItem).toHaveCount(0);
  });

  test('should filter todos', async ({ page }) => {
    // This tests that when we list todos, an active to and another one in progress
    // When we click the 'Completed Todo' button
    // Only the todo item which is completed is shown
    // When we click the button with 'Active Todo'
    // Only incomplete todos are shown
    // Finally, we wil click the All button and both completed and active todos will be shown
    // All actions adescribed show that the filter is working
    await page.fill('input[type="text"]', 'Completed Todo');
    await page.click('button[type="submit"]');
    await page.locator('li').filter({ hasText: 'Completed Todo' }).locator('input[type="checkbox"]').check();


    await page.fill('input[type="text"]', 'Active Todo');
    await page.click('button[type="submit"]');

    await page.click('button:has-text("Completed")');
    await expect(page.locator('li')).toHaveCount(1);
    await expect(page.locator('li')).toContainText('Completed Todo');

    await page.click('button:has-text("Active")');
    await expect(page.locator('li')).toHaveCount(1);
    await expect(page.locator('li')).toContainText('Active Todo');

    await page.click('button:has-text("All")');
    await expect(page.locator('li')).toHaveCount(2);
  });
});
  • import { test, expect } from ‘@playwright/test’;: Imports the test and expect functions from the Playwright testing library.
  • test.describe(‘Todo App’, () => { … });: Defines a test suite named ‘Todo App’.
  • test.beforeEach(async ({ page }) => { await page.goto(’/’); });: // This makes sure befire each test is run, we visit the home page /

Run the tests:

npm run test:e2e

This will run all the tests.

Conclusion

We have successfully built a Todo app with additional features like deleting and filtering todos and tested these features using Playwright. This demonstrates the power and flexibility of Playwright in automating end-to-end testing for web applications.

Feel free to explore more features and write additional tests to further enhance your Todo app. Playwright’s extensive API and robust support for multiple browsers make it an excellent tool for ensuring the quality and reliability of your web applications.