We revamped our site to better serve our users!
Frontend Hire
Todo App with React, TypeScript, and TDD

Writing Tests

In this section, we'll write tests to confirm the behavior of the Todo App.

Let us see where we are currently. Run the dev server and open the respective dev URL, as Vite shows in your browser.

npm run dev

Also, buckle up. This section is going to be a long ride.

Render Test

As frontend developers, we find it easier to work with visible things. We already know that the component is visible in the browser.

But why do we even need to test it? Writing tests for this seems futile. Right now, the component is straightforward. But, as the component grows, it will become more complex and we cannot be sure that any new changes will not cause regressions.

So, writing tests to confirm our current behavior is a good idea. Let us write a test to verify that the input and button are rendering in the browser.

Test Structure

Create a new App.test.tsx file in the src folder. This file will pretty much contain the tests for the entire App component.

./src/App.test.tsx
import { describe, test } from 'vitest';
 
describe('App', () => {
  test('should render input field and add button', () => {});
});

We previously wrote a sample test for the sum function. Every test should have a meaningful name. The test name should describe what the test is doing. In this case, we are testing if the App component renders the input field and the add button. (read more about test API here)

Describe is used to group tests. It is not necessary to use describe, but it is good practice to group tests. It helps organize the tests and debug them. (read more about describe API here)

Render Component for Testing

We need to render the component in the test. We can use the render function from @testing-library/react to render the component. (read more about render API here)

./src/App.test.tsx
import { describe, test } from 'vitest';
import { render } from '@testing-library/react';
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />); 
  });
});

Query the DOM

We need to query the DOM to check if the input field and the button are present.

We can use various queries. In general, following the guidelines of RTL (read the priority here) for the query priority order is a good idea.

To summarise at a high level, we need to use the queries closest to the user. In our case:

  • We'll use getByRole to query the input and the button.
./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
  });
});

Assert

We have the elements. Now, we need to assert that they are present in the DOM. We can use the expect function from vitest to assert. (read more about expect API here)

And also, use the toBeDefined matcher to check if the element is defined. (read more about toBeDefined matcher here)

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeDefined();
    expect(button).toBeDefined();
  });
});

Run the Test

Now, run the test and see if it passes.

npm run test

The test should pass with an output like this: Test Pass

Improving the Test

Now, toBeDefined is not the best matcher to use here. We can use toBeInTheDocument matcher from @testing-library/jest-dom to check if the element is present in the DOM. (read more about toBeInTheDocument matcher here)

So, let us install @testing-library/jest-dom and use the toBeInTheDocument matcher.

npm install -D @testing-library/jest-dom

We will import the vitest compatible version of @testing-library/jest-dom from @testing-library/jest-dom/vitest. (read more about vitest compatible version here)

And it would import the entire thing.

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest'; 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
});

Rerun the test, and it should pass.

The jest-dom import (line 3 in the above snippet) usually happens in a separate setup file for all the tests in our application. We will do that now as a good practice.

Create a new file, setupTests.ts, at the project's root and add the import.

./setupTests.ts
import '@testing-library/jest-dom/vitest'; 

And remove the import from the test file.

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest'; 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
});

We must also update our vite.config.ts to use this setup file. (read more about setup files here)

./vite.config.ts
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
 
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: './setupTests.ts', 
  },
});

Rerun the test, and it should pass. But you'll see an error related to TypeScript despite the test passing. TypeScript error

This error is because TypeScript does not include the setupTests.ts file in our project. Let's update our tsconfig.json to include this file.

./tsconfig.json
{
  "compilerOptions": {
    /* Some of the options omitted for brevity */
  },
  "include": ["src", "./setupTests.ts"], 
  "references": [{ "path": "./tsconfig.node.json" }]
}

Save the file, and the error should go away.

Great, we wrote our first test, which actually made sense to test. But we are not done yet. We need to test the functionality of the component further.

Before we move on, let us use an eslint plugin to help us write better tests. Install eslint-plugin-testing-library and eslint-plugin-jest-dom as dev dependencies. These plugins will help us write better tests.

npm install -D eslint-plugin-testing-library eslint-plugin-jest-dom

And let us add them to our .eslintrc.cjs file.

.eslintrc.cjs
module.exports = {
  /* Rest of the options omitted for brevity */
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
    'plugin:jest-dom/recommended',
    'plugin:testing-library/react',
    'prettier',
  ],
  /* Rest of the options omitted for brevity */
};

Testing the user interaction

Let us write a test to check if the user can add a task to the list.

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', () => {});
});

So, how can we test user interaction? If we look at the docs of Testing Library, you can see a section for User Actions under the Core API and a page for Firing Events. Here's the exact link to the page.

