DEV Community

mnsr
mnsr

Posted on • Edited on

Axios Mocking in React using Typescript and testing-library

I got stuck on an issue yesterday, so I thought I'd create a post on my findings and solution.

In the end, it was quite simple really (the longer I get stuck on something, the easier the solution turns out to be... 🤷‍♂️). My googlefu had failed me completely as every solution I found, was either out dated, or incomplete, or more importantly: did not account for Typescript.

Important packages used here (github link at the end):

Hmm. Where to start? Lets go with a basic useFetch hook, as that's where we use axios to fetch our data.

useFetch axios hook

export interface IUseFetch {
  response: any;
  loading: boolean;
  error: boolean;
}

export const useFetch = (run: boolean, url: string) => {
  const [response, setResponse] = useState({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(false);

  useEffect(() => {
    let mounted = true;
    const abortController = new AbortController();
    const signal = abortController.signal;
    if (run && mounted) {
      const fetchData = async () => {
        try {
          setLoading(true);
          const response = await axios.get(url);
          if (response.status === 200 && !signal.aborted) {
            setResponse(response.data);
          }
        } catch (err) {
          if (!signal.aborted) {
            setResponse(err);
            setError(true);
          }
        } finally {
          if (!signal.aborted) {
            setLoading(false);
          }
        }
      };
      fetchData();
    }

    return () => {
      mounted = false;
      abortController.abort();
    };
  }, [run, url]);

  return { response, loading, error };

}
Enter fullscreen mode Exit fullscreen mode

Pretty standard useFetch hook. The run variable is the trigger for fetch to run.

App

Next up, our basic React component. This component is just an input, that does a search and shows a div with some search results that come from our useFetch hook above.


export interface ILocation {
  location: string;
  country: string;
}

export default function App() {
  const [searchString, setSearchString] = useState(""); 
  const [isPanelOpen, setIsPanelOpen] = useState(false); // show/hide results
  const [doSearch, setDoSearch] = useState(false); // controls fetch run

  // useFetch hook above.
  const { response, loading } = useFetch(doSearch, "test.json");

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearchString(e.target.value);
  };

  // If the searchString length > 0, then do the following
  useEffect(() => {
    setDoSearch(searchString.length > 0);
    setIsPanelOpen(searchString.length > 0);
  }, [searchString.length]);

  const renderSearchResults = () =>
    !loading &&
    !error &&
    response &&
    response.length > 0 && (
      <ul aria-label="search-results">
        {response.map((loc: ILocation, i: number) => (
          <li key={i}>
            {loc.location}, {loc.country}
          </li>
        ))}
      </ul>
    );

  return (
    <div className="App">
      <label htmlFor="search">Search:</label>
      <input
        type="text"
        aria-label="search-input" // label used by our tests
        id="search"
        name="search"
        autoComplete="off"
        value={searchString}
        onChange={handleChange}
      />

      {isPanelOpen && (
        <div aria-label="search-panel">{renderSearchResults()}</div>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Easy enough? Cool.

Now to the testing.

Testing

Before we start, looking at the code above, we have three aria-labels that we will use to assert.

  • search-input: our input box
  • search-panel: our search result container div. This may show empty depending on response (we won't cover that here)
  • search-results: holds the actual json response from our useFetch hook

First, lets prepare our test file.

Create a file called App.test.tsx and set it up:

import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import React from 'react';
import {
  cleanup,
  render,
  fireEvent,
  wait,
} from '@testing-library/react';

import axios from 'axios';
import App from './App';

jest.mock('axios');

Enter fullscreen mode Exit fullscreen mode

Things to note:

  • @testing-library/jest-dom/extend-expect: allows us some useful extensions to jest-dom like .toBeInTheDocument().
  • We import axios normally No need for funny names.
  • we do a standard jest.mock('axios') This lets our tests know that whenever they see an axios import, to replace it with a mock function.

What you came here for: The Mock

Before we write our test, we mock. We're going to be mocking axios, and this was the part I was stuck on. But it's quite straightforward.

import { AxiosResponse } from 'axios';

// load our test.json file. This can be copied to the local 
// folder. Can be a short version of your actual data set.
const testJson = require('../../test.json');

// Our mocked response
const axiosResponse: AxiosResponse = {
  data: testJson,
  status: 200,
  statusText: 'OK',
  config: {},
  headers: {},
};

// axios mocked
export default {
  // Typescript requires a 'default'
  default: {
    get: jest.fn().mockImplementation(() => Promise.resolve(axiosResponse)),
  },
  get: jest.fn(() => Promise.resolve(axiosResponse)),
};

Enter fullscreen mode Exit fullscreen mode

What's happening here is that we create a mocked AxiosResponse, which contains all the essentials like the response.status which we use in our useFetch hook, and then the most important part: the response.data.

Then we have the actual axios mock. Whenever our app sees the axios import, it'll use whatever is inside this. We're using get for this example, so I've included a get mock. The important thing to note here is that we have a default and this is used by Typescript. More info here

The Test

Next, we write our test. In this test, we'll follow the recommendations Kent C. Dodds has written about in his blogs. So we'll do just one end to end (E2E) test here. This will cover the user typing something into the input box, and seeing our search results.


test("type text into input, and  display search results", async () => {
  // our test searchString
  const searchString = "syd";

  // Render App
  const { getByLabelText, queryByLabelText, debug } = render(<App />);

  // find the input
  const input = getByLabelText("search-input");

  // search panel should not be rendered at this point
  expect(queryByLabelText("search-panel")).not.toBeInTheDocument();

  // this fire the onChange event and set the value to 'syd'
  fireEvent.change(input, { target: { value: searchString } });

  // useFetch should be called to get data
  expect(axios.get).toHaveBeenCalled();

  // assert our input value to be searchString
  expect(input.value).toBe(searchString);

  // search panel is loaded in the document
  expect(queryByLabelText("search-panel")).toBeInTheDocument();

  // wait for search results to be rendered
  await wait(() => {
    expect(queryByLabelText("search-results")).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

We use async because need to await the search results to render.

And that's it. The key to mocking axios in typescript is just the mock file that returns an AxiosResponse. Then in our test we can assert expect(axios.get).toHaveBeenCalled().

Here's a link to a github repo as Codesandbox doesn't support jest.mock.

If you play around with it, you can see how it works by commenting the entire axios.ts file and jest.mock('axios') in the App.test.tsx file.

Hope this helps someone.

Top comments (6)

Collapse
 
netochaves profile image
Neto Chaves

Great article thank you for showing this. just a note.

I think you've an error when you call the useFetch hook because you're passing the url first but you're expecting the run argument as the first one.

Collapse
 
mnsr profile image
mnsr

Thanks. It's my first article I've ever written.

Yup, good catch. Fixed it.

Collapse
 
jannikwempe profile image
Jannik Wempe

Very valuable post, thanks! I guess it will save some people a lot of time (maybe including myself in the future) 😊

Collapse
 
lucaconfa80 profile image
Luca Confalonieri

Interesting article.
I am trying to follow your example in my tests, but I get stuck on a
TypeError: axios_1.default is not a function error.
Not sure why.

Collapse
 
mohsin708961 profile image
{{7*7}}

Awesome

Collapse
 
svensoldin profile image
Sven Soldin

Thank you, this helped me a bunch !