DEV Community

Cover image for StatsContext
jacobwicks
jacobwicks

Posted on

StatsContext

In this post we will make the Context that will track the stats (short for statistics) for each question. This Context will be called StatsContext. StatsContext will track how many times the user has answered each question right, wrong, and how many times the user has skipped that question.

In the next post we will make a Stats component. The Stats component will show the stats to the user. The Stats component will appear on the Answering screen.

User Story

  • The user sees a card. They hover their mouse over an icon and a popup appears. The popup shows the user how many times they have seen the card, and how many times they have gotten the answer right or wrong.

Features

  • Stats for cards are tracked
  • Right, Wrong, and Skip buttons update StatsContext
  • User can see the stats for the card they are looking at

To make these features work we will

  • Define the types for Stats
  • Make the StatsContext
  • Write the tests for the Stats Component
  • Make the Stats component
  • Change the tests for Answering
  • Add the Stats component to Answering

Add Stats Types to Types.ts

File: src/types.ts
Will match: src/complete/types-4.ts

Add the interface Stats to types. Stats describes the stats for a single question.

//The stats for a single question
export interface Stats {

    //number of times user has gotten it right
    right: number,

    //number of times user has gotten it wrong
    wrong: number,

    //number of times user has seen the question but skipped it instead of answering it
    skip: number
};
Enter fullscreen mode Exit fullscreen mode

Add the interface StatsType. StatsType is an object with a a string for an index signature. Putting the index signature in StatsType means that TypeScript will expect that any key that is a string will have a value that is a Stats object.

We will use the question from Cards as the key to store and retrieve the stats.

//an interface with an string index signature
//each string is expected to return an object that fits the Stats interface
//the string that we will use for a signature is the question from a Card object
export interface StatsType {
    [key: string]: Stats
};
Enter fullscreen mode Exit fullscreen mode

Describe the StatsDispatch function and the StatsState type.

StatsDispatch

To change the contents of StatsContext we will have our components dispatch actions to StatsContext. This works just like dispatching actions to the CardContext. To dispatch actions to StatsContext we will use useContext to get dispatch out of StatsContext inside components that use StatsContext. StatsContext contains StatsState. We have to tell TypeScript that the key 'dispatch' inside StatsState will contain a function.

StatsState

StatsState is a union type. A union type is a way to tell TypeScript that a value is going to be one of the types in the union type.

StatsState puts together StatsType and StatsDispatch. This means that TypeScript will expect a Stats object for every key that is a string in StatsState, except for 'dispatch,' where TypeScript will expect the dispatch function.

//The StatsDispatch function
interface StatsDispatch {
    dispatch: (action: StatsAction) => void
};

//a union type. The stats state will have a Stats object for any given key
//except dispatch will return the StatsDispatch function
export type StatsState = StatsType & StatsDispatch
Enter fullscreen mode Exit fullscreen mode

StatsActionType and StatsAction

The enum StatsActionType and the type StatsAction define the types of actions that we can dispatch to StatsContext. Later in this post you will write a case for each type of StatsAction so the reducer in StatsContext can handle it. In addition to the type, each action takes a parameter called 'question.' The 'question' is a string, same as the question from the Card objects. When the reducer receives an action, it will use the question as the key to find and store the stats.

//an enum listing the three types of StatsAction
//A user can get a question right, wrong, or skip it
export enum StatsActionType {
    right = 'right',
    skip = 'skip',
    wrong = 'wrong'
};

//Stats Action
//takes the question from a card 
export type StatsAction = { 
    type: StatsActionType, 
    question: string 
};
Enter fullscreen mode Exit fullscreen mode

Create StatsContext

Testing StatsContext

Our tests for StatsContext will follow the same format as the tests we wrote for CardContext. We will test the Provider, the Context, and the reducer. We will start by testing the reducer to make sure that it handles actions correctly and returns the state that we expect. We'll test that the Provider renders without crashing. Then we will write a helper component to make sure that the Context returns the right data.

Recall that the reducer is what handles actions and makes changes to the state held in a Context. The reducer will add new stats objects when it sees a question that isn't being tracked yet. The reducer will add to the stats numbers for a question when it receives an action.

Choosing What to Test

  • reducer returns state
  • reducer adds a new stats object when it receives a new question
  • reducer handles right action, returns correct stats
  • reducer handles skip action, returns correct stats
  • reducer handles wrong action, returns correct stats
  • StatsContext provides an object with Stats for questions

