DEV Community

loading...
Cover image for Saving to LocalStorage

Saving to LocalStorage

jacobwicks profile image jacobwicks ・20 min read

In this post we are going to write the code that saves the cards to the browser's localStorage. LocalStorage is a feature of web browsers that lets you save data to the user's computer in between sessions. Using localStorage will make it possible for cards to persist between sessions. When we start the app we can load cards from localStorage instead of loading the example cards that we wrote inside the CardContext services.

We are also going to write the code that saves the stats to the browser's localStorage. This will let the user's stats persist between sessions.

User Stories

  • The user loads the app. The user sees all the cards they have written. The user selects the subject that they want to study. The program displays the cards in that subject in random order.

  • The user thinks of a new card. The user opens the card editor. The user clicks the button to create a new card. The user writes in the card subject, question prompt, and an answer to the question. The user saves their new card.

  • The user changes an existing card and saves their changes.

  • The user opens the app. The user looks at the stats for a card and sees how many times they have answered it before.

Features

  • Cards save to localStorage and load when the app is started
  • Stats save to localStorage and load when the app is started

What is localStorage?

localStorage is an object that lets you save data in between browser sessions.

localStorage.setItem(): The setItem method allows you set the value of a property of localStorage.

localStorage.getItem(): The getItem method lets you retrieve the value of a property of localStorage.

We'll use JSON.stringify() on the array cards to turn it into a string before saving it. When we load cards, we'll use JSON.parse() to turn it back into an array.

JSON.stringify(): Converts a JSON object to a string.

JSON.parse(): Parses a string to a JSON object.

To test our code that uses localStorage, we'll be doing some 'mocking.'

What is Mocking?

Mocking is a term that has both a strict, technical meaning, and also a general meaning. Generally, mocking means using any kind of code to make a fake version of other code for use in testing. We'll be making a fake version of localStorage so that when our tests call the localStorage methods we can see what values they called with and also control what values get returned.

For a more detailed explanation of mocking, see: But really, what is a JavaScript mock?
For the different technical meanings of mocking, see the Little Mocker.

What to Test

  • Saving Cards saves Cards to localStorage
  • Loading Cards loads Cards from localStorage
  • Loading cards returns undefined if nothing found in localStorage
  • Saving Stats saves Stats to localStorage
  • Loading Stats loads the stats from localstorage
  • Loading stats returns empty object if nothing found in localStorage

Save Test 1: Saving Cards

File: src/services/Save/index.test.ts
Will Match: src/services/Save/complete/test-1.ts

Save/index.ts is a .ts file, not a tsx file. There will not be any JSX in Save, so we don't need to use the .tsx extension.

Write a comment for each test.

//saving cards saves cards
//loading cards retrieves saved cards
//loading cards returns undefined if nothing found
//saving stats saves stats
//loading stats retrieves saved stats
//loading stats returns empty object if nothing found

Imports and afterEach.

import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { saveCards } from './index';
import { initialState } from '../CardContext';

afterEach(cleanup);

Make a describe block named 'Saving and Loading Cards.'

describe('Saving and Loading Cards', () => {
//saving cards saves cards
//loading cards retrieves saved cards
//loading cards returns undefined if nothing found
});

Setup for Mocking LocalStorage

Inside the describe block, we will get a reference to the original localStorage object from the window. The window is basically the global object for the browser. It contains the document object model (the dom) where all the code that the user sees is. It also contains localStorage.

Before each test we get a reference to localStorage. During each test, we'll set this reference to a mock localStorage that we'll create. That way, we can control what the test sees and interacts with when the test accesses localStorage.

