CardContext
jacobwicks
Posted on January 17, 2020
Now let's make Answering
display a card to the user. To display a card Answering needs to get the card from somewhere. The component that will give the card to Answering
is a React Context component. We are going to use a Context
component named CardContext
to manage the array of cards. Our components will get the array of cards and the index of the current card from the CardContext
.
This post will show you how to make the CardContext
. After we make the CardContext
, we'll change the App
and Answering
so that Answering
can access the cards. We'll make Answering
show the question from the current card. The last thing we'll do in this post is make clicking the Skip
Button change the current index in CardContext
to the index of next card in the cards array. In the next post we'll make Answering
show the answer from the current card after the user clicks the Submit
.
What is Context?
Context is one of the React Hooks. Context
does three things for this app:
-
Context
contains data, like the array of card objects and the index number of the current card -
Context
lets the components access the data contained inContext
-
Context
lets components dispatch actions toContext
. WhenContext
receives an action it makes changes to the data that it contains
The Four Parts of CardContext
We'll make the four different parts of the CardContext
-
initialState
: the object that has the starting value of thecards
array and the starting value of thecurrent
index. -
reducer
: the function that handles the actions dispatched toContext
and makes changes to the data in theContext
. For example, when thereducer
handles a 'next' action it will change thecurrent
index to the index of the next card in thecards
array. -
CardContext
: The context object contains the data. Contains the array ofcards
and thecurrent
index. -
CardProvider
: the React component that gives components inside it access to the data in theCardContext
.
Types.ts: Make the types.ts File
File: src/types.ts
Will Match: src/complete/types-1.ts
Before we make CardContext
we will make the types file. The types file is where we will keep all the TypeScript interface types for this app. Interface types define the shape of objects. Assigning types lets you tell the compiler what properties objects will have. This lets the compiler check for errors, like if you try to use a property that is not on an object.
Create a new file named types.ts
in the src/
folder.
The Card Interface
Copy or retype the interface Card
into types.ts
and save it. Card
models a single flashcard. It has three properties: answer, question, and subject. Each property is a string.
//defines the flashcard objects that the app stores and displays
export interface Card {
//the answer to the question
answer: string,
//the question prompt
question: string,
//the subject of the question and answer
subject: string
}
We will keep an array of Card
objects in CardContext
. We will call this array 'cards.' The array cards
will be our data model of a real world object, a deck of flashcards. Components in the app will be able to use CardContext
to look at the cards
. For example, Answering
will look at a single card in cards
and show the user the question property inside of a Header
.
We will come back to the types file later in this post when we need to declare more types.
Testing CardContext
To fully test CardContext
we will test CardProvider
, CardContext
, and the reducer
. We will start by testing the reducer
, the function that handles actions correctly and returns the state object that holds the cards. Then we will test the CardProvider
, starting with a test that it renders without crashing. Later we will write a helper component to make sure that CardContext
returns the right data.
The Reducer
The reducer
is what makes changes to the state held in a Context
. Each Context
has a dispatch
function that passes actions to the reducer
. The reducer
handles actions using a switch statement. The reducer
's switch statement looks at the type of the action.
The switch statement has a block of code, called a case
, for each action type. The case
is where you write the code that will change the state. The reducer
will run the code inside the case
that matches the action type. The code inside each case handles the action and returns a state object.
We'll start out by testing that the reducer takes a state object and an action object and returns the same state object.
CardContext Test 1: Reducer Returns State
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-1.tsx
import React from 'react';
import { render, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { reducer } from './index';
afterEach(cleanup);
describe('CardContext reducer', () => {
it('returns state', () => {
const state = {};
const action = { type: undefined };
expect(reducer(state, action)).toEqual(state);
})
})
Put this test inside a describe() block. Name the describe block 'CardContext reducer.' The describe block is a way to group tests. When you run the tests, Jest will show you the name of the describe block above the tests that are inside it. The test names will be indented to show that they are inside a describe block.
This test goes inside a describe block because we are going to group all the tests for the reducer together.
Running Tests for One File
Run this test. While we are making CardContext
we only care about the tests for CardContext
. While you are running Jest, type 'p' to bring up the file search. Type 'CardContext,' use the arrow keys to highlight CardContext/index.test.tsx
, and hit enter to select this test file.
Now we are only running the tests inside this test file.
Pass CardContext Test 1: Reducer Returns State
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-1.tsx
Write the first version of the reducer
. The reducer
takes two parameters.
The first parameter is the state object. We have not yet declared the shape of the state for CardContext
. So we'll assign the state parameter a type of any
. Later we will change the state parameter to a custom CardState
type. CardState will be defined in the file types.ts
.
The second parameter is the action object. Actions must have a type. The reducer
always looks at the type of the action to decide how to handle it. We have not declared the types of actions that CardContext
will handle. So we'll assign action a type of any
to the actions. Later we will change it to a custom CardAction
type. CardAction
will be defined in the file types.ts
.
//the reducer handles actions
export const reducer = (state: any, action: any) => {
//switch statement looks at the action type
//if there is a case that matches the type it will run that code
//otherwise it will run the default case
switch(action.type) {
//default case returns the previous state without changing it
default:
return state
}
};
The way that the reducer
handles the actions that it receives is with a switch statement. The switch statement looks at the action type.
//the first argument passed to the switch statement tells it what to look at
switch(action.type)
The switch statement looks for a case
that matches the type of the action. If the switch statement finds a case that matches the action type, it will run the code in the case. If the switch case does not find a case that matches the action type, it will run the code in the default case.
We have only written the default case. The default case returns the state object without any changes. The first test that we wrote passes an empty object {}, and an action with type undefined
. The reducer
will pass the action to the switch statement. The switch statement will look for an action with a matching type, undefined
, fail to find it, and run the default case. The default case will return the empty object {} that the reducer received, so the reducer will return an empty object.
This doesn't do anything useful yet, but it does pass our first test.
CardContext Test 2: CardProvider Renders Without Crashing
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-2.tsx
One of the exports from Context
s is the Provider
. Provider
s are React components that make the Context
available to all of their child components. The Provider
for CardContext
is called CardProvider
. Add an import of the CardProvider
from index. We will write the CardProvider
to pass this test.
import { CardProvider } from './index';
The test to show that the CardProvider
renders without crashing is just one line. Use JSX to call CardProvider
inside the render()
function.
it('renders without crashing', () => {
render(<CardProvider children={[<div key='child'/>]}/>)
});
React Context Provider
requires an array of child components. It can't be rendered empty. So we pass the prop children
to CardProvider
. The code
[<div key='child'/>]
is an array that contains a div. The div has a key because React requires components to have a key when it renders an array of components.
This test will fail because we haven't written the CardProvider
yet.
Pass CardContext Test 2: CardProvider Renders Without Crashing
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-2.tsx
Import createContext
and useReducer
from React.
import React, { createContext, useReducer } from 'react';
We'll use createContext
and useReducer
to make the CardContext
work. Here are some explanations of what they do. Don't worry if you don't understand createContext and useReducer. You will learn more about them by seeing them in action.
createContext() takes an initial state object as an argument. It returns a context object that can be used by the Provider
component. After we pass Test 2 we will make an example array cards
and pass it to createContext
as part of the initialState
object.
useReducer() takes a reducer
function like the one we just wrote and adds a dispatch
method to it. The dispatch
method is a function that accepts action
objects. When a React component calls the dispatch
from a Context
, the component sends an action to the reducer
of that Context
. The reducer
can then change the state
in the Context
. That's how a component can do things like make a button that changes the index to the index of the next card. The button will use dispatch
to send an action to the reducer
, and the reducer
will handle the action and make the changes.
InitialState
Declare the initialState
object below the reducer
.
//the object that we use to make the first Context
const initialState = {};
Start with an empty object. This empty object initialState
will be enough to get the CardProvider
to pass the first test. Later we will define a CardState
interface and make the initialState
match that interface. The CardState
will contain the array cards
and the current
index number.
Make the CardContext
Use createContext
to make a context object CardContext
out of the initialState
.
//a context object made from initialState
const CardContext = createContext(initialState);
Declare the CardProviderProps Interface
Declare an interface for the props that CardProvider
will accept. Call the interface CardProviderProps
. CardProvider
can accept React components as children. Assign the type React.ReactNode to the children
prop.
We keep the interface type declaration for CardProviderProps
in this file instead of types.ts because we won't need to import the CardProviderProps
into any other files. It will only be used here. Types.ts holds types that will get used in more than one place in the App.
//the Props that the CardProvider will accept
type CardProviderProps = {
//You can put react components inside of the Provider component
children: React.ReactNode;
};
This is the first version of CardProvider
.
Call useReducer
to get an array containing values for the state object and the dispatch methods.
Declare an object value
. We create value
using the spread operator(...). The spread operator can be used to create arrays and objects. Using the spread operator on the state object tells the compiler to create an object using all the properties of state, but then add the dispatch method.
CardProvider
returns a Provider
component. CardProvider
makes value
available to all of its child components.
const CardProvider = ({ children }: Props ) => {
//useReducer returns an array containing the state at [0]
//and the dispatch method at [1]
//use array destructuring to get state and dispatch
const [state, dispatch] = useReducer(reducer, initialState);
//value is an object created by spreading state
//and adding the dispatch method
const value = {...state, dispatch};
return (
//returns a Provider with the state and dispatch that we created above
<CardContext.Provider value={value}>
{children}
</CardContext.Provider>
)};
Instead of exporting a default value, export an object containing CardContext
and CardProvider
.
export {
//some components will import CardContext so they can access the state using useContext
CardContext,
//the App will import the CardProvider so the CardContext will be available to components
CardProvider
};
Save the file. Now CardContext
renders without crashing!
Making InitialState and Declaring the CardState Type
File: src/services/CardContext/index.tsx
Will match: src/services/CardContext/complete/index-3.tsx
Now we are going to make the array of cards
that will go in the CardContext
. These cards are objects of the type Card
. We made the type Card
earlier. Each Card
will have an answer, question, and a subject.
Import Card
from types.
import { Card } from '../../types';
We are going to declare the variables card1
, card2
, and cards
. Put these variables in the file after the imports but before everything else. JavaScript variables have to be declared before they are used. If you put these variables too far down in the file you'll get an error when you try to use the variables before they are declared.
Declare card1
. To tell TypeScript that card1
has the type Card
, put : Card
after the declaration but before the =.
Because card1
is an object of type Card
, it needs to have an answer, question, and a subject. Answer, question and subject are all strings. But the answer is going to have multiple lines. We will store the answer as a template literal. That sounds complicated, but what it basically means is that if you write a string inside of backticks instead of quote marks ' ' or " ", then you can use linebreaks.
Here's card1
:
//declare a card object
const card1: Card = {
question: 'What is a linked list?',
subject: 'Linked List',
//answer is inside of backticks
//this makes it a 'template literal`
//template literals can contain linebreaks
answer: `A linked list is a sequential list of nodes.
The nodes hold data.
The nodes hold pointers that point to other nodes containing data.`
};
And card2
:
//declare another card object
const card2: Card = {
question: 'What is a stack?',
subject: 'Stack',
answer: `A stack is a one ended linear data structure.
The stack models real world situations by having two primary operations: Push and pop.
Push adds an element to the stack.
Pop pulls the top element off of the stack.`
};
Now declare the array cards
. TypeScript will infer that cards
is an array of objects with the type Card
because all the objects in the array when it is created fit the Card
interface.
//make an array with both cards
//this is the starting deck of flashcards
const cards = [card1, card2];
We will put this array of cards
into the initialState
object.
Types.ts: Declare CardState Interface
File: src/types.ts
Will Match: src/complete/types-2.ts
Before we put the cards
into initialState
, we need to declare the CardState
interface. initialState
will fit the CardState
interface. CardState
will have cards
, which is the array of Card
objects that represents the deck of flashcards. CardState
will also have current
, the number that is the index of the card in cards
that the user is currently looking at.
We also need to declare that CardState
contains the dispatch
method. dispatch
is the function that passes actions to the Context
reducer
. We haven't made the CardAction
type that will list all the types of actions that CardContext
can handle. When we do, we'll change the type of the dispatch actions to CardAction
. For now, we'll make the actions any
type.
//the shape of the state that CardContext returns
export interface CardState {
//the array of Card objects
cards: Card[],
//the index of the currently displayed card object
current: number,
//the dispatch function that accepts actions
//actions are handled by the reducer in CardContext
dispatch: (action: any) => void
};
Make the InitialState Object
File: src/services/CardContext/index.tsx
Will match: src/services/CardContext/index-3.tsx
Import the CardState
interface.
import { Card, CardState } from '../../types';
Make reducer
Use CardState
Now that we have declared the CardState
interface, reducer
should require the state
object to be a CardState
.
Change the first line of the reducer
from
//the reducer handles actions
export const reducer = (state: any, action: any) => {
To
//the reducer handles actions
export const reducer = (state: CardState, action: any) => {
Now the reducer
requires the state to be a CardState
.
Change initialState
Change the definition of initialState
from
//the object that we use to make the first Context
const initialState = {};
To this:
//the object that we use to make the first Context
//it is a cardState object
export const initialState: CardState = {
//the deck of cards
cards,
//the index of the current card that components are looking at
current: 0,
//dispatch is a dummy method that will get overwritten with the real dispatch
//when we call useReducer
dispatch: ({type}:{type:string}) => undefined,
};
We have made initialState
fit the CardState
interface. initialState
is exported because it will be used in many test files.
Add Optional testState parameter to CardProviderProps
Speaking of tests, we want to be able to use a state object that isn't initialState for some of our tests. Add an optional prop testState
to CardProviderProps
. testState
will fit the interface CardState
. testState
is optional, so put a question mark ?
in front of the :
.
//the Props that the CardProvider will accept
type CardProviderProps = {
//You can put react components inside of the Provider component
children: React.ReactNode;
//We might want to pass a state into the CardProvider for testing purposes
testState?: CardState
};
Change CardProvider to Use Optional testState Prop
Add testState
to the list of props that we get from CardProviderProps
. Change the arguments passed to useReducer
. If CardProvider recieved a testState
, it will pass the testState
to useReducer
. Otherwise, it will use the initialState
object declared earlier in the file.
const CardProvider = ({ children, testState }: CardProviderProps ) => {
//useReducer returns an array containing the state at [0]
//and the dispatch method at [1]
//use array destructuring to get state and dispatch
const [state, dispatch] = useReducer(reducer, testState ? testState : initialState);
Test That CardContext Provides initialState
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-3.tsx
Import initialState
from index.
import { CardProvider, initialState } from './index';
Change the CardContext reducer Test for 'returns state'
The first test of the reducer
isn't passing a CardState
. It's passing an empty object. Let's change that. Instead of passing reducer
an empty object, pass it the initialState
object that we imported from CardContext/index.tsx
.
Change the 'returns state' test from:
it('returns state', () => {
const state = {};
const action = { type: undefined };
expect(reducer(state, action)).toEqual(state);
});
To use initialState
:
it('returns state', () => {
const action = { type: undefined };
expect(reducer(initialState, action)).toEqual(initialState);
});
Testing CardContext
The creator of the React Testing Library says that the closer your tests are to the way that your users use your app, then the more confident you can be that your tests actually tell you the app works. So React Testing Library doesn't look at the inside of React components. It just looks at what is on the screen.
But the CardContext
doesn't put anything on the screen. The only time the user will see something from CardContext
on the screen is when another component gets something from CardContext
and then shows it to the user. So how do we test CardContext
with React Testing Library? We make a React component that uses CardContext
and see if it works!
Make CardConsumer, A Helper React Component in the Test File
The best way I have figured out how to test Context
components is to write a component in the test file that uses the Context
that you are testing. This is not a component that we'll use anywhere else. It doesn't have to look good. All it does is give us an example of what will happen when a component in our app tries to get data from the Context
.
We'll call the helper component CardConsumer
. It will use the CardContext
and display the current index, and all three properties of the current question.
Isn't the Helper Component Just Doing the Same Thing that the App Components Will Do?
Yes. It is. The other components that we will make in this app will access all the different parts of CardContext
. We'll write tests for those components to make sure that they work. Taken together, all the tests for all those components will tell us everything that the tests using the helper component will tell us.
But CardConsumer
displays it all in one place, and that place is in the test file for the CardContext
itself. If CardContext
doesn't work, some of the tests for the components that use CardContext
might fail. But we know for sure that the tests for CardContext
will fail. And that gives us confidence that we can modify CardContext
without breaking the app!
Make CardConsumer: the Helper Component
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-4.tsx
Import useContext
from React. CardConsumer
will use useContext
to access CardContext
, just like our other components will.
import React, { useContext } from 'react';
Import CardState
from types.ts
.
import { CardState } from '../../types';
Import CardContext
.
import { CardContext, CardProvider, initialState } from './index';
Write the helper component CardConsumer
. The only new thing you are seeing here is the call to useContext
. We imported CardContext
and pass it to useContext
as an arguent: useContext(CardContext)
.
As I talked about earlier, useContext
lets you access the data in a Context
. We are using useContext
to get cards
and the current
index.
Then we declare a const card
and assign it a reference to the object at the current
index in cards
. We return a div with each property from card
displayed so that we can use React Testing Library matchers to search for them. CardConsumer
is using CardContext
the same way our user will. That is why it is useful for testing.
//A helper component to get cards out of CardContext
//and display them so we can test
const CardConsumer = () => {
//get cards and the index of the current card
const { cards, current } = useContext(CardContext);
//get the current card
const card = cards[current];
//get the question, answer, and subject from the current card
const { question, answer, subject } = card;
//display each property in a div
return <div>
<div data-testid='current'>{current}</div>
<div data-testid='question'>{question}</div>
<div data-testid='answer'>{answer}</div>
<div data-testid='subject'>{subject}</div>
</div>
};
Make renderProvider: A Helper Function to Render CardConsumer Inside CardProvider
Every component that uses a Context
has to be inside the Provider
component for that Context
. Every component that will use CardContext
needs to be inside the CardContext
Provider
, which we named CardProvider
. CardConsumer
is a component that uses CardContext
. So CardConsumer
needs to be inside CardProvider
. Let's write a helper function named renderProvider
that renders the CardConsumer inside the CardContext.
//renders the CardConsumer inside of CardProvider
const renderProvider = (testState?: CardState) => render(
<CardProvider testState={testState}>
<CardConsumer/>
</CardProvider>
);
Now when we want to look at CardConsumer
for tests we can just call renderProvider()
.
Do you see that renderProvider
takes an optional testState
prop? That is so that when we want to test a certain state, we can pass the state to renderProvider
. If we just want the normal initialState
that the CardProvider
has, then we don't need to pass anything to renderProvider
.
CardContext Tests 4-7: CardContext Provides Correct Values
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-5.tsx
We already know that reducer
is working. We have a test that shows that when it receives the initialState
and an action with type undefined
it will return the initialState
. But we don't know that CardContext
is working. Let's test CardContext
.
These tests are in addition to the tests for the reducer
. Do not delete your reducer
tests.
What Features of CardContext Should We Test?
Let's test everything that CardContext
does. CardContext
- has an array of
cards
- has
current
, the number of the index of the current card
We know what's in initialState
because we just made the initialState
object. So let's test that CardConsumer
gets a value of 0 for current
, finds a Card
object at the index current in the array cards
, and that the card object has a question, a subject, and an answer. Write a comment for each test.
//current is 0
//question is the same as initialState.cards[0].question
//subject is the same as initialState.cards[0].subject
//answer is the same as initialState.cards[0].answer
We'll put all the CardConsumer
tests inside of a describe block. Name the describe block 'CardConsumer using CardContext.' This will keep our tests organized.
//testing the CardConsumer using CardContext inside CardProvider
describe('CardConsumer using CardContext', () => {
//current is 0
//question is the same as initialState.cards[0].question
//subject is the same as initialState.cards[0].subject
//answer is the same as initialState.cards[0].answer
});
CardContext Test 4: Current is 0
Write the first test and save it.
//testing the CardConsumer using CardContext inside CardProvider
describe('CardConsumer using CardContext', () => {
//current is 0
it('has a current value 0', () => {
const { getByTestId } = renderProvider();
const current = getByTestId(/current/i);
expect(current).toHaveTextContent('0');
});
//question is the same as initialState.cards[0].question
//subject is the same as initialState.cards[0].subject
//answer is the same as initialState.cards[0].answer
});
Hard-Coded Values in Tests Tell You Different Things Than References to Objects
Notice that we are testing for a hard-coded value of 0. We just made the initialState
object. We know that initialState.current
is going to start with a value of 0. We could have passed a reference to initialState.current
in our assertion. But we didn't. We passed a string '0.'
The rest of the CardConsumer
tests will expect that the current card is the card found at cards[0]
. If we changed initialState
to pass a different index, all those tests would fail. But, with the hardcoded value of 0, the current value test would also fail. We'd know initialState
was passing a different value. But if we expected current to have text content equal to initialState.current, this test would pass even though initialState.current wasn't the value we thought it would be. You should generally prefer to use hardcoded values in your tests, especially instead of references to objects that are generated by other code.
CardContext Test 5: card.question
Get the question from the current card from the initialState
.
Get the getByTestId
matcher from the renderProvider
helper function.
Use getByTestId
to find the question by its testid
, passing a case insensitive regular expression to getByTestId
.
Assert that the textContent
of the question
div will match the question from the current card.
//question is the same as initialState.cards[0].question
it('question is the same as current card', () => {
//get cards, current from initialState
const { cards, current } = initialState;
//get the question from the current card
const currentQuestion = cards[current].question;
const { getByTestId } = renderProvider();
//find the question div
const question = getByTestId(/question/i);
//question div should match the current question
expect(question).toHaveTextContent(currentQuestion);
});
CardContext Test 6: card.subject
The test for the subject is almost the same as the test for the question.
//subject is the same as initialState.cards[0].subject
it('subject is the same as current card', () => {
//get cards, current from initialState
const { cards, current } = initialState;
//get the subject from the current card
const currentSubject = cards[current].subject;
const { getByTestId } = renderProvider();
//find the subject div
const subject = getByTestId(/subject/i);
//subject div should match the current subject
expect(subject).toHaveTextContent(currentSubject);
});
CardContext Test 6: card.answer
Write the test for the answer is almost the same as the other two tests.
//answer is the same as initialState.cards[0].answer
it('answer is the same as current card', () => {
//get cards, current from initialState
const { cards, current } = initialState;
//get the answer from the current card
const currentanswer = cards[current].answer;
const { getByTestId } = renderProvider();
//find the answer div
const answer = getByTestId(/answer/i);
//answer div should match the current answer
expect(answer).toHaveTextContent(currentanswer);
});
This test should work, right? Save it and run it. What happens?
It fails! That's surprising, isn't it? Look at the error that Jest gives us:
Now that's puzzling. It's got the same text in 'Expected element to have text content' as it has in 'received.' Why do you think it doesn't match?
It Doesn't Match Because the Line Breaks from the Template Literal Aren't Showing Up
Puzzles like this are part of the joy of testing, and programming in general. The question, subject, and answer are all strings. But we stored the question and the subject as strings in quotes. We stored the answer as a template literal in backticks because we wanted to have linebreaks in the answer.
The linebreaks are stored in the template literal. But when the template literal is rendered in the web browser, they won't show up. The linebreaks also won't show up in the simulated web browser of the render function from the testing library. So the text content of the div doesn't exactly match the answer from the current card because the answer from the card has linebreaks and the text content of the div doesn't.
Solution: Rewrite the Test for card.answer
Let's rewrite the test so it works. We obviously have the right content. And we're not going to somehow convince the render function to change the way it treats template literals with linebreaks. So we need to use a different assertion.
Change the assertion in the answer test from
//answer div should match the current answer
expect(answer).toHaveTextContent(currentanswer);
To:
//text content answer div should equal the current answer
expect(answer.textContent).toEqual(currentanswer);
That did it!
The lesson here is: when a test fails, it's not always because the component can't pass the test. Sometimes its because you need to change the test.
Great! Now we know that CardContext
is working. CardConsumer
is getting all the right answers.
Make CardContext Handle the 'next' Action
Types.ts: Declare CardAction Type
File: src/types.ts
Will Match: src/complete/types-3.ts
Go to types.ts. Declare an enum CardActionTypes
. An enum is basically a list. When you write an enum, then say that an object type is equal to the enum, you know that the object type will be one of the items on the list.
CardActionTypes
is a list of all the types of action that the CardContext
reducer
will handle. Right now it just has 'next,' but we'll add more later.
Also declare a TypeScript type called CardAction
. This is the interface for the actions that CardContext
will handle. Save types.ts. We will import CardAction
into the CardContext
. We will add more types of action to this type later.
//the types of action that the reducer in CardContext will handle
export enum CardActionTypes {
next = 'next',
};
export type CardAction =
//moves to the next card
| { type: CardActionTypes.next }
CardContext Test 8: Reducer Handles 'next' Action
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-6.tsx
Import CardAction
into the CardContext
test.
import { CardAction, CardActionTypes, CardState } from '../../types';
Test reducer
for handling an action with type 'next.' Name the test 'next increments current.' Put this test inside the describe block 'CardContext reducer.'
To test how the reducer handles actions, first create the action object with the type that you want to test. Then pass a state and the action to the reducer
. You can assign the result to a variable, or just test the property that you are interested in directly. This test looks at the current property of the return value.
it('next increments current', () => {
//declare CardAction with type of 'next'
const nextAction: CardAction = { type: CardActionTypes.next };
//pass initialState and nextAction to the reducer
expect(reducer(initialState, nextAction).current).toEqual(1);
});
Be Aware of Your Assumptions
But wait! Do you see the assumption we are making in that test? We assume that initialState
will have current === 0. What if it didn't? What if it somehow changed to 1, and what if case 'next' in the reducer switch didn't do anything? The test would still pass. We would think next
worked when it didn't. We want our tests to give us confidence. How would you change the test to avoid this possibility?
Here's one way: use the spread operator to make a new object out of initialState
, but overwrite the existing value of current
with 0.
it('next increments current', () => {
//declare CardAction with type of 'next'
const nextAction: CardAction = { type: CardActionTypes.next };
//create a new CardState with current === 0
const zeroState = {
...initialState,
current: 0
};
//pass initialState and nextAction to the reducer
expect(reducer(zeroState, nextAction).current).toEqual(1);
});
CardContext Test 9: Reducer Handles 'next' Action When Current !== 0
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-6.tsx
In addition to making sure that case 'next' works when the current
index is 0, we should test to make sure that it doesn't return an invalid index when the index is the last valid index in the array cards
. When the current index is the last valid index, the next index should be 0.
it('next action when curent is lastIndex of cards returns current === 0 ', () => {
const nextAction: CardAction = { type: CardActionTypes.next };
//get last valid index of cards
const lastIndex = initialState.cards.length - 1;
//create a CardState object where current is the last valid index of cards
const lastState = {
...initialState,
current: lastIndex
};
//pass lastState and nextAction to reducer
expect(reducer(lastState, nextAction).current).toEqual(0);
});
Ok. Now change the reducer to pass these tests. Think about how you would write the code inside the next case. Look at the tests. Does the structure of the tests give you any ideas?
Pass CardContext Tests 8-9: Reducer Handles 'next' Action
File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-4.tsx
To make the reducer
work we are going to write the first case for the switch statement. Add the case 'next' to the switch statement in the reducer
.
Use object destructuring to get cards
and current
out of the state object.
Declare const total
equal to cards.length -1
, which is the last valid index in cards
.
Declare const next
. If current + 1 is bigger than total, set next
= 0.
Use the spread operator to create a new state object. Return all the same properties as the old state, but overwrite current
with the value of next
.
switch(action.type) {
case 'next': {
//get cards and the current index from state
const { cards, current } = state;
//total is the last valid index in cards
const total = cards.length - 1;
//if current + 1 is less than or equal to total, set next to total
//else set next to 0
const next = current + 1 <= total
? current + 1
: 0;
//return a new object created using spread operator
//use all values from old state
//except overwrite old value of current with next
return {
...state,
current: next
}
}
//default case returns the previous state without changing it
default:
return state
};
CardContext Test 10: Use CardConsumer to Test Dispatch of 'next' Action from Components
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-7.tsx
So now we are confident that the reducer
works. reducer
can handle next
actions. But how can we test if dispatching a next
action from a component will work? By using CardConsumer
! We'll add a button to CardCounsumer
that dispatches next when clicked. Then we'll click it and see if the value in the div that shows current
changes.
Let's write the test.
Import fireEvent
from React Testing Library. We'll use fireEvent
to click the next
button we'll add to CardConsumer
.
import { render, cleanup, fireEvent } from '@testing-library/react';
Write the test for CardConsumer
. We'll dispatch the next
action the way a user would. By finding a button with the text 'Next' and clicking it.
Use the spread operator to create a CardState
with current === 0.
Get a reference to the currentDiv. Expect it to start at 0, then after clicking the button, it should be 1.
//dispatching next from component increments value of current
it('dispatching next action from component increments value of current', () => {
//create a new CardState with current === 0
const zeroState = {
...initialState,
current: 0
};
const { getByTestId, getByText } = renderProvider(zeroState);
//get currentDiv with testId
const currentDiv = getByTestId(/current/i);
//textContent should be 0
expect(currentDiv).toHaveTextContent('0');
//get nextButton by text- users find buttons with text
const nextButton = getByText(/next/i);
//click the next button
fireEvent.click(nextButton);
expect(currentDiv).toHaveTextContent('1');
});
Pass CardContext Test 10: Add 'Next' Button to CardConsumer
File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-7.tsx
Import the Button
component from Semantic UI React. We could use a normal < button \/>, but you should always make your tests as much like your app as possible. And in our app, we are using the < Button \/> from Semantic UI React.
import { Button } from 'semantic-ui-react';
In the CardConsumer
component get dispatch from useContext
.
//and display them so we can test
const CardConsumer = () => {
//get cards and the index of the current card
//also get dispatch
const { cards, current, dispatch } = useContext(CardContext);
Add a Button
to the return value of CardConsumer
. Give the Button an onClick
function that calls dispatch
with an object {type: 'next'}
. When you simulate a click on the button, the button will call the dispatch
function of CardContext
with a 'next' action. The reducer
should handle it, and return a new state. When the new state shows up, CardConsumer
should show the new value inside its 'current' div.
//display each property in a div
return <div>
<div data-testid='current'>{current}</div>
<div data-testid='question'>{question}</div>
<div data-testid='answer'>{answer}</div>
<div data-testid='subject'>{subject}</div>
<Button onClick={() => dispatch({type: CardActionTypes.next})}>Next</Button>
</div>
That works! Are you feeling confident about adding CardContext
to the App
? You should be. You have written tests for all the parts that matter, and they all pass. Now we are ready to import the CardProvider
into the App
to make the cards
available to Answering
.
Import CardProvider Into App
File: src/App.tsx
Will Match: src/complete/app-3.tsx
We are going to add CardProvider
to the App
component. You will notice that this doesn't make any of your tests fail. The reason none of the tests fail is because adding CardProvider
does not change what appears on the screen. CardProvider
just makes the CardContext
available to all the components inside of CardProvider
, it doesn't make anything look different.
Change App.tsx to this:
import React from 'react';
import './App.css';
import Answering from './scenes/Answering';
import { CardProvider } from './services/CardContext';
const App: React.FC = () =>
<CardProvider>
<Answering />
</CardProvider>;
export default App;
To make the CardState
in CardContext
available to components, you have to "wrap" those components in the CardProvider
component that is exported from CardContext
. We are adding the CardProvider
at the App, the highest level component. You do not have to add React Providers
at the App level. You can import Providers
in sub-components and wrap other sub-components there. But in this app it makes sense to wrap the components in the provider out here at the App level.
Answering Test 1: Answering Shows the Question From the Current Card
File: src/scenes/Answering/index.test.tsx
Will Match: src/scenes/Answering/test-8.tsx
If you are only running the tests for CardContext
, switch to running all tests or the tests for Answering
.
Import CardState
from src/types.ts.
Import CardProvider
and initialState
from CardContext
.
import { CardState } from '../../types';
import { CardProvider, initialState } from '../../services/CardContext';
Then write a helper function to render the Answering
component wrapped in the CardProvider
. Remember, any component that uses a Context
has to be inside of the Provider
for that Context
.
afterEach(cleanup);
const renderAnswering = (testState?: CardState) => {
return render(
<CardProvider testState={testState? testState : initialState}>
<Answering />
</CardProvider>
);
}
Change the 'has a question prompt' test from this:
//test to see if the question prompt is in the document
it('has a question prompt', () => {
//Use Object Destructuring to get getByTestId from the result of render
const { getByTestId } = render(<Answering/>);
//find question by searching for testId 'question'
const question = getByTestId('question');
//assert that question is in the document
expect(question).toBeInTheDocument();
});
To this:
//test to see if the question prompt is in the document
it('has the question prompt from the current card', () => {
const { cards, current } = initialState;
//get the question from current card
const currentQuestion = cards[current].question;
//get getByTestId from the helper function
const { getByTestId } = renderAnswering();
const question = getByTestId('question');
//question content should be the question from the current card
expect(question).toHaveTextContent(currentQuestion);
});
Save the Answering/test.index.tsx
file and run your tests. The 'has the question prompt from the current card' test you just changed will fail.
Good job! Next we will make the Answering component actually show the question.
Pass Answering Test 1: Answering Shows the Question From the Current Card
File: src/scenes/Answering/index.tsx
Will Match: src/scenes/Answering/index-6.tsx
Now that Answering
is wrapped in the CardProvider
, Answering
can use CardContext
to access the cards
in CardContext
.
Import useContext
from React:
import React, { useContext } from 'react';
useContext is a method from the react library that lets you get values from a context. We will call useContext
to get the array cards
and the index of the current
card from CardContext
.
Import CardContext
into Answering
.
//CardContext gives us access to the cards
import { CardContext } from '../../services/CardContext';
Call useContext
to get cards
and current
from CardContext
. Use object destructuring to get the question from the current card. Pass the question to the Header
as the content prop.
const Answering = () => {
//get cards and current index from CardContext
const { cards, current } = useContext(CardContext);
//get the question from the current card
const { question } = cards[current];
return (
<Container data-testid='container' style={{position: 'absolute', left: 200}}>
<Header data-testid='question' content={question}/>
<Button>Skip</Button>
<Form>
<TextArea data-testid='textarea'/>
</Form>
<Button>Submit</Button>
</Container>
)};
That's it! Save it and run your tests.
Passed all tests, but the snapshots failed. Hit u to update the snapshots.
There we go! Remember, the snapshots failed because what shows up on the screen changed. Use npm start to run the app.
Looking good!
Make the Skip Button in Answering Work by Dispatching 'next' Action
One last thing. Now that we can see the cards
in Answering
, let's make the Skip
Button cycle to the next one. We will use all the work we did making the CardContext
reducer handle actions with a type CardActionTypes.next
.
We will make the Skip
button dispatch an action with the type CardActionTypes.next
to CardContext
. When CardContext
receives the action, it will run it through the reducer
. The reducer
will run the case 'next' that you wrote earlier. The code in the case 'next' will return a new state object with the current
index set to the index of the next card in cards
.
Decide What to Test
We should test what happens when the user clicks the Skip
Button
. The current
index should change to the next card in cards
. We can test for this by looking at the contents of the question
Header
and comparing it to the array cards
from the initialState
object.
Answering Test 2: Skip Button Works
File: src/scenes/Answering/index.test.tsx
Will Match: src/scenes/Answering/complete/test-9.tsx
Import fireEvent from React Testing Library so that we can simulate clicking the Skip
button.
import { render, cleanup, fireEvent } from '@testing-library/react';
Write the test for clicking the skip button.
//test that skip button works
it('clicks the skip button and the next question appears', () => {
//create a CardState with current set to 0
const zeroState = {
...initialState,
current: 0
};
//current starts out at 0
const { getByTestId, getByText } = renderAnswering(zeroState);
const question = getByTestId('question');
//current starts out at 0, so question should be cards[0]
expect(question).toHaveTextContent(initialState.cards[0].question);
const skip = getByText(/skip/i);
//this should change current index from 0 to 1
fireEvent.click(skip);
expect(question).toHaveTextContent(initialState.cards[1].question);
});
Pass Answering Test 2: Skip Button Works
File: src/scenes/Answering/index.tsx
Will Match: src/scenes/Answering/index-7.tsx
Import CardActionTypes
so that we can make Skip
dispatch a 'next' action.
//The types of action that CardContext can handle
import { CardActionTypes } from '../../types';
Get dispatch
from CardContext
.
//get cards, current index, and dispatch from CardContext
const { cards, current, dispatch } = useContext(CardContext);
Pass an onClick
function to the Skip
button. Make it dispatch an action with type CardActionTypes.next
.
<Container data-testid='container' style={{position: 'absolute', left: 200}}>
<Header data-testid='question' content={question}/>
<Button onClick={() => dispatch({type: CardActionTypes.next})}>Skip</Button>
<Form>
<TextArea data-testid='textarea'/>
</Form>
<Button>Submit</Button>
</Container>
That's it. Save it, and the test will pass!
Next Post
In the next post we will make Answering show the user the answer from the card when the user clicks the 'Submit' button.
Posted on January 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.