We'll start testing with the reducer.

Test 1: Reducer Takes State, Action and returns State

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-1.tsx

Write a comment for each test we are going to make.

//reducer
    //returns state
    //adds a new stats object when it receives a new question
    //handles right action, returns correct stats
    //handles skip action, returns correct stats
    //handles wrong action, returns correct stats

//StatsContext provides an object with Stats for questions
Enter fullscreen mode Exit fullscreen mode

The reducer takes a state object and an action object and returns a new state object. When the action type is undefined, the reducer should return the same state object that it received.

Imports and the first test. Declare state, an empty object. Declare action as an object with an undefined type.

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('StatsContext reducer', () => {
    it('returns state', () => {
        const state = {};
        const action = { type: undefined };
        expect(reducer(state, action)).toEqual(state);
    });
});
Enter fullscreen mode Exit fullscreen mode

Suite Failed To Run

Passing Test 1: Reducer Takes State, Action and returns State

File: src/services/StatsContext/index.tsx
Will Match: src/services/StatsContext/complete/index-1.tsx

Write the first version of the reducer. Remember that the reducer takes two parameters.

The first parameter is the state object. The state object type is StatsState.

The second parameter is the action object. The action object type is StatsAction.

Imports:

import { StatsAction, StatsState } from '../../types';
Enter fullscreen mode Exit fullscreen mode

Write the reducer:

//the reducer handles actions
export const reducer = (state: StatsState, action: StatsAction) => {
    //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
    }
};
Enter fullscreen mode Exit fullscreen mode

first pass

Test 2 Preparation: Add blankStats and initialState to StatsContext file

File: src/services/StatsContext/index.tsx
Will Match: src/services/StatsContext/complete/index-2.tsx

Before we write the tests, we need to add the blankStats and initialState objects to the StatsContext file.

Imports the types.

import { Stats, StatsAction, StatsState } from '../../types';
Enter fullscreen mode Exit fullscreen mode

Create the blankStats object. Later, the reducer will copy this object to create the Stats object used to track new questions. Put blankStats in the file above the reducer.

//a Stats object
//use as the basis for tracking stats for a new question
export const blankStats = {
    right: 0,
    wrong: 0,
    skip: 0
} as Stats;
Enter fullscreen mode Exit fullscreen mode

Create the initialState. Put it after the reducer.

//the object that we use to make the first Context
export const initialState = {
    dispatch: (action: StatsAction) => undefined
} as StatsState;
Enter fullscreen mode Exit fullscreen mode

Ok, now we are ready to write the second test.

Test 2: reducer Adds a New Stats Object When it Receives a New Question

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-2.tsx

The next test we are going to write is 'adds a new stats object when it receives a new question.' That's a good thing to test. But shouldn't we test each case to make sure it works? Will we have to write three tests?

And what about all the tests after that?

  • handles right action, returns correct stats
  • handles skip action, returns correct stats
  • handles wrong action, returns correct stats

Those are probably going to be basically the same test. Do we really have to write the same code three times? No, we don't! Jest provides a way to make and run tests from a list of arguments. The way to make and run multiple tests from a list of arguments is the it.each method.

First we'll write a single test to show that the right case in the reducer adds a new stats object to the state. Then we'll write the code to pass that test. After that, I'll show you how to use it.each to make many tests at once when you want to test a lot of things with similar code. We'll replace the individual test with code that generates three tests, one to test each case.

Make the Single Test for reducer Handles right Action

Import the blankStats and initialState from StatsContext. Import StatsActionType from types.

import { blankStats, initialState, reducer } from './index';
import { StatsActionType } from '../../types';
Enter fullscreen mode Exit fullscreen mode

Write the test.

    //adds a new stats object when it receives a new question
    it('adds a new stats object when it receives a new question', () => {
        const question = 'Example Question';

        //the action we will dispatch to the reducer
        const action = {
            type: StatsActionType.right,
            question
        };

        //the stats should be the blankStats object
        //with right === 1
        const rightStats = {
            ...blankStats,
            right: 1
        };

        //check to make sure that initialState doesn't already have a property [question]
        expect(initialState[question]).toBeUndefined();

        const result = reducer(initialState, action);

        //after getting a new question prompt in an action type 'right'
        //the question stats should be rightStats
        expect(result[question]).toEqual(rightStats);
    });
