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):
- @testing-library/jest-dom v5.1.1,
- @testing-library/react v9.4.1
- ts-jest v25.2.1
- jest v25.1.0
- axios v0.19.2
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 };
}
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>
);
}
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');
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)),
};
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();
});
});
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)
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 therun
argument as the first one.Thanks. It's my first article I've ever written.
Yup, good catch. Fixed it.
Very valuable post, thanks! I guess it will save some people a lot of time (maybe including myself in the future) 😊
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.
Awesome
Thank you, this helped me a bunch !