circuit

Testing local storage with testing library

How to test changes to the local storage on synchronous cases and asynchronous ones using the testing library.




Photo by Erda Estremera on UnsplashPhoto by Erda Estremera on Unsplash

Hey everyone, recently I struggled a bit trying to produce some tests for local storage using the testing library. The examples I found online were scarce, or just a bit confusing for the typical reader or someone with little or no knowledge.

After digging around, I came up with something I may consider to be an acceptable solution. Before delving into code, let’s review the basics.

What is the local storage?

The local storage is a property that allows accessing a Storage object that allows saving key/value pairs that persist between sessions and has no expiration date.

A storage object exposes a get, set, and remove Item methods that allow reading, adding, and removing items from the local storage.

How to test?

The main thing here passes by mocking the above-referred methods.

We can do that by using the Object.defineProperty() to modify the local storage methods and pass them mock functions.

Object.defineProperty(window, "localStorage", {
  value: {
    getItem: jest.fn(() => null),
    setItem: jest.fn(() => null),
  },
  writable: true,
});

As you can see, we replace the implementation of the getItem and setItem with a jest.fn(). Now we can access the window.localStorage.getItem freely and do assertions over it.

You may be wondering what that writable is there for, right?

The writable attribute allows reassigning values to that property, at will, by using an assignment operator. That means that if we decide that we want to access the localStorage.setItem outside that defineProperty method and modify its content we can.

With that information on your belt, let’s queue in a React component and test it using the testing library.

The component

import React from "react";
import { useState } from "react";

function App({ axios }) {
  // Similar to useState but first arg is key to the value in local storage.
  const [name, setName] = useLocalStorage("name", "Bob");

  const fetch = async (endpoint) => {
    const response = await axios.get(endpoint);
    setName(response.data);
  };

  return (
    <div>
      <input
        type="text"
        placeholder="Enter your name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <button
        onClick={() => {
          fetch("www.google.com");
        }}
      >
        Fetch
      </button>
    </div>
  );
}

// Hook
function useLocalStorage(key, initialValue) {
  // State to store our value
  // Pass initial state function to useState so logic is only executed once
  const [storedValue, setStoredValue] = useState(() => {
    try {
      // Get from local storage by key
      const item = window.localStorage.getItem(key);
      // Parse stored json or if none return initialValue
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      // If error also return initialValue
      console.log(error);
      return initialValue;
    }
  });

  // Return a wrapped version of useState's setter function that ...
  // ... persists the new value to localStorage.
  const setValue = (value) => {
    try {
      // Allow value to be a function so we have same API as useState
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      // Save state
      setStoredValue(valueToStore);
      // Save to local storage
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      // A more advanced implementation would handle the error case
      console.log(error);
    }
  };

  return [storedValue, setValue];
}

export default App;

