In this article we will develop a basic CRUD React app without having an API in place. Instead we will make use of Mock Service Worker to intercept & mock our fetch calls. React Query will be used as a data fetching library and we will follow a test-first approach using React Testing Library.
React-Query: For data fetching.
MSW: To intercept & mock our API calls.
React Testing Library: Write our tests.
Let's imagine a scenario where you have the specifications and requirements for your UI already but the API your app is supposed to interact with is not ready yet. Only the contract itself is already defined.
The API is roughly defined as:
GET /users, returns all users
GET /users/:id returns a user by id
POST /users, creates a new user
PUT /users/:id, updates an existing user by id
DELETE /users/:id, deletes an existing user by primary key.
So it is a basic Create Read Update Delete feature set.
Hence our app will have the following features:
- list users with user name
- show a specific user details
- update a specific user
- create a new user
- delete user
Design TRIGGER Warning: For the sake of simplicity we will not care about Design or UX in this guide. We will focus solely on raw feature demonstration. So be warned, this will look like 💩!
The Setup
Start with creating a create-react-app
:
npx create-react-app react-tdd
And install our extra dependencies:
yarn add react-query
yarn add -D msw @mswjs/data
Clean up and React Query
Let's get at least the basic app foundation going before writing our first tests. First let's rip out everything we don't need from src/App.js
, add a QueryClientProvider
from react-query
and a placeholder Users
component.
import { QueryClient, QueryClientProvider } from 'react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
}
export default App;
Users.js
export function Users() {
return <div>Users</div>;
}
Get Mock Service Worker up and running
Because we are not developing against an API and we also don't want to mock our fetch calls nor react-query
itself we use msw
to intercept fetch calls and return mock data. To set up msw
we first need to run its initial setup script which will create the service worker script for us.
npx msw init public/ --save
Next we create 3 new files:
src/mocks/db.js
.
import { factory, primaryKey } from '@mswjs/data';
export const mockUsers = [
{
id: '1',
name: 'Alice',
email: 'alice@aol.com',
},
{
id: '2',
name: 'Bob',
email: 'bob@aol.com',
},
{
id: '3',
name: 'Dennis',
email: 'dennis@aol.com',
},
];
// Create a "db" with an user model and some defaults
export const db = factory({
user: {
id: primaryKey(),
name: () => 'Firstname',
email: () => 'email@email.com',
},
});
// create 3 users
mockUsers.forEach((user) => db.user.create(user));
Here we created some fake/mock data and then made use of MSW's data
library to create an in-memory database. This will allow us to read & change data while developing/testing our app, almost as if we were interacting with a real DB.
src/mocks/server.js
import { setupServer } from 'msw/node';
import { db } from './db';
// for node/test environments
export const server = setupServer(...db.user.toHandlers('rest', 'http://localhost:8000/api/'));
src/mocks/browser.js
import { setupWorker } from 'msw';
import { db } from './db';
// for browser environments
export const worker = setupWorker(...db.user.toHandlers('rest', 'http://localhost:8000/api/'));
Then we also create 2 request handlers that will intercept any call to the specified URL. A worker for browser environments which can be used in Browser tests (e.g. Cypress) or during development in general. And one server for node environments which will be used in our Testing Library tests.
We also make use of the toHandlers()
utility which takes a DB model, User in this case, and creates all the handlers for the usual CRUD operations automagically. This does exactly match our real API's specifications. What a lucky coincidence!
With that in place we can connect it to our app & test runner.
For tests we can use src/setupTests.js
:
import '@testing-library/jest-dom';
import { server } from './mocks/server.js';
// Establish API mocking before all tests.
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished.
afterAll(() => server.close());
For our browser environments we call worker.start
as soon as possible in src/App.js
:
import { QueryClient, QueryClientProvider } from 'react-query';
import { Users } from './Users';
+ if (process.env.NODE_ENV === 'development') {
+ const { worker } = require('./mocks/browser');
+ worker.start();
+ }
const queryClient = new QueryClient();
Now any matching call http://localhost:8000/api/*
, our imaginary API, will be intercepted and mock data will be returned - in tests AND in the real app if we would start the development server with yarn start
.
First test
We have set up the base of our app and configured MSW. This would be a good time to start and actually develop our UI. For that we will write a test first. It will fail (🔴) at first and we will implement the actual code to make it pass (🟢) afterwards. That will be the flow we will use to implement all the following features as well.
From now on we can leave yarn test
and yarn start
running in parallel to watch our tests and develop our app in the browser.
Let's assume our users list will have a loading state while loading users.
Users.test.js
import { render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Users } from './Users';
describe('Users', () => {
test('renders loading', async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
await waitFor(() => {
expect(screen.getByText('Loading Users...')).toBeInTheDocument();
});
});
});
Our test fails (🔴) with Unable to find an element with the text: Loading Users....
as expected. Now we try to get it to pass.
In src/Users.js
we make use of useQuery
and a fetch
helper function getUsers
to call our users API endpoint at /api/users
. Eventually we handle the isLoading
state.
import { useQuery } from 'react-query';
async function getUsers() {
try {
const data = await fetch(`http://localhost:8000/api/users`);
if (!data.ok) {
throw new Error(data.status);
}
const json = await data.json();
return json;
} catch (error) {
console.log(error);
}
}
export function Users() {
const { isLoading } = useQuery('users', getUsers);
if (isLoading) {
return <div>Loading Users...</div>;
}
return <div>Users</div>;
}
Our tests should pass now (🟢).
Next feature is actually showing the list of users. Again, we write our test first.
In Users.test.js
we expect the names of all our mock users to be displayed.
import { mockUsers } from './mocks/db';
...
test('lists users', async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
await waitFor(() => {
mockUsers.forEach((mockUser) => {
expect(screen.getByText(mockUser.name, { exact: false })).toBeInTheDocument();
});
});
});
It fails (🔴) and we implement the correct code to make it pass.
export function Users() {
const { isLoading, data: users } = useQuery('users', getUsers);
if (isLoading) {
return <div>Loading Users...</div>;
}
return (
<>
<div>Users</div>
<ul>
{users.map((user) => (
<li key={user.id}>
<div>Name: {user.name}</div>
</li>
))}
</ul>
</>
);
}
Tests pass (🟢) and we can go on implement the next feature.
Our app should have the functionality for creating users as well. You know the drill: failing test first!
Users.test.js
test('create new user', async () => {
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<Users />
</QueryClientProvider>
);
const createButton = await screen.findByText('Create new User');
fireEvent.click(createButton);
const newUserInList = await screen.findByText('Name: John');
expect(newUserInList).toBeInTheDocument();
});
And the matching implementation. We create a new component CreateUser
.
import { useMutation, useQueryClient } from 'react-query';
async function createUser(newUser) {
try {
const data = await fetch(`http://localhost:8000/api/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newUser),
});
if (!data.ok) {
throw new Error(data.status);
}
const json = await data.json();
return json;
} catch (error) {
console.log(error);
}
}
export function CreateUser() {
const queryClient = useQueryClient();
const createUserMutation = useMutation((newUser) => createUser(newUser), {
onSuccess: () => {
queryClient.invalidateQueries('users');
},
});
return (
<button
onClick={() =>
createUserMutation.mutate({
id: '4',
name: 'John',
email: 'john@aol.com',
})
}
>
Create new User
</button>
);
}
We use React-Query's useMutation
and a helper function createUser
to make a POST call to our API. onSuccess
we invalidate our users
data to trigger a refetch. For simplicity we hard code the new users info.
Our test passes (🟢).
At this point I think it is clear how a possible workflow could look like and what the possibilities & advantages of having a mocked interactive API are. Our UI is ready to be connected to a real API once it is implemented.
I won't go through testing all the other features here but instead link to a repository with the completed code in place.
Or maybe you want to take it as a challenge and complete the rest of the tests yourself? Here are some ideas that should probably be implemented next:
- We are still missing "Show a user's detailed info", "Updating a user" and "Deleting a user"
- What about Error handling and states?
- Another thing that already stands out is that there could be a lot of repetition with the fetch helper functions. Maybe refactor and find a better abstraction for it?
Repository: : https://github.com/DennisKo/react-msw-demo
I am open for questions and improvements! Contact me here or on Twitter:
Top comments (2)
Very useful article, thank you
How would you handle tests where code is 4xx or 5xx? Do these always log in the console, even when they're tests?