Enter fullscreen mode Exit fullscreen mode

That looks pretty similar to the tests we've written before.
Fails

Run it, and it will fail.

Pass the Single Test for reducer Handles right Action

Now let's write the code for the reducer to handle actions with the type 'right.'
The case will need to:

  • Get the question out of the action.

  • Get the previous stats. To find the previous stats, first look in the state for a property corresponding to the question. If there are stats for the question already, use those. Otherwise, use the blankStats object.

  • Make the new stats. Use the previous stats, but increment the target property by one. e.g. right: prevStats.right + 1.

  • Make a new state object. Assign newStats as the value of the question.

  • Return the new state.

Remember, the cases go inside the switch statement. Add case 'right' to the switch statement in the reducer and save it.

        case 'right': {
            //get the question from the action
            const { question } = action;

            //if the question is already in state, use those for the stats
            //otherwise, use blankStats object
            const prevStats = state[question] ? state[question] : blankStats;

            //create newStats from the prevStats
            const newStats = {
                ...prevStats,
                //right increases by 1
                right: prevStats.right + 1
            };

            //assign newStats to question
            const newState = {
                ...state,
                [question]: newStats
            };

            return newState;
        }
Enter fullscreen mode Exit fullscreen mode

Passes New Question

Case right, wrong and skip Will All Be Basically the Same Code

If you understand how the code for case right works, think about how you would write the code for the other cases, wrong and skip. It's pretty much the same, isn't it? You'll just be targeting different properties. wrong instead of right, etc.

What Will the Tests Look Like?

The tests will look very repetitive. In fact, the tests would be the same. To test wrong, you would copy the test for right and just replace the word 'right' with the word 'wrong.' Writing all these tests out would be a waste of time when we will have three cases that all work the same. Imagine if you had even more cases that all worked the same! Or if you wanted to test them with more than one question prompt. You would be doing a lot of copying and pasting.

Jest includes a way to generate and run multiple tests. The it.each() method.
Delete the test we just wrote for 'adds a new stats object when it receives a new question.' We don't need it anymore. We are going to replace it with code that generates and runs multiple tests.

Tests: Using it.Each to Generate Multiple Tests

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-3.tsx

it.each() is the method that generates and runs multiple tests. Because it() is an alias for test(), you can also use test.each() if you think that sounds better. We'll start out using it.each() in this post, but later in the tutorial we'll use test.each() when we run multiple tests.

The API, which means the arguments that it.each() accepts and the way you use them, are different from what you would expect. One thing to note is that the code that you write to generate the title for each test uses a weird format called printf formatting. That's why you'll see % signs in the titles when we write them.

To make it.each work we will

  • Use Object.values() to get an array containing each value in the enum StatsActionType
  • Use Array.map() to iterate over the StatsActionType array
  • for each StatsActionType we will make an array of arguments that it.each will turn into a test
  • So we'll end up with an array of arrays of test arguments
  • We'll pass that array to it.each(). it.each() will print a test name based on the arguments and then run a test using the arguments

Start by making a describe block.

    describe('Test each case', () => {

});
Enter fullscreen mode Exit fullscreen mode

Inside the describe block 'Test each case'

Write the functions that we'll use to generate the arguments for it.each().

Make a helper function that takes a StatsActionType and returns a Stats object with the argument type set to 1.

const getStats = (type: StatsActionType) => ({...blankStats, [type]: 1});
Enter fullscreen mode Exit fullscreen mode

Bracket Notation doesn't mean there's an array. Bracket notation is a way of accessing an object property using the value of the variable inside the brackets. So when you call getStats('right') you will get back an object made by spreading blankStats and setting right to 1.

The getStats returns an object. It has a Concise Body and an Implicit Return. Surrounding the return value in parentheses is a way of telling the compiler that you are returning an object. The curly brackets enclose the object that is getting returned. Without the parentheses around them, the compiler would read the curly brackets as the body of the function instead of a returned value.

Declare an example question.

const exampleQuestion = 'Is this an example question?';
Enter fullscreen mode Exit fullscreen mode

Make a helper function that accepts a StatsActionType and returns a StatAction object.

        //function that takes a StatsActionType and returns an action
        const getAction = (
            type: StatsActionType, 
            ) => ({
                type,
                question: exampleQuestion
        });