Inputs are usually changed, and buttons are clicked. We pretty much end up with something like this:

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react'; 
 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', () => {
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    fireEvent.change(input, { target: { value: 'New Task' } });
    fireEvent.click(button);
 
    expect(screen.getByText('New Task')).toBeInTheDocument();
  });
});

Now, if you run your tests, they should be passing! But a couple of things are not good here.

  • First, we still use the old rendered component from the previous test. This old component can cause issues if we are not careful. So, we need to render a new component again.
./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', () => {
    render(<App />); 
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    fireEvent.change(input, { target: { value: 'New Task' } });
    fireEvent.click(button);
 
    expect(screen.getByText('New Task')).toBeInTheDocument();
  });
});

You might try doing something like the above! But that will fail the tests with this error: Test Fail

The error might be intimidating at first, but it is constructive. It tells us that there are multiple elements with the exact text. This error happened because we did not clean up our first test's render. So, the second render had the first test's elements as well. So, we must clean up the first render and subsequent renders.

./src/App.test.tsx
import { describe, expect, test, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
 
import App from './App';
 
afterEach(cleanup); 
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', () => {
    render(<App />);
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    fireEvent.change(input, { target: { value: 'New Task' } });
    fireEvent.click(button);
 
    expect(screen.getByText('New Task')).toBeInTheDocument();
  });
});

Now, our ESLint rules would complain that: cleanup is performed automatically by your test runner, you don't need manual cleanups.

You can read more about this here, figure out why, and find a solution. Here's the link

In short, ESLint assumes we have a global afterEach declared. But we do not! There are multiple ways to fix this issue. We will solve it by moving our clean-up logic to the global setup file.

./setupTests.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
 
afterEach(cleanup); 

And clean up our ./src/App.test.tsx file

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', () => {
    render(<App />);
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    fireEvent.change(input, { target: { value: 'New Task' } });
    fireEvent.click(button);
 
    expect(screen.getByText('New Task')).toBeInTheDocument();
  });
});

This change is also good because we are not repeating the same code in every test, and even if someone forgets to clean up, it will be taken care of.

Now, run the tests, and they should pass. But there is still a problem:

  • To amplify the problem significantly, let us make the input field readonly and see if our tests still pass.
./src/App.tsx
// Rest of the code omitted for brevity
 
function App() {
  // Some of the code omitted for brevity
 
  return (
    <div>
      {/* Some of the code omitted for brevity */}
      <input
        readOnly
        id="task-input"
        value={taskName}
        onChange={(e) => setTaskName(e.target.value)}
      />
      {/* Some of the code omitted for brevity */}
    </div>
  );
}
 
// Rest of the code omitted for brevity

Our tests will still pass! But, we know that the user cannot type in the input field. What we have right now is a false positive.

We could have avoided this problem by testing user interaction differently. Even the docs recommend another library called user-event for testing user interaction. Here's the link to the docs

Let us install user-event and use it in our tests.

npm install -D @testing-library/user-event

Since user interactions are asynchronous, we need to use async, await, and waitFor from @testing-library/react for the changes to take effect. (read more about async methods)

./src/App.test.tsx
import { describe, expect, test } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
 
import App from './App';
 
describe('App', () => {
  test('should render input field and add button', () => {
    render(<App />);
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
 
  test('should add task to list when add button is clicked', async () => {
    const user = userEvent.setup(); 
    render(<App />);
 
    const input = screen.getByRole('textbox', { name: 'Add Task:' });
    const button = screen.getByRole('button', { name: 'Add' });
 
    await user.type(input, 'New Task');
    await user.click(button);
 
    await waitFor(() => {
      expect(screen.getByText('New Task')).toBeInTheDocument();
    });
  });
});

Our tests should fail. Because we are using readOnly on the input field, remove the readOnly prop from the input field and rerun the tests.

./src/App.tsx
// Rest of the code omitted for brevity
 
function App() {
  // Some of the code omitted for brevity
 
  return (
    <div>
      {/* Some of the code omitted for brevity */}
      <input
        readOnly
        id="task-input"
        value={taskName}
        onChange={(e) => setTaskName(e.target.value)}
      />
      {/* Some of the code omitted for brevity */}
    </div>
  );
}
 
// Rest of the code omitted for brevity

Our tests should pass now. We also have a more robust test for user interaction.

Now, we have made a decent amount of progress on tests. Remember where we left our actual business logic? In the next section, let us return to that with TDD (Test Driven Development).

At this point, your code should be a good match to the branch of the repository: 5-writing-tests

On this page