describe('Saving and Loading Cards', () => {
    let originalLocalStorage: Storage

    beforeEach(() => {
        originalLocalStorage = window.localStorage
    })

    afterEach(() => {
        (window as any).localStorage = originalLocalStorage
    })

    const { cards } = initialState;
    const stringCards = JSON.stringify(cards);

    //saving cards saves cards

Write the first test. We will use jest.spyOn to see if saveCards calls the localStorage setItem method with the right arguments. We are spying on the setItem method of the window.localStorage prototype. When we spy on a method, we replace that method with a jest.fn, and can see what calls get made to the spied on method. jest.spyOn is a type of mocking.

it('Saving cards saves cards', () => {

        const setItem = jest.spyOn(window.localStorage.__proto__, 'setItem');

        saveCards(cards);

        expect(setItem).toHaveBeenCalledWith("cards", stringCards);
    })

Save Cards Test Fail

Pass Save Test 1: Saving Cards

File: src/services/Save/index.ts
Will Match: src/services/complete/index-1.ts

Using localStorage is fairly simple. It's globally available, so you don't need to import it. You access the setItem method and pass it two arguments. The first argument is the name of the property you want to set. The name is a string. The second argument is the value of the property. The value is also a string.

cards is an array, so we use JSON.stringify() to change it into a string before saving it.

export const saveCards = (cards: Card[]) => {
  try {
      localStorage.setItem('cards', JSON.stringify(cards));
    } catch (err) {
      console.error(err);
    }
};

When you finish writing the code and run the app, you can check if the cards are getting saved. You can check your localStorage in the dev console of your web browser. Click application, localstorage, then localhost:3000 and you can see the saved cards.

Save Card Test Pass

Save Tests 2-3: Loading Cards

File: src/services/Save/index.test.ts
Will Match: src/services/Save/complete/test-2.ts

Import loadCards.

import { saveCards, loadCards } from './index';

loadCards should retrieve the cards from localStorage and return them as a JSON object, an array.

We are doing some more complicated mocking in this test. We defined stringCards earlier as a JSON.stringify'd version of cards. Now we are making a jest.fn that will return the value stringCards when called.

let mockGetItem = jest.fn().mockReturnValue(stringCards)

localStorageMock is an object with a property getItem. localStorageMock.getItem returns a function that accepts any parameters and invokes mockGetItem, which returns stringCards.

        let localStorageMock = {
            getItem: (params: any) => mockGetItem(params),
        } 

To overwrite localStorage with our localStorageMock we use Object.defineProperty.

        Object.defineProperty(window, 'localStorage', {
            value: localStorageMock,
            writable: true
            });

Now when loadCards calls localStorage it will actually be calling the localStorageMock that we just made. Trying to call localStorage.getItem() with any parameters will call the mockGetItem jest function.

Because we know loadCards will try to call localStorage.getItem('cards'), we know it will receive our mock value. loadCards should parse stringCards and return an array that matches cards.

    //loading cards retrieves saved cards
    it('Loading cards returns saved cards object', () => {
        let mockGetItem = jest.fn().mockReturnValue(stringCards);

        let localStorageMock = {
            getItem: (params: any) => mockGetItem(params),
        }; 

        Object.defineProperty(window, 'localStorage', {
            value: localStorageMock,
            writable: true
           });


        const loadedCards = loadCards();
        expect(mockGetItem.mock.calls.length).toBe(1);
        expect(mockGetItem.mock.calls[0][0]).toBe('cards');
        expect(loadedCards).toStrictEqual(cards);
    });

We want loadCards to return undefined if no cards are found in localStorage. This time mockGetItem returns undefined.

    //loading cards returns undefined if nothing found
    it('Loading cards when no saved cards returns undefined', () => {
        let mockGetItem = jest.fn().mockReturnValue(undefined);
        let localStorageMock = {
            getItem: (params: any) => mockGetItem(params),
        } 
        Object.defineProperty(window, 'localStorage', {
            value: localStorageMock,
            writable: true
            })


        const loadedCards = loadCards();
        expect(mockGetItem.mock.calls.length).toBe(1);
        expect(mockGetItem.mock.calls[0][0]).toBe('cards');
        expect(loadedCards).toStrictEqual(undefined);
    });

Loading Cards Fail

Pass Save Tests 2-3: Loading Cards

File: src/services/Save/index.ts
Will Match: src/services/Save/complete/index-2.ts

Write the loadCards function. If we get a value from localStorage, parse it and cast it to an array type Card[]. If we don't get a value, return undefined.

export const loadCards = () => {
  try {
    const stored = localStorage.getItem('cards');
    return stored 
      ? JSON.parse(stored) as Card[]
      : undefined;
  } catch (err) {
      console.error("couldn't get cards from localStorage");
      return undefined;
  }
};

Load Cards Pass

Add Saving to CardContext

We are going to add saving and loading to CardContext.

  • Write the tests
  • Import the saveCards function into CardContext
  • Change the CardContext Provider so that it saves cards to localStorage when cards changes
  • Run the app and use Writing and the Save button to add another card
  • Inside the CardContext services file we will make a new getInitialState function that will try to load saved cards from localStorage

CardContext Tests 1-2: Saving the Array 'cards' When it Changes

File: src/services/CardContext/index.test.tsx
Will Match: src/services/CardContext/complete/test-10.tsx

Make a describe block named 'saving to localStorage and loading from localStorage.'

describe('saving to localStorage and loading from localStorage ', () => {
    it('when a card is added to cards, attempts to save', () => {
        const saveCards = jest.spyOn(localStorage, 'saveCards');

        const newCard = {
            question: 'New Question',
            subject: 'New Subject',
            answer: 'New Answer'
        };

        const newCards = [...initialState.cards, newCard];

        const SavesCard = () => {
            const { dispatch } = useContext(CardContext);
            return <Button content='save' onClick={() => dispatch({
                type: CardActionTypes.save,
                ...newCard
            })}/>}

        const { getByText } = render(
            <CardProvider>
                <SavesCard/>
            </CardProvider>);

        expect(saveCards).toHaveBeenCalledTimes(1);

        const saveCard = getByText(/save/i);
        fireEvent.click(saveCard);
        expect(saveCards).toHaveBeenCalledTimes(2);

        expect(saveCards).toHaveBeenCalledWith(newCards);
        saveCards.mockRestore();
    });

    it('when a card is taken out of cards, attempts to save cards', () => {
        const saveCards = jest.spyOn(localStorage, 'saveCards');

        const { current, cards } = initialState;
        const { question }  = cards[current];

        const newCards = cards.filter(card => card.question !== question);

        const DeletesCard = () => {
            const { dispatch } = useContext(CardContext);
            return <Button content='delete' onClick={() => dispatch({
                type: CardActionTypes.delete,
                question
            })}/>}

        const { getByText } = render(
            <CardProvider>
                <DeletesCard/>
            </CardProvider>);

        expect(saveCards).toHaveBeenCalledTimes(1);

        const deleteCard = getByText(/delete/i);
        fireEvent.click(deleteCard);
        expect(saveCards).toHaveBeenCalledTimes(2);

        expect(saveCards).toHaveBeenLastCalledWith(newCards);
    });
});

CardContext Fails Tests

Pass CardContext Tests 1-2: Saving Cards When Cards Changes

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-8.tsx

So, we want the user to be able to create new cards, change cards, and delete existing cards. That means the app needs to save the changes that the user makes. How would you do it?

You could give them a Save All Cards button, and save to localStorage when they click it. You'd probably also want to notify them when they had unsaved changes if you did that.

You could change the onClick function of the existing Save button to save to localStorage. You could do the same with the Delete button.

You could change the reducer, and call saveCards inside of the save case and inside the delete case. But you generally don't want your reducer to to have 'side effects,' and saving to localStorage is a 'side effect.'

A side effect is changing anything that's not the state object. Don't worry if you don't fully understand what a side effect is. It's enough to understand that if you use your reducer to change things besides variables that you create inside the reducer, you'll end up writing bugs into your code. In this app that we are writing using the reducer to save to localStorage is a side effect that probably wouldn't cause any problems. But we aren't going to do it that way.

The way we are going to make the app save cards is to make the CardContext save cards to localStorage every time the array of cards changes. We can do this because the CardProvider is a React component like any other. We can use hooks inside of the CardProvider. So we can use useEffect to trigger a function any time cards changes. It's just like how we've used useEffect before, to trigger a function that clears inputs when current changes. Except this time we are putting it inside the CardProvider and the function will call saveCards so we can save the cards to localStorage.

Import useEffect.

import React, { createContext, useEffect, useReducer } from 'react';

Import saveCards from Save.

import { saveCards } from '../Save';

Add a useEffect hook to save cards to localStorage when cards change.

    const [state, dispatch] = useReducer(reducer, testState ? testState : initialState);

    useEffect(() => {
        //save cards to localStorage
        saveCards(state.cards);
  }, [state.cards])

Add Loading to CardContext

To make the CardContext load the saved questions we are going to change the way that the CardContext gets the initialState. Right now initialState is an object inside of CardContext/index.js.

CardContext Services

We are going to make a function called getInitialState that returns the initialState object. We are going to put this function into the services subfolder of CardContext. This will let us keep the CardContext index file organized and easy to read. This is important because later in the project we are going to add some more cases to the reducer, which will make the CardContext file bigger.

CardContext Services Tests

File: src/services/CardContext/services/index.test.ts
Will Match: src/services/CardContext/services/complete/test-1.ts

What to Test?

We are going to write tests for the getInitialState function. Until now, initialState was just an object that we had written. We knew what would be in it. But now initialState will be the result of the getInitialState function. The getInitialState function is going to attempt to load saved cards from localStorage. And we can't be sure that it's going to get any cards, or that there won't be an error. So we want to test

  • getInitialState returns a default array of cards when loadCards from localStorage returns undefined
  • getInitialState returns the saved array of cards when loadCards returns a saved array of cards
  • getInitialState returns a current index of 0

getInitialState will always call the loadCards function that we wrote in Save. What loadCards returns depends on what is in localStorage. When we are running tests, we aren't using the localStorage in our web browser. We are using localStorage in the test web browser that Jest makes. This test browser localStorage starts out empty. And we can put things into it. So one way to test how getInitialState works with an empty localStorage or with cards in localStorage is to actually use the test browser localStorage. Don't put anything in and run the first test. Put cards in and run the second test. But then our test of getInitialState would also be a a test of the loadCards function. And it would depend on how well we understand what is in the test browser localStorage.

We Need to Mock LoadCards

We only want to test getInitialState. We don't want to test loadCards at the same time. So what we should do is make a fake version of loadCards. We will make a fake version of loadCards, and declare what the fake version of loadCards will return when getInitialState calls it. We will then test getInitialState in a way that makes getInitialState call the fake loadCards function instead of the real one. That's how we know what value of loadCards getInitialState is using. We'll know getInitialState is using the value that we want because it is calling the fake version of loadCards that we control.

A fake version of a function is called a mock function. The process of setting up mock functions is called mocking. Mocking can be complicated to set up right. I have no doubt that you will someday be very frustrated trying to mock a function while you are testing. But this example should work for you. And I hope it gives you an idea of how to set up mock functions when you are testing your own projects.

Write a Comment for Each Test.

//gets default initialState when it does not get cards from localstorage
//initialState contains saved cards when saved cards returned from localStorage
//current index should start at 0

Use Require Instead of Import

Do we do the imports at the top of this file? No! We aren't using the import command to get the function that we are testing. We are getting the function with the require command. There are complicated, technical differences between the way that these two commands work.

The basic reason we are not using import is because import would do the work to set up getInitialState before our mock loadCards function was ready. If we got getInitialState using import, getInitialState would be set up to use the real loadCards function. After that, our mock loadCards function would be set up. Then our tests wouldn't work because when we tested getInitialState it would call the real loadCards function. That's not what we want!

When we use require, getInitialState is set up when the require code runs. We can call require after we set up our mock function. That way, we can force getInitialState to call the mock loadCards function instead of the real one. When getInitialState calls the mock loadCards, it will get the return value that we put in the mock function. By controlling the return value of the mock function, we can control the test inputs.

//this command will reset the mock values in between tests
beforeEach(() => jest.resetModules());

//gets default initialState when it does not get cards from localstorage
it('gets default initialState when no cards in localstorage', () => {

    //the first argument is the path to the file that has the function you want to mock
    //the second argument is a function that returns an object
    //give the object a property for each function you want to mock
    jest.mock('../../Save', () => ({ 
        //loadCards is the only function we are mocking 
        //the value of loadCards is a function that returns undefined
        loadCards: () => undefined 
    }));

    //get the getInitialState function using require
    //put this AFTER THE MOCK, 
    //so now getInitialState will call the mock loadCards
    //and NOT THE REAL loadCards
    const { cards, getInitialState } = require("./index");

    const initialState = getInitialState();

    //because we set loadCards up to return undefined
    //getInitialState should return a CardState where the cards array is the default cards array
    expect(initialState.cards).toEqual(cards);
});

//initialState contains saved cards when saved cards returned from localStorage    
it('returns stored cards', () => {
    const mockCards = ['stored card', 'another stored card'];

    //See how we have a different return value?
    jest.mock('../../Save', () => ({ 
        loadCards: () => mockCards 
    }));

    const { getInitialState } = require("./index");

    const initialState = getInitialState();

    //getInitialState().cards should equal the return value we gave it
    expect(initialState.cards).toEqual(mockCards);
});

//current index should start at 0
it('starts current at 0', () => {
    const { getInitialState } = require('./index');

    const initialState = getInitialState();

    expect(initialState.current).toEqual(0);
})

Write the CardContext Services Index

File: src/services/CardContext/services/index.ts
Will Match: src/services/CardContext/services/complete/index-1.ts

Start the services file with these imports:

import { Card, CardState } from '../../../types';
import { loadCards } from '../../Save';

Remember, loadCards is the function that we mocked in our tests. We don't need to do anything special with it in this file to mock it in the tests.

Cut and paste card1, card2, and cards from CardContext/index.tsx to CardContext/services/index.ts.

//declare a card object
const card1: Card = {
    question: 'What is a linked list?',
    subject: 'Linked List',
    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.`
};

//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.`
}

//make an array with both cards
const cards = [card1, card2];

We are going to make a function getInitialState that returns the initialState object. We will declare a const loadedCards and assign it the return value of the loadCards function that gets the cards out of localStorage. If loadedCards is an array of cards then getInitialState will use it. If loadedCards is undefined then getInitialState will use cards, the array of example cards.

Mocking the loadCards function in the tests lets us control the return value of the loadCards function. That is how we test our getInitialState function.

//loadedCards is the result of calling loadCards
//try to get saved cards from localStorage
const loadedCards = loadCards();

//a function that loads the cards from localStorage
//and returns a CardState object
export const getInitialState = () => ({
    //the cards that are displayed to the user
    //if loadedCards is undefined, use cards
    cards: loadedCards ? loadedCards : cards,

    //index of the currently displayed card
    current: 0,

    //placeholder for the dispatch function
    dispatch: (action:CardAction) => undefined
} as CardState);

Import getInitialState into CardContext

File: src/services/CardContext/index.tsx
Will Match: src/services/CardContext/complete/index-9.tsx

Import the getInitialState function from services:

import { getInitialState } from './services/';

If any of these objects are still in CardContext, delete them:

  • card1
  • card2
  • cards

Change the definition of initialState from:

export const initialState: CardState = {
    current: 0,
    cards,
    dispatch: ({type}:{type:string}) => undefined,
};

to a call to getInitialState:

export const initialState = getInitialState();

Instead of just declaring the initialState object in CardContext, we call the getInitialState function. getInitialState will try to load the cards from localStorage. If the cards load, getInitialState will return the initialState object with cards loaded from localStorage. If it receives undefined, it will return the example cards that we wrote.

InitialState Pass

Those tests we wrote with the mocked loadCards function pass now!

Run the app. The cards will now load from localStorage when the app starts!

Cards in LocalStorage

Open the dev console. Click Application. Click localStorage. Click localhost:3000. These commands and menus may be different if you aren't using Chrome, or if you are using a different version of Chrome.

Save Test 3: Save Stats

File: src/services/Save/index.test.ts
Will Match: src/services/Save/complete/test-3.ts

Import saveStats.

import { 
    saveCards, 
    loadCards, 
    saveStats
} from './index';

Make a describe block 'Saving and Loading Stats.'

describe('Saving and Loading Stats', () => {
    let originalLocalStorage: Storage

    beforeEach(() => {
        originalLocalStorage = window.localStorage
    })

    afterEach(() => {
        (window as any).localStorage = originalLocalStorage
    })

//saving stats saves stats
//loading stats retrieves saved stats
//loading stats returns empty object if nothing found
});

Make some example stats, and stringify them.

    const stats = {
        'Example Question': {
            right: 3,
            wrong: 2,
            skip: 1
        }
    };

    const stringStats = JSON.stringify(stats);

    //saving stats saves stats

Make the test for saving stats. Use jest.spyOn to mock the localStorage setItem.

    //saving stats saves stats
    it('Saving stats saves stats', () => {

        const setItem = jest.spyOn(window.localStorage.__proto__, 'setItem');

        saveStats(stats);

        expect(setItem).toHaveBeenCalledWith("cards", stringStats);
    });

Save Stats Fail

Pass Save Tests 3: Save Stats

File: src/services/Save/index.ts
Will Match: src/services/Save/complete/index-3.ts

Import StatsType.

import { Card, StatsType } from '../../types';

The saveStats function is fairly simple.

export const saveStats = (stats: StatsType) => {
  try {
    localStorage.setItem('stats', JSON.stringify(stats));
  } catch (err) {
    console.error(err);
  }
};

Alt Text

Save Tests 4-5: Loading Stats

File: src/services/Save/complete/index.test.ts
Will Match:src/services/Save/complete/test-4.ts

Import loadStats.

import { 
    saveCards, 
    loadCards, 
    saveStats,
    loadStats
} from './index';

If there are stats in localStorage, loadStats should return a stats object.

    //loading stats retrieves saved stats
    it('Loading stats returns saved stats object', () => {
        const mockGetItem = jest.fn().mockReturnValue(stringStats);

        const localStorageMock = {
            getItem: (params: any) => mockGetItem(params),
        } 

        Object.defineProperty(window, 'localStorage', {
            value: localStorageMock,
            writable: true
            })    

        const loadedStats = loadStats();

        expect(mockGetItem.mock.calls.length).toBe(1);
        expect(mockGetItem.mock.calls[0][0]).toBe('stats');
        expect(loadedStats).toStrictEqual(stats);
    });

loadStats should return an empty object (not undefined) if nothing is found in localStorage.

    //loading stats returns empty object if nothing found
    it('Loading stats when no saved cards returns undefined', () => {
        const mockGetItem = jest.fn().mockReturnValue(undefined);

        const localStorageMock = {
            getItem: (params: any) => mockGetItem(params),
        } 

        Object.defineProperty(window, 'localStorage', {
            value: localStorageMock,
            writable: true
            })

        const loadedStats = loadStats();

        expect(mockGetItem.mock.calls.length).toBe(1);
        expect(mockGetItem.mock.calls[0][0]).toBe('stats');
        expect(loadedStats).toStrictEqual({});
    });

Alt Text

Pass Save Tests 4-5: Loading Stats

File: src/services/Save/complete/index.ts
Will Match:src/services/Save/complete/index-4.ts

export const loadStats = () => {
  try {
    const stored = localStorage.getItem('stats');

    return stored 
      ?  JSON.parse(stored) as StatsType
      : {} as StatsType
  } catch (err) {
      console.error("couldn't get stats from localStorage");
      return {} as StatsType;
  }
};

Load Stats Pass

Add Saving to StatsContext

We are going to add saving and loading to StatsContext.

  • Write the tests
  • Import the saveStats function into StatsContext
  • Change the StatsContext provider so that it saves stats to localStorage when stats changes
  • Change getInitialState to load saved stats from localStorage

StatsContext Tests 1-3: Saves Stats After Each Type of Action

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

Import the contents of Save as localStorage.

import * as localStorage from '../Save';
import { Button } from 'semantic-ui-react';

Write a comment for each test.

//saves stats when stats changed
//stats is empty object when it does not get stats from localstorage
//initialState contains saved stats when saved stats are returned from localStorage

Make a describe block named 'saving to localStorage and loading from localStorage.' Make another describe block inside the first, called 'saving.'

describe('saving to localStorage and loading from localStorage ', () => {
    //saves stats when stats changes    
    describe('saves stats when stats changes', () => {
    });

    //stats is empty object when it does not get stats from localstorage
    //initialState contains saved stats when saved stats are returned from localStorage

});

Declare a const question. This will be the question that we dispatch in stats actions.
Make a helper component UpdateButtons with three buttons that dispatch actions to statsContext.
Use Object.values and Array.map to turn the StatsActionType into an array of test parameters.

Run the tests with test.each.

    describe('save', () => {        
        const question = 'Is this an example question?';

        const UpdateButtons = () => {
            const { dispatch } = useContext(StatsContext);
            const dispatchStat = (type: StatsActionType) => dispatch({type, question});

            return <div>
                <Button content='right' onClick={() => dispatchStat(StatsActionType.right)}/>
                <Button content='wrong' onClick={() => dispatchStat(StatsActionType.wrong)}/>
                <Button content='skip' onClick={() => dispatchStat(StatsActionType.skip)}/>
            </div>
        }

        const eachTest = Object.values(StatsActionType)
        .map(actionType => {
            //an object of type StatsState
            const result = { [question] : {
                ...blankStats,
                [actionType]: 1
            }}

            //return an array of arguments that it.each will turn into a test
            return [
                actionType,
                result
            ];
        });

        //pass the array eachTest to it.each to run tests using arguments
        test.each(eachTest)
        //printing the title from it.each uses 'printf syntax'
        ('%#: %s saves new stats', 
        //name the arguments, same order as in the array we generated
        (
            actionType, 
            result
            ) => {
            //test starts here            
            const saveStats = jest.spyOn(localStorage, 'saveStats');
            saveStats.mockClear();

            const { getByText } = render(
                <StatsProvider testState={{} as StatsState}>
                    <UpdateButtons />
                </StatsProvider>);

            expect(saveStats).toHaveBeenCalledTimes(1);
            expect(saveStats).toHaveBeenCalledWith({});

            const regex = new RegExp(actionType as StatsActionType);
            const button = getByText(regex);
            fireEvent.click(button);

            expect(saveStats).toHaveBeenCalledTimes(2);
            expect(saveStats).toHaveBeenLastCalledWith(result);

        });
    });

StatsContext Save Fail

Pass StatsContext Tests 1-3: Saves Stats After Each Type of Action

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

Import useEffect.

import React, { createContext, useEffect, useReducer } from 'react';

Import saveStats.

import { saveStats } from '../Save';

Add the useEffect to save stats whenever state changes.

    const [state, dispatch] = useReducer(reducer, testState ? testState : initialState);

    useEffect(() => {
        saveStats(state);
    }, [state])

    const value = {...state, dispatch} as StatsState;

StatsContext Save Pass

StatsContext Test 4: Loading Stats from LocalStorage

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

Change Imports.

import React, { useContext} from 'react';
import { render, cleanup, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { Stats, StatsActionType, StatsState } from '../../types';
import { Button } from 'semantic-ui-react';

jest.mock('../Save', () => ({
    saveStats: jest.fn(),
    loadStats: () => ({})
}));

const { 
    blankStats, 
    initialState, 
    reducer, 
    StatsContext,
    StatsProvider 
} = require('./index');

Write test. Use jest.spyOn to mock loadStats.

    describe('load', () => {
        //stats is empty object when it does not get stats from localstorage
        it('gets default initialState when no stats in localstorage', () => {        
            expect(initialState).toHaveProperty('dispatch');
            expect(Object.keys(initialState).length).toEqual(1);
        });

        //loading stats retrieves saved stats
        it('loads stats from localStorage when there are stats in localStorage', () => {
            const localStorage = require('../Save'); 
            const loadStats = jest.spyOn(localStorage, 'loadStats');

            loadStats.mockImplementation(() => ({
                'Example Question': {
                    right: 1,
                    wrong: 2,
                    skip: 3
                }
            }));

            const { getInitialState } = require('./index');
            const initialState = getInitialState();

            expect(initialState).toHaveProperty('dispatch');
            expect(initialState).toHaveProperty('Example Question');
            expect(Object.keys(initialState).length).toEqual(2);
        })
    })

initialState is already the default state, so the first test passes.
StatsContext Load Fail

Pass StatsContext Test 4: Loading Stats from LocalStorage

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

Import loadStats.

import { loadStats, saveStats } from '../Save';

Make a getInitialState function. Use the spread operator to add the result of loadStats. Remember, loadStats will just return an empty object if there's an error.

//getInitialState is a function that returns a StatsState object
export const getInitialState = () => ({
    //spread the return value of the loadStats function
    ...loadStats(),
    dispatch: (action: StatsAction) => undefined
//tell TypeScript it is a StatsState object
} as StatsState);

//the object that we use to make the first Context
export const initialState = getInitialState();

StatsContext Pass

Ok, now stats will be saved in between sessions!

Stats in localStorage

Next Post: The Selector

Discussion

pic
Editor guide