Enter fullscreen mode Exit fullscreen mode

Inside the first describe block make another describe block. This is called 'nesting' describe blocks. Nested describe blocks will print out on the test screen inside of their parent blocks. Also, variables that are in scope for outer describe blocks will be available to inner describe blocks. So we can use all the variables we just declared in any test that is inside the outer describe block.

describe('Reducer adds a new stats object when it receives a new question prompt', () => {

});
Enter fullscreen mode Exit fullscreen mode

Inside the Describe Block 'Reducer adds a new stats object when it receives a new question prompt'

Write the code to generate the arguments that we will pass to it.each.
Object.values will give us an array of each value in StatsActionType: ['right', 'skip', 'wrong'].

Array.map will iterate through each value in that array and return a new array.
In the callback function we pass to map we'll create an action object, the results that we expect to see, and return the array of arguments for the test.

 //uses Array.map to take each value of the enum StatsActionType
            //and return an array of arguments that it.each will run in tests
            const eachTest = Object.values(StatsActionType)
            .map(actionType => {
                //an object of type StatAction
                const action = getAction(actionType);

                //an object of type Stats
                const result = getStats(actionType);

                //return an array of arguments that it.each will turn into a test
                return [
                    actionType,
                    action,
                    initialState,
                    exampleQuestion,
                    result
                ];
            });
Enter fullscreen mode Exit fullscreen mode

Use it.each to run all the tests. Each test will get an array of five arguments. If we wanted to rename the arguments, we could, but to try and make it easier to read we will name the arguments the same thing that we named them when we created them.

I'm not going to explain printf syntax, but here's a link if you're curious.

            //pass the array eachTest to it.each to run tests using arguments
            it.each(eachTest)
            //printing the title from it.each uses 'printf syntax'
            ('%#: %s adds new stats', 
            //name the arguments, same order as in the array we generated
            (actionType, action, initialState, question, result) => {
                    //assert that question isn't already in state
                    expect(initialState[question]).toBeUndefined();

                    //assert that the stats object at key: question matches result
                    expect(reducer(initialState, action)[question]).toEqual(result);
            });
Enter fullscreen mode Exit fullscreen mode

Fails 2 auto tests

Pass the it.each Tests for skip and wrong

File: src/services/StatsContext/index.tsx
Will Match: src/services/StatsContext/complete/index-3.tsx

Write the case for skip and add it to the switch statement. Notice that we use bracket notation and the ternary operator to get the value for prevStats.

        //user skipped a card
        case 'skip': {
            //get the question from the action
            const { question } = action;

            //if the question is already in state, use those for the stats
            //otherwise, use blankStats object
            const prevStats = state[question] ? state[question] : blankStats;

            //create newStats from the prevStats
            const newStats = {
                ...prevStats,
                //skip increases by 1
                skip: prevStats.skip + 1
            };

            //assign newStats to question
            const newState = {
                ...state,
                [question]: newStats
            };

            return newState;
        }
Enter fullscreen mode Exit fullscreen mode

Passes two of the auto tests

How Would You Write the Code for Case wrong?

File: src/services/StatsContext/index.tsx
Will Match: src/services/StatsContext/complete/index-3.tsx

Try writing the case to handle wrong actions on your own before you look at the example below. Hint: Look at the cases right and skip.

        //user got a question wrong
        case 'wrong': {
            //get the question from the action
            const { question } = action;

            //if the question is already in state, use those for the stats
            //otherwise, use blankStats object
            const prevStats = state[question] ? state[question] : blankStats;

            //create newStats from the prevStats
            const newStats = {
                ...prevStats,
                //wrong increases by 1
                wrong: prevStats.wrong + 1
            };

            //assign newStats to question
            const newState = {
                ...state,
                [question]: newStats
            };

            return newState;
        }
Enter fullscreen mode Exit fullscreen mode

Passes all Auto Tests So far

Test 4: Results for Existing Questions

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-4.tsx

Rewrite the helper function getStats() to take an optional parameter stats, a Stats object. The '?' tells TypeScript that the parameter is optional. If getStats receives stats, create the new Stats object by spreading the argument received for stats. Otherwise, spread the imported blankStats object.

//function that takes a StatsActionType and returns a Stats object
        //may optionally take a stats object
        const getStats = (
            type: StatsActionType, 
            stats?: Stats
            ) => stats 
                    ? ({ ...stats,
                        [type]: stats[type] + 1 })
                    : ({ ...blankStats,
                        [type]: 1 });
