Introdution
In this article I will show you the project I built to learn some React Hooks with unit tests. The application is a simple page that fetches a movie list using HTTP request and display the result. This article will also cover unit tests using react testing library.
Demo on CodeSandbox:
Final code is on github:
fabiothiroki / react-hooks-movies
A cool simple interface for The Open Movie Database API
Thanks for Reiha Hosseini for the frontend design development.
Setup
As a prerequisite you must have Node >= 8.10 and npm >= 5.6 installed on your computer.
First we will create the application structure using create-react-app:
npx create-react-app react-hooks-movies
cd react-hooks-movies
npm start
At this step, my project was built using React version 16.13.1.
Initial project structure
The creact-react-app
creates a basic App
component in the project root directory. We will move this file and its related to its own folder component to keep things more organized. I personally prefer to create a components
folder and move all App
components files to its own App
component folder. Then you just need to change the App.js
import path on index.js
:
import App from './components/App/App';
Check your application on http://localhost:3000/ and everything should be working as before.
Optionally you can copy my index.css content so you have the same result as me.
Api
Now we can start writing the additional modules needed. We will start by writing the one responsible for making the http request to retrieve the movie data. We could build this part directly on App
component but creating a separate module for this will help us write the tests in the near future.
The API used is the free OMDB. Be sure to signup there to get your own API Key, and place it as an environment variable called REACT_APP_API_KEY
. This way you won't expose your key if you want to share your code on Github.
To get some fancy results and cool posters, we will use the search
parameter to fetch movies that have 'war' on its name:
// src/api/api.js
const MOVIE_API_URL = `https://www.omdbapi.com/?apikey=${process.env.REACT_APP_API_KEY}`;
export const fetchMovies = (search = 'war') => (
fetch(`${MOVIE_API_URL}&s=${search}`)
.then(response => response.json())
);
As you can see we're already returning a Promise
containing a parsed JSON.
Movie component
This component will render each movie data returned in the array of the previous module. No secret here, just a plain React
component:
// src/components/Movie/Movie.js
import React from "react";
const Movie = ({ movie }) => {
return (
<figure className="card">
<img
src={movie.Poster}
alt={`The movie titled: ${movie.Title}`}
/>
<figcaption>{movie.Title}</figcaption>
</figure>
);
};
export default Movie;
Reducer
The reducer
is a function
that receives an action
object, and a state
object and returns the new state
that will be rendered by App
component. So basically we will use the reducer
function to handle the application state, that will be managed by three variables: loading
, movies
and error
.
// src/components/App/reducer.js
export const initialState = {
loading: true,
movies: [],
errorMessage: null
};
In this case I prefer the useReducer
hook instead of the useState
hook because I this state is complex enough.
The only action we need for now is the one dispatched when the API request returns successfully, we will call it SEARCH_MOVIES_SUCCESS
.
export const reducer = (state, action) => {
switch (action.type) {
case "SEARCH_MOVIES_SUCCESS":
return {
loading: false,
movies: action.payload,
errorMessage: null,
};
default:
return state;
}
};
So whenever we receive this action, we update the current state to hide both the loading
indicator and the error
message and we update with API Response.
App component
Now on App
component we just need to glue the api
with its reducer
and the Movie
component.
UseReducer hook
The useReducer
hook is a function that receives a reducer
function like the one we have implemented on previous step and an object representing the initial state as second argument. Its return are two variables, the current state and a method for dispatching actions.
So first we add all new imports
:
// src/components/App/App.js
import React, { useReducer } from 'react';
import { initialState, reducer } from "./reducer";
import Movie from "../Movie/Movie";
Now we can call useReducer
inside the functional component, get the initial state from it and render.
export const App = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { movies, errorMessage, loading } = state;
return (
<div className="wrapper">
<h2><strong>Movies</strong></h2>
<div className="cards">
{loading &&
<span>loading...</span>
}
{errorMessage &&
<span>{errorMessage}</span>
}
{movies &&
movies.map((movie, index) => (
<Movie key={`${index}-${movie.Title}`} movie={movie} />
))
}
</div>
</div>
);
};
If you run the current application on browser, you can notice the correct rendering of loading
state.
UseEffect hook
Finally we will effectively render the cool movie posters. But what does this hook do?
By using this Hook, you tell React that your component needs to do something after render.
So in this case we will do the movies data fetching.
First, start adding the new imports:
import React, { useEffect, useReducer } from 'react';
import { fetchMovies } from '../../api/api';
Then inside the component, just after defining the dispatch
method you can already call the hook
:
export const App = () => {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
fetchMovies()
.then(jsonResponse => {
dispatch({
type: "SEARCH_MOVIES_SUCCESS",
payload: jsonResponse.Search
});
});
}, []);
// Hidden previous code
}
The first parameter of useEffect
is a function containing the effect itself and the second parameter is an array of values that the effect depends on, in case we want to conditionally fire this effect. In this case we can pass an empty array because this is a static page.
DOM Testing
At this step your application is working and hopefully is looking the same as the demo.
So now we will focus on testing with tools:
- Jest 24.9.0: the test runner.
- React Testing Library 9.5.0: testing utility that encourages the developer to write tests resembling the way the user sees the application.
The first prerequisite for this test is the mock
of the API part, because we're considering this is an external dependency and in this particular case we don't want to write automated tests. I've just copied the same json
response of the API but kept only one movie to make the test simple and clean.
// src/components/App/App.test.js
import { fetchMovies } from '../../api/api';
jest.mock('../../api/api');
const mockReponse = {
"Search": [{
"Title": "Avengers: Infinity War",
"Year": "2018",
"imdbID": "tt4154756",
"Type": "movie",
"Poster": "https://m.media-amazon.com/images/M/MV5BMjMxNjY2MDU1OV5BMl5BanBnXkFtZTgwNzY1MTUwNTM@._V1_SX300.jpg"
}],
"totalResults": "3964",
"Response": "True"
};
beforeEach(() => {
fetchMovies.mockResolvedValueOnce(Promise.resolve(mockReponse));
});
By using Jest Mock feature, we're basically telling the tests to return the mockResponse
whenever fetchMovies
is called.
The test case we will focus on will consist by the following assertions in the following order:
- After
App
component is renderd, it should display theloading
state. - Then it should trigger the API request, and if successful, the
loading
state should be hidden. - The movies should be rendered.
// src/components/App/App.test.js
import React from 'react';
import { render, waitForElementToBeRemoved } from '@testing-library/react';
import App from './App';
// mock configuration...
test('renders loading first, then movies', async () => {
const { getByText } = render(<App />);
// Should display loading state
expect(getByText(/loading/i)).toBeTruthy();
// Should trigger API request
expect(fetchMovies).toHaveBeenCalledTimes(1);
// Should hide loading state
await waitForElementToBeRemoved(() => getByText(/loading/i));
// Should display the movie returned on mock
expect(getByText(/Avengers/i)).toBeTruthy();
});
As an exercise for the reader, you could also write the test when the API returns an error.
Now if you run npm run test
, this test should pass sucessfully!
Conclusion
I hope you could've learn the basics on how to consume an API and render its data using React Hooks! Automated tests should also be part of your application so I hope you enjoyed this part as well.