Above, you can see a functional component that leverages the useLocalStorage custom hook. (Check https://usehooks.com/useLocalStorage/ for more info and custom hooks).

The component has an input that receives a name stored on the local storage and an onChange event that updates the local storage. For more fun and an extra difficulty, we add a fetch function that receives an endpoint, and, using axios, we make a get request and save the response data to our local storage.

The tests

We will be covering the following test cases:

  1. On render, the local storage getItem should be called.

  2. On the input text change, we should call the local storage setItem with the new text.

  3. On button click, we should make a get request to the endpoint and store the response on local storage.

First thing, first let’s mock our dependencies and setup or tests

import React from "react";
import { render, fireEvent, waitForElement } from "@testing-library/react";
import App from "./localStorage";

describe("App", () => {
  const fakeAxios = {
    get: jest.fn(() => Promise.resolve({ data: "Richard" })),
  };

  beforeEach(() => {
    Object.defineProperty(window, "localStorage", {
      value: {
        getItem: jest.fn(() => null),
        setItem: jest.fn(() => null),
      },
      writable: true,
    });
  });

  it("Should call localStorage getItem on render", () => {});

  it("Should call localStorage setItem on text change", () => {});

  it("Should call axios.get on click and call localStorage setItem on button click", async () => {});
});

As you can see on the gist above, we mock the axios get method to return a promise and leverage jest beforeEach function to redefine our local storage mocks before every test case.

Finished the setup, the tests should be straightforward.

On render, the local storage getItem should be called.

it("Should call localStorage getItem on render", () => {
  render(<App axios={fakeAxios} />);
  expect(window.localStorage.getItem).toHaveBeenCalledTimes(1);
});

This test case is the most straightforward of the three. All we have to do is render our component and assert that our mocked getItem is called.

On the input text change, we should call the local storage setItem with the new text

it("Should call localStorage setItem on text change", () => {
  const { queryByPlaceholderText } = render(<App axios={fakeAxios} />);

  const input = queryByPlaceholderText("Enter your name");
  fireEvent.change(input, { target: { value: "Daniel" } });

  expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
  expect(window.localStorage.setItem).toHaveBeenCalledWith("name", '"Daniel"');
});

This test case needs interaction with our component to be able to validate if the setItem is called. We do this by rendering our component and extracting its queryByPlaceholderText method and make use of it to gain access to our input. Having our input component, we now can use the testing library fireEvent to change its value.

The rest is simple, we assert that our setItem is called one time, and afterward, we check if that call was done using the expected key/value pair.

On button click, we should make a get request to the endpoint and store the response on local storage.

This test is trickier since it deals with asynchronous content, luckily we already mocked the axios get method always to return a given string.

it("Should call axios.get on click and call localStorage setItem on button click", async () => {
  const { getByDisplayValue, getByText } = render(<App axios={fakeAxios} />);

  const fetchButton = getByText("Fetch");
  fireEvent.click(fetchButton);
  await waitForElement(() => getByDisplayValue("Richard"));

  expect(fakeAxios.get).toHaveBeenCalledTimes(1);
  expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
  expect(window.localStorage.setItem).toHaveBeenCalledWith("name", '"Richard"');
});

The first thing we need to do is to start our async request. This part can be done by getting our button a firing a click event on it. Now we don’t want our assertions to be done before we make sure that the async requests have ended. Since we know that after finishing the request, our input value will change, then we can use the waitForElement and the getByDisplayValue to wait for the mocked value to appear.

Afterward, we can do our assertions successfully, and everything works right.

Now you can check our complete test file

import React from "react";
import { render, fireEvent, waitForElement } from "@testing-library/react";
import App from "./localStorage";

describe("App", () => {
  const fakeAxios = {
    get: jest.fn(() => Promise.resolve({ data: "Richard" })),
  };

  beforeEach(() => {
    Object.defineProperty(window, "localStorage", {
      value: {
        getItem: jest.fn(() => null),
        setItem: jest.fn(() => null),
      },
      writable: true,
    });
  });

  it("Should call localStorage getItem on render", () => {
    render(<App axios={fakeAxios} />);
    expect(window.localStorage.getItem).toHaveBeenCalledTimes(1);
  });

  it("Should call localStorage setItem on text change", () => {
    const { queryByPlaceholderText } = render(<App axios={fakeAxios} />);

    const input = queryByPlaceholderText("Enter your name");
    fireEvent.change(input, { target: { value: "Daniel" } });

    expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
    expect(window.localStorage.setItem).toHaveBeenCalledWith(
      "name",
      '"Daniel"'
    );
  });

  it("Should call axios.get on click and call localStorage setItem on button click", async () => {
    const { getByDisplayValue, getByText } = render(<App axios={fakeAxios} />);

    const fetchButton = getByText("Fetch");
    fireEvent.click(fetchButton);
    await waitForElement(() => getByDisplayValue("Richard"));

    expect(fakeAxios.get).toHaveBeenCalledTimes(1);
    expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
    expect(window.localStorage.setItem).toHaveBeenCalledWith(
      "name",
      '"Richard"'
    );
  });
});

You can also play around with it on Codesandbox

Conclusion

As you can see testing, local storage can be effortless, or it can be an earful. It all depends on how you approach it.

I hope this article helped with eventual doubts you may have about local storage testing as well as using the testing library for this end.

If you have any questions, you can find me on Twitter at @danieljcafonso

I hope you enjoyed and stay tuned for the next guides.




Continue Learning