Enter fullscreen mode Exit fullscreen mode

Create a new describe block below the describe block 'Reducer adds a new stats object when it receives a new question prompt' but still nested inside the describe block 'Test each case.'

Name the new describe block 'Reducer returns correct stats.'

        describe('Reducer returns correct stats', () => {
})
Enter fullscreen mode Exit fullscreen mode

Inside the describe block 'Reducer returns correct stats'

Write a StatsState object, existingState.

        //create a state with existing questions
        const existingState = {
            ...initialState,
            [examplePrompt]: {
                right: 3,
                skip: 2,
                wrong: 0
            },
            'Would you like another example?': {
                right: 2,
                skip: 0,
                wrong: 7
            }
        };
Enter fullscreen mode Exit fullscreen mode

Use Object.values and Array.map to create the test arguments.

        //Object.Values and array.map to turn StatsActionType into array of arrays of test arguments
        const existingTests = Object.values(StatsActionType)
        .map(actionType => {
            //get the action with the type and the example prompt
            const action = getAction(actionType);

            //get the stats for examplePrompt from existingState
            const stats = existingState[exampleQuestion];

            //getStats gives us our expected result
            const result = getStats(actionType, stats);

            //return the array
            return [
                actionType,
                action,
                existingState,
                result,
                exampleQuestion,
            ];
        });

Enter fullscreen mode Exit fullscreen mode

Use it.each to run the array of arrays of test arguments.

  it.each(existingTests)
        ('%#: %s returns correct stats',
            (actionType, action, initialState, result, question) => {
                //assert that question is already in state
                expect(initialState[question]).toEqual(existingState[exampleQuestion]);
                //assert that the stats object at key: question matches result
                expect(reducer(initialState, action)[question]).toEqual(result);
        });
Enter fullscreen mode Exit fullscreen mode

Passes Auto Tests

That's it! Now you know one way to generate multiple tests. There are other ways to generate multiple tests. it.each() can take a template literal instead of an array of arrays. We'll make multiple tests that way later. There is also a separate library you can install and use called jest in case.

Tests That Pass When You Write Them

These tests all pass because we already wrote the code to pass them. If a test passes when you write it, you should always be at least a little suspicious that the test isn't telling you anything useful. Can you make the tests fail by changing the tested code? Try going into the index file and changing the code for one of the cases in the reducer's switch statement so it doesn't work. Does the test fail? If it still passes, then that's bad!

Test 5: StatsProvider Renders Without Crashing

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-5.tsx

Add an import of the StatsProvider from StatsContext. We will write the StatsProvider to pass this test.

import { blankStats, initialState, reducer, StatsProvider } from './index';
Enter fullscreen mode Exit fullscreen mode

Make a describe block named 'StatsProvider.'
Write the test to show that the StatsProvider renders without crashing. Recall from testing CardContext that the React Context Provider component requires a prop children that is an array of components. That's why we render StatsProvider with an array of children. If you prefer, you can use JSX to put a child component in StatsProvider instead of passing the array.

//StatsContext provides an object with Stats for questions
describe('StatsProvider', () => {
    it('renders without crashing', () => {
        render(<StatsProvider children={[<div key='child'/>]}/>)
    });
})
Enter fullscreen mode Exit fullscreen mode

This test will fail because we haven't written the StatsProvider yet.

Doesn't Render

Pass Test 5: StatsProvider Renders Without Crashing

File: src/services/StatsContext/index.tsx
Will Match: src/services/StatsContext/complete/index-4.tsx

We'll use createContext and useReducer to make the StatsContext work. Import them from React.

import React, { createContext, useReducer } from 'react';
Enter fullscreen mode Exit fullscreen mode

Declare the initialState. We'll put a placeholder dispatch function in there. We just have to have it to stop TypeScript from throwing an error. This placeholder makes our initialState object fit the StatsState union type that we declared. The placeholder dispatch accepts the correct type of argument, the StatsAction. But the placeholder will be replaced with the actual dispatch function inside the CardProvider.

//the object that we use to make the first Context
export const initialState = {
    dispatch: (action: StatsAction) => undefined
} as StatsState;
Enter fullscreen mode Exit fullscreen mode

