📓 4.5.0.5 NY Times API: Writing and Testing our Reducer and Actions
We're now ready to start refactoring our New York Times (NYT) API application to use the useReducer()
hook to handle state. However, we're going to go a few steps further than we did in the last lesson: we're going to write action creators and action constants for our actions, and we're going to fully test our reducer and action creators.
While we don't have to use action creators or action constants with useReducer()
, it's good to take the time to practice testing. Remember that reducers, action constants, and action creators are just pure JavaScript functions.
Project Planning and Setup
Open up your NYT API app, and all the following directories to src
:
__tests__
reducers
actions
Next, add the following directories to src/__tests__
:
reducers
actions
Now we're ready to start planning our application state — and how our reducers will update it.
Planning Our Initial State
When we use the useState()
hook to manage the state related to our API call, we have three variables:
isLoaded
, initialized tofalse
topStories
, initialize to an empty arrayerror
, initialized tonull
The question we need to answer is whether we should create one reducer to manage all of this state, or separate this state into multiple reducers, or even leave some of the state to be managed by a useState()
hook. What do you think we should do?
Well, we know that the values of isLoaded
, topStories
, and error
are set based on the success or failure of the API call. This is a good indication that these state variables are all related and it is best that we manage them within the same useReducer()
hook. So, we'll do just that.
Here's what our initial state will look like:
{
isLoaded: false,
topStories: [],
error: null
}
Planning our Actions
We'll need to have two actions, one for the success of the API call and another for a failure:
'GET_TOP_STORIES_SUCCESS'
: This action will be dispatched when we receive a response for a successful API call. It will setisLoaded
totrue
and will include atopStories
property with the API response's payload.'GET_TOP_STORIES_FAILURE'
: This action will be dispatched when we receive a response from a failed API call. It will setisLoaded
totrue
and will include anerror
property the API response's error message.
Add Constants for Reducer Actions
Before we go any further, let's create constants for our actions, just as we did in the React with Redux course section:
export const GET_TOP_STORIES_FAILURE='GET_TOP_STORIES_FAILURE'
export const GET_TOP_STORIES_SUCCESS='GET_TOP_STORIES_SUCCESS'
Testing
Now that we have everything set up, we can start testing.
Testing and Writing Our Reducer's Initial State
For our first test, our reducer should just return the unchanged state if no action is specified.
Here's our test:
import topStoriesReducer from '../../reducers/top-stories-reducer';
describe('topStoriesReducer', () => {
const initialState = {
isLoaded: false,
topStories: [],
error: null
};
test('should successfully throw a new error if a non-matching action type is passed into it', () => {
expect(
() => {
topStoriesReducer(initialState, {type: null })
}
).toThrowError("There is no action matching null.");
});
});
We start by importing our reducer (which we haven't created yet — we'll do that in a moment). Then we store the initialState
in a constant in our describe
block. Finally, our test verifies that if no action type is specified, a new error is thrown with the message "There is no action matching null."
.
Next, we need to create our reducer with a switch and a default case:
const topStoriesReducer = (state, action) => {
switch (action.type) {
default:
throw new Error(`There is no action matching ${action.type}.`);
}
};
export default topStoriesReducer;
For now, our reducer throws an error for the default case, just like we tested for. If we run our tests, they will pass.
Testing and Writing GET_TOP_STORIES_SUCCESS
Now we're ready to write a test for our GET_TOP_STORIES_SUCCESS
action. This action will be triggered if our API call is successful.
Here's the test:
import * as c from './../../actions/ActionTypes';
describe('topStoriesReducer', () => {
let action; // Don't forget to declare action as a variable.
... // previous initialState variable.
test('successfully getting top stories should change isLoaded to true and update topStories', () => {
const topStories = "An article";
action = {
type: c.GET_TOP_STORIES_SUCCESS,
topStories
};
expect(topStoriesReducer(initialState, action)).toEqual({
isLoaded: true,
topStories: "An article",
error: null
});
});
});
First, we need to make sure we import our constants from ActionTypes.js
and create an action
variable that we can reuse throughout the tests.
Note that we've created a constant called topStories
which is storing a string. Our reducer doesn't care what the payload will look like — for the purposes of our test, we just want to make sure our new action will update the topStories
property correctly.
Our test will verify that when the GET_TOP_STORIES_SUCCESS
action is triggered, isLoaded
will be set to true
and the topStories
property will be updated to the payload (in this case, a string).
Once we make sure the test fails, we can update our reducer to make it pass:
import * as c from '../actions/ActionTypes';
const topStoriesReducer = (state, action) => {
switch (action.type) {
case c.GET_TOP_STORIES_SUCCESS:
return {
...state,
isLoaded: true,
topStories: action.topStories
};
default:
throw new Error(`There is no action matching ${action.type}.`);
}
};
export default topStoriesReducer;
Our new action returns a new state object: we use JavaScript's spread syntax to make a copy of the state
object, and we specify that isLoaded
is set to true
and the topStories
property is set to action.topStories
— the payload we've passed into our action.
If we run our tests, our latest test will pass.
Testing and Writing GET_TOP_STORIES_FAILURE
Next we'll test and write the second action — GET_TOP_STORIES_FAILURE
. Both the test and the reducer action will look very similar to GET_TOP_STORIES_SUCCESS
. Here's the test:
...
test('failing to get topStories should change isLoaded to true and add an error message', () => {
const error = "An error";
action = {
type: c.GET_TOP_STORIES_FAILURE,
error
};
expect(topStoriesReducer(initialState, action)).toEqual({
isLoaded: true,
topStories: [],
error: "An error"
});
});
...
We create an error
constant that holds a string. The action itself looks very similar to GET_TOP_STORIES_SUCCESS
— the only difference is the payload. We'll expect the new state to have isLoaded
set to true
and error
set to "An error"
. Meanwhile, topStories
will remain an empty array since it won't change if we don't get a successful payload.
Verify that the test fails. Then, we can update our reducer:
import * as c from '../actions/ActionTypes';
const topStoriesReducer = (state, action) => {
switch (action.type) {
case c.GET_TOP_STORIES_SUCCESS:
return {
...state,
isLoaded: true,
topStories: action.topStories
};
case c.GET_TOP_STORIES_FAILURE:
return {
...state,
isLoaded: true,
error: action.error
};
default:
throw new Error(`There is no action matching ${action.type}.`);
}
};
export default topStoriesReducer;
As we can see, the actions for success and failure are very similar — they just have different payloads.
At this point, our reducer is complete.
Testing and Writing Action Creators
Next, we'll write action creators for our reducer actions. We'll also test these action creators. Since this is a review of something we've learned how to do previously, we will run through this quickly.
Here are the tests:
import * as actions from './../../actions';
import * as c from './../../actions/ActionTypes';
describe('top stories reducer actions', () => {
it('getTopStoriesSuccess should create GET_TOP_STORIES_SUCCESS action', () => {
const topStories = "An article";
expect(actions.getTopStoriesSuccess(topStories)).toEqual({
type: c.GET_TOP_STORIES_SUCCESS,
topStories
});
});
it('getTopStoriesFailure should create GET_TOP_STORIES_FAILURE action', () => {
const error = "An error";
expect(actions.getTopStoriesFailure(error)).toEqual({
type: c.GET_TOP_STORIES_FAILURE,
error
});
});
});
These tests just verify that the JavaScript functions we'll create to generate our reducer actions actually do so successfully.
Here are the functions to make our new tests pass:
import * as c from './ActionTypes';
export const getTopStoriesSuccess = (topStories) => ({
type: c.GET_TOP_STORIES_SUCCESS,
topStories
});
export const getTopStoriesFailure = (error) => ({
type: c.GET_TOP_STORIES_FAILURE,
error
});
Note that we export each action creator separately.
Summary
At this point, we've planned out the initial state of our reducer and how our reducer will change it. We created constants for each of our reducer actions and then used test-driven development to create a reducer that will update state when we make an API call. Finally, we tested and wrote action creators that will make it easier to dispatch our actions in our application.
However, we still haven't refactored our application to use the useReducer()
hook! Let's do that next and wrap up this practice project.