Saving to LocalStorage
jacobwicks
Posted on January 17, 2020
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);
})
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 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);
});
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;
}
};
Add Saving to CardContext
We are going to add saving and loading to CardContext
.
- Write the tests
- Import the
saveCards
function intoCardContext
- Change the
CardContext
Provider so that it savescards
tolocalStorage
whencards
changes - Run the app and use
Writing
and theSave
button to add another card - Inside the
CardContext
services file we will make a newgetInitialState
function that will try to load saved cards fromlocalStorage
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);
});
});
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 whenloadCards
fromlocalStorage
returns undefined -
getInitialState
returns the saved array of cards whenloadCards
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.
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!
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);
});
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);
}
};
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({});
});
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;
}
};
Add Saving to StatsContext
We are going to add saving and loading to StatsContext.
- Write the tests
- Import the
saveStats
function intoStatsContext
- Change the
StatsContext
provider so that it savesstats
tolocalStorage
whenstats
changes - Change
getInitialState
to load savedstats
fromlocalStorage
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);
});
});
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 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.
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();
Ok, now stats will be saved in between sessions!
Next Post: The Selector
Posted on January 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.