Use createContext to create the StatsContext from the initialState.

const StatsContext = createContext(initialState);
Enter fullscreen mode Exit fullscreen mode

Declare the props for the StatsProvider. StatsProvider can accept ReactNode as its children. We can also declare the optional prop testState, which is a StatsState. When we want to override the default initialState for testing purposes we just need to pass a testState prop to StatsProvider.

//the Props that the StatsProvider will accept
type StatsProviderProps = {
    //You can put react components inside of the Provider component
    children: React.ReactNode;

    //We might want to pass a state into the StatsProvider for testing purposes
    testState?: StatsState
};
Enter fullscreen mode Exit fullscreen mode

Write the StatsProvider and the exports. If you want to review the parts of the Provider, take a look at the CardProvider in post 6, where we made CardContext.

We use Array Destructuring to get the state object and the dispatch function from useReducer. We return the Provider with a value prop created by spreading the state and the reducer. This is the actual reducer function, not the placeholder that we created earlier. Child components are rendered inside the Provider. All child components of the Provider will be able to use useContext to access the StatsContext.

const StatsProvider = ({ children, testState }: StatsProviderProps) => {
    const [state, dispatch] = useReducer(reducer, testState ? testState : initialState);
    const value = {...state, dispatch} as StatsState;
    return (
        <StatsContext.Provider value={value}>
            {children}
        </StatsContext.Provider>
    )};

export { 
    StatsContext, 
    StatsProvider 
};
Enter fullscreen mode Exit fullscreen mode

Great! Now the StatsProvider renders without crashing.

Renders Without Crashing

Test 6: Does Stats Context Provide Stats Values

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-6.tsx

To test if the StatsProvider is providing the correct values for StatsContext, we are going to write a helper component. Let's list the features we are trying to test:

Features

  • provides value for right
  • provides value for skip
  • provides value for wrong

Import useContext from React.

import React, { useContext} from 'react';
Enter fullscreen mode Exit fullscreen mode

Inside the 'StatsProvider' describe block, make the helper component StatsConsumer. StatsConsumer uses useContext to access StatsContext, and will display the stats that it receives. Rendering StatsConsumer will allow us to check if StatsContext and StatsProvider are working correctly.

    //A helper component to get Stats out of StatsContext
    //and display them so we can test
    const StatsConsumer = () => {
        const stats = useContext(StatsContext);

        //stats is the whole StatsState
        //one of its keys is the dispatch key, 
        //so if there's only 1 key there's no stats
        if (Object.keys(stats).length < 2) return <div>No Stats</div>;

        //use the filter method to grab the first question
        const question = Object.keys(stats).filter(key => key !== 'dispatch')[0];
        const { right, skip, wrong } = stats[question];

        //display each property in a div
        return <div>
            <div data-testid='question'>{question}</div>
            <div data-testid='right'>{right}</div>
            <div data-testid='skip'>{skip}</div>
            <div data-testid='wrong'>{wrong}</div>
        </div>
    };
Enter fullscreen mode Exit fullscreen mode

Create exampleQuestion and testState. You can copy and paste the existingState from inside the 'reducer' describe block above.

    const exampleQuestion = 'Is this an example question?';

    //create a state with existing questions
    const testState: StatsState = {
        ...initialState,
        [exampleQuestion]: {
            right: 3,
            skip: 2,
            wrong: 0
        },
        'Would you like another example?': {
            right: 2,
            skip: 0,
            wrong: 7
        }
    };
Enter fullscreen mode Exit fullscreen mode

Make a nested describe block 'StatsContext provides stats object.' Make a helper function renderConsumer to render StatsConsumer inside the StatsProvider. Pass StatsProvider the testState object.

Test question, right, skip, and wrong.

 //StatsContext returns a stats object
    describe('StatsContext provides stats object', () => {
        const renderConsumer = () => render(
            <StatsProvider testState={testState}>
                <StatsConsumer/>
            </StatsProvider>)

        it('StatsConsumer sees correct question', () => {
            const { getByTestId } = renderConsumer();
            const question = getByTestId('question');
            expect(question).toHaveTextContent(exampleQuestion);
        })

        it('StatsConsumer sees correct value of right', () => {
            const { getByTestId } = renderConsumer();
            const right = getByTestId('right');
            expect(right).toHaveTextContent(testState[exampleQuestion].right.toString());
            })

        it('StatsConsumer sees correct value of skip', () => {
            const { getByTestId } = renderConsumer();
            const skip = getByTestId('skip');
            expect(skip).toHaveTextContent(testState[exampleQuestion].skip.toString());
            })

        it('StatsConsumer sees correct value of wrong', () => {
            const { getByTestId } = renderConsumer();
            const wrong = getByTestId('wrong');
            expect(wrong).toHaveTextContent(testState[exampleQuestion].wrong.toString());    
        })
    })
