Using React Testing Library to Test Your Next.js Application with TypeScript and GraphQL

Marcos Schead - May 31 - - Dev Community

Introduction

In the fast-paced web development world, ensuring your applications' quality and reliability is paramount. Testing is a critical component in the development process, especially for the frontend where user interaction is most prominent. Using modern tools like React, Next.js, TypeScript, GraphQL, and React Testing Library, developers can build robust and maintainable applications. In this blog post, we'll explore the importance of frontend testing and guide you through a practical example to get you started.

Why Frontend Testing Matters

Ensuring Code Quality

Frontend testing helps maintain high code quality by catching bugs early in the development process. It ensures that individual components function as expected, reducing the chances of errors in the production environment.

Enhancing User Experience

By testing user interactions and UI components, developers can ensure a seamless and bug-free user experience. This is crucial for retaining users and providing a smooth navigation experience.

Facilitating Refactoring

With a comprehensive suite of tests, developers can confidently refactor code without fear of breaking existing functionality. This encourages cleaner and more maintainable codebases.

Improving Collaboration

Testing promotes better collaboration among team members. Clear and concise tests serve as documentation for how components should behave, making it easier for new developers to understand the codebase.

Increasing Productivity

Automated tests save time in the long run by reducing the need for manual testing. This allows developers to focus on building new features and improving existing ones.

Practical Example: Setting Up Frontend Testing

Let's dive into a practical example to illustrate how to set up and run frontend tests in a Next.js application with TypeScript, GraphQL, and React Testing Library.

Step 1: Initialize the Repo

First, we'll create a new Next.js application using a predefined example with Jest for testing.

npx create-next-app@latest --example with-jest blog-todo-graphql --typescript
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Apollo Client and GraphQL

Next, we'll install the Apollo Client and GraphQL to handle our GraphQL queries and mutations.

npm install @apollo/client graphql
Enter fullscreen mode Exit fullscreen mode

Step 3: Update tsconfig.json

To ensure that our imports are correctly resolved, update the tsconfig.json file to include the following paths configuration:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Update React Testing Library

Ensure that you have the latest version of React Testing Library for the best features and bug fixes.

npm install @testing-library/react@15.0.6
Enter fullscreen mode Exit fullscreen mode

Step 5: Clean Up the Project Structure

We'll clean up the project structure by deleting unnecessary files and folders. Keep only the app folder with page.tsx and layout.tsx files.

rm -rf pages __tests__
Enter fullscreen mode Exit fullscreen mode

Step 6: Adjust page.tsx File

Update the page.tsx file to include the following content:

app/page.tsx

export const metadata = {
  title: "App Router",
};

import AddTodo from "@/components/add-todo";
import ListTodos from "@/components/list-todos";

const Home = () => {
  return (
    <div>
      <h1>Todo List</h1>
      <AddTodo />
      <ListTodos />
    </div>
  );
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

Step 7: Add the Necessary Project Files

Here are the files we need to add to our project, along with a brief explanation of their purpose:

schemas/index.gql

type Todo {
  id: String!
  title: String!
  description: String!
}

type Query {
  todos: [Todo!]!
}

type Mutation {
  addTodo(title: String!, description: String!): Todo!
}
Enter fullscreen mode Exit fullscreen mode

This file defines the GraphQL schema for our Todo application, including the Todo type, a query to fetch todos, and a mutation to add a new todo.

components/add-todo.tsx

import { useState } from "react";
import { useMutation, gql } from "@apollo/client";
import { GET_TODOS } from "@/components/list-todos";

export const ADD_TODO = gql`
  mutation AddTodo($title: String!, $description: String!) {
    addTodo(title: $title, description: $description) {
      id
      title
      description
    }
  }
`;

const AddTodo = () => {
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [addTodo] = useMutation(ADD_TODO);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await addTodo({
      variables: { title, description },
      refetchQueries: [{ query: GET_TODOS }],
    });
    setTitle("");
    setDescription("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
        required
      />
      <input
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Description"
        required
      />
      <button type="submit">Add Todo</button>
    </form>
  );
};

export default AddTodo;
Enter fullscreen mode Exit fullscreen mode

This component provides a form for adding new todos. It uses the Apollo Client to send a GraphQL mutation to add the todo and refetches the list of todos after adding a new one.

components/list-todos.tsx

import { useQuery, gql } from "@apollo/client";

export const GET_TODOS = gql`
  query GetTodos {
    todos {
      id
      title
      description
    }
  }
`;

const ListTodos = () => {
  const { loading, error, data } = useQuery(GET_TODOS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <ul>
      {data.todos.map(
        (todo: { id: string; title: string; description: string }) => (
          <li key={todo.id}>
            <h3>{todo.title}</h3>
            <p>{todo.description}</p>
          </li>
        )
      )}
    </ul>
  );
};

export default ListTodos;
Enter fullscreen mode Exit fullscreen mode

This component fetches and displays a list of todos using the Apollo Client. It shows loading and error states while the data is being fetched.

mocks/apollo-mock-provider.tsx

import React from "react";
import { MockedProvider, MockedResponse } from "@apollo/client/testing";

interface ApolloMockProviderProps {
  mocks: MockedResponse[];
  children: React.ReactNode;
}

const ApolloMockProvider: React.FC<ApolloMockProviderProps> = ({ mocks, children }) => (
  <MockedProvider mocks={mocks} addTypename={false}>
    {children}
  </MockedProvider>
);

export default ApolloMockProvider;
Enter fullscreen mode Exit fullscreen mode

This component provides a mocked Apollo Client for testing purposes. It uses the MockedProvider from @apollo/client/testing to supply mock data for our tests.

tests/add-todo.test.tsx

import { render, screen, fireEvent } from "@testing-library/react";
import ApolloMockProvider from "@/mocks/apollo-mock-provider";
import { ADD_TODO } from "@/components/add-todo";
import { GET_TODOS } from "@/components/list-todos";
import Home from "app/page";

const mocks = [
  {
    request: {
      query: ADD_TODO,
      variables: {
        title: "New Test Todo",
        description: "New Test Description",
      },
    },
    result: {
      data: {
        addTodo: {
          id: 1,
          title: "New Test Todo",
          description: "New Test Description",
        },
      },
    },
  },
  {
    request: {
      query: GET_TODOS,
    },
    result: {
      data: {
        todos: [],
      },
    },
  },
  {
    request: {
      query: GET_TODOS,
    },
    result: {
      data: {
        todos: [
          {
            id: 1,
            title: "New Test Todo",
            description: "New Test Description",
          },
        ],
      },
    },
  },
];

test("adds a todo", async () => {
  render(
    <ApolloMockProvider mocks={mocks}>
      <Home />
    </ApolloMockProvider>
  );

  // Check that initially there are no todos
  expect(screen.queryByText("New Test Todo")).not.toBeInTheDocument();
  expect(screen.queryByText("New Test Description")).not.toBeInTheDocument();

  // Add a new todo
  fireEvent.change(screen.getByPlaceholderText(/title/i), {
    target: { value: "New Test Todo" },
  });
  fireEvent.change(screen.getByPlaceholderText(/description/i), {
    target: { value: "New Test Description" },
  });

  fireEvent.click(screen.getByText("Add Todo"));

  // Check that the new todo is added
  expect(await screen.findByText("New Test Todo")).toBeInTheDocument();
  expect(await screen.findByText("New Test Description")).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This test checks the functionality of adding a new todo. It uses the mocked Apollo Provider to simulate the GraphQL requests and responses.

tests/list-todos.test.tsx

import { render, screen } from "@testing-library/react";
import ListTodos from "@/components/list-todos";
import ApolloMockProvider from "@/mocks/apollo-mock-provider";
import { GET_TODOS } from "@/components/list-todos";

const mocks = [
  {
    request: {
      query: GET_TODOS,
    },
    result: {
      data: {
        todos: [
          { id: 1, title: "Test Todo 1", description: "Test Description 1" },
          { id: 2, title: "Test Todo 2", description: "Test Description 2" },
        ],
      },
    },
  },
];

test("lists all todos", async () => {
  render(
    <ApolloMockProvider mocks={mocks}>
      <ListTodos />
    </ApolloMockProvider>
  );

  expect(screen.getByText("Loading...")).toBeInTheDocument();

  expect(await screen.findByText("Test Todo 1")).toBeInTheDocument();
  expect(await screen.findByText("Test Description 1")).toBeInTheDocument();

  expect(await screen.findByText("Test Todo 2")).toBeInTheDocument();
  expect(await screen.findByText("Test Description 2")).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This test checks that the list of todos is correctly fetched and displayed. It uses the mocked Apollo Provider to supply the test data.

Benefits of Independent Tests

As you can see, in this setup, we didn't need to add the ApolloProvider component to our main application, just the MockedProvider for our tests. It's important to note that the ApolloProvider and MockedProvider serve different purposes and are independent of each other. Since this content focuses on demonstrating the tests and not the application running, we don't need to add the ApolloProvider here. However, in a real application, you must include the ApolloProvider to integrate your application with the actual API.

To run the tests, use the command below

npm run test
Enter fullscreen mode Exit fullscreen mode

Conclusion

Frontend testing is an indispensable part of modern web development. It ensures that your application is reliable, maintainable, and provides a great user experience. By integrating testing into your workflow, you can build robust applications with confidence. Follow the steps and add the provided files to set up a solid testing environment for your Next.js application. Happy testing!

. . . . . .