DEV Community

jacobwicks
jacobwicks

Posted on

CardContext

Image of Flashcards

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 in Context
  • Context lets components dispatch actions to Context. When Context 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 the cards array and the starting value of the current index.
  • reducer: the function that handles the actions dispatched to Context and makes changes to the data in the Context. For example, when the reducer handles a 'next' action it will change the current index to the index of the next card in the cards array.
  • CardContext: The context object contains the data. Contains the array of cards and the current index.
  • CardProvider: the React component that gives components inside it access to the data in the CardContext.

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.

Select Tests

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.
Returns State

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 Contexts is the Provider. Providers 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.
Failed test

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!
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.

First Test Passes

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);
    });

Question test Passes

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);
    });

Subject Test Passes

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
It fails! That's surprising, isn't it? Look at the error that Jest gives us:
Error mesage

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!

Answer Test Passes

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);
    });

Next fails to increment current
Test fails.

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
    };

Alt Text
That passes the test.

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');
    });

Test Fails because Next Button is not there

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>

Next button passes

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.

Question display fails
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.

Snapshots failed

Passed all tests, but the snapshots failed. Hit u to update the snapshots.

Alt Text

There we go! Remember, the snapshots failed because what shows up on the screen changed. Use npm start to run the app.

The App- it's beautiful

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);
  });

Clicking Skip Button Fails

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.

Top comments (0)