Enter fullscreen mode Exit fullscreen mode

StatsConsumer passes

Test 7: it.each() With Tagged Literal

File: src/services/StatsContext/index.test.tsx
Will Match: src/services/StatsContext/complete/test-7.tsx

it.each() can take an array of arrays. it.each can also accept a tagged literal. A tagged literal, or template literal, sounds way more complicated than it is. A tagged literal is information inside of backticks. They are pretty common in modern javascript, and very useful.

To use a tagged literal for your it.each tests, you basically write out a table and let it.each run through the table. You declare the names of your arguments in the top row, and separate everything with the pipe | character.

Delete the three tests that we wrote for the value of right, skip, and wrong. Replace them with this example of it.each using a tagged literal.

This example also calls it by its alternate name, test. Remember, the 'it' method is an alias for the 'test' method. So calling test.each is the same as calling it.each. I think "test each" sounds better than "it each," so I usually use test.each when I'm running multiple tests.

        it('StatsConsumer sees correct question', () => {
            const { getByTestId } = renderConsumer();
            const question = getByTestId('question');
            expect(question).toHaveTextContent(exampleQuestion);
        });

        test.each`
        type        | expected
        ${'right'}  | ${testState[exampleQuestion].right.toString()}
        ${'skip'}   | ${testState[exampleQuestion].skip.toString()}
        ${'wrong'}  | ${testState[exampleQuestion].wrong.toString()}
        `('StatsConsumer sees correct value of $type, returns $expected', 
            ({type, expected}) => {
                const { getByTestId } = renderConsumer();
                const result = getByTestId(type);
                expect(result).toHaveTextContent(expected);
        });
Enter fullscreen mode Exit fullscreen mode

See how in the top row we named our arguments? The first column is named 'type' and the second column is named 'expected.' Also notice that when we are printing the title we can refer to them by name instead of using the printf format. Like I said earlier, the test.each API is different from how you'd expect it to be.

We use object destructuring to get type and expected out of the arguments passed to each test. Then writing the tests goes as normal.

StatsConsumer passes generated tests

If you have a few minutes, try adding another column to the arguments. Try renaming the arguments. Try changing the titles of the tests, and rewriting the matchers and assertions.

Ok, now we have confidence that the StatsProvider is working. Let's import the StatsProvider into the App, then make the Stats component that will show Stats to the user.

Import StatsProvider into the App

File: src/App.tsx
Will Match: src/complete/app-4.tsx

We've got the StatsContext written. Now let's make the stats from StatsContext available to the components. You will make StatsContext available by importing the StatsProvider into the App and wrapping the components in the StatsProvider.

Go to /src/App.tsx. Change it to this:

import React from 'react';
import './App.css';
import Answering from './scenes/Answering';
import { CardProvider } from './services/CardContext';
import { StatsProvider } from './services/StatsContext';

const App: React.FC = () => 
    <CardProvider>
      <StatsProvider>
        <Answering />
      </StatsProvider>
    </CardProvider>

export default App;
Enter fullscreen mode Exit fullscreen mode

Great! Now the contents of the stats context will be available to the Answering component. It will also be available to any other components that you put inside the StatsProvider.

Try Refactoring

Look at the code for the StatsContext reducer. Cases right, skip, and wrong have almost the same code inside of them. They each get the previous stats the same way. They each create the nextStats object and the nextState object the same way.

Can you write a single function getPrevStats that each case can call to get the previous stats for a question? Hint: You can pass the state to a function just like any other object. You'll know if your function works or doesn't because the tests will tell you if you break anything.

Can you write a single function getNextStats that each case can call that will return the next stats value?

If you write these functions and replace all the code inside the cases with them, you're eliminating duplicate code without changing the way the code works. That is called refactoring, and it's a big part of Test Driven Development.

Next Post

In the next post we will make the Stats Component that will show the stats to the user.

Top comments (0)