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
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
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": {
"@/*": ["./*"]
}
}
}
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
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__
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;
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!
}
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;
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;
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;
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();
});
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();
});
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
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!