General Tips
Like anything else in JavaScript, writing tests can be done in many different ways. This is both good and bad at the same time. Good because there's no single-answer-answers-all, bad because every test suite is gonna be different than the other.
To minimise this issue there's a few guidelines and practices we can follow to ensure our tests are predictable, easy to read and maintainable.
Prefer jest.spyOn()
over jest.mock()
jest.spyOn()
is better when compared with jest.mock()
for several reasons:
- It lets you override mocks on on individual test cases:
Click to expand
Bad (you can't override what's been mocked with the jest.mock() call):
jest.mock('@app/myModule', () => ({
someFunction: () => false;
}));
describe('some module', () => {
it('must do X', () => {
// Can't override the mock as jest.mock is hoisted before tests begin
});
});
Good (you can override mocks on individual test cases):
describe('some module', () => {
beforeEach(() => {
jest.spyOn(myModule, 'someFunction').mockReturnValue(false);
});
it('must do X when `myModule.someFunction` returns `true`', () => {
jest.spyOn(myModule, 'someFunction').mockReturnValueOnce(true);
});
});
- It doesn't mess with the original shape of exported modules
Click to expand
Bad (What happens if useAsyncRender
is moved to a different scope ?):
jest.mock('@app/hooks/misc', () => {
return {
...jest.requireActual('@app/hooks/misc'),
useAsyncRender: jest.fn(() => true),
};
});
Good:
import * as miscHooks from '@app/hooks/misc';
jest.spyOn(miscHooks, 'useAsyncRender').mockReturnValue(true);
- It uses WAY less lines of code than
jest.mock
Click to expand
Bad:
jest.mock('@app/hooks', () => {
const goBack = jest.fn();
const navigate = jest.fn();
mockUseGetterSelector = jest.fn(() => ({
bankAccounts: [],
}));
return {
...jest.requireActual('@app/hooks'),
useGetterSelector: mockUseGetterSelector,
useNavigation: jest.fn(() => ({
goBack,
navigate,
})),
useRoute: jest.fn(() => ({
params: { eventStatus: 'OPEN' },
})),
useFormat: jest.fn(() => ({
MASK_TEXT: (txt: string) => txt,
MONEY: (money: string) => money,
})),
};
});
Good:
import * as hooks from '@app/hooks';
const mockUseGetterSelector = jest.fn(() => ({
bankAccounts: [],
}));
jest.spyOn(hooks, 'useGetterSelector').mockImplementation(mockUseGetterSelector);
jest.spyOn(hooks, 'useNavigation').mockReturnValue({
goBack: jest.fn(),
navigate: jest.fn()
});
jest.spyOn(hooks, 'useRoute').mockReturnValue({ params: {eventStatus: 'OPEN'} });
jest.spyOn(hooks, 'useFormat').mockReturnValue({
MASK_TEXT: (txt: string) => txt,
MONEY: (money: string) => money,
});
Write happy path mocks, and override only when necessary
This means to write your mocks to work as the function/package being mocked would, and then only override that when you are testing scenarios where they don't work as expected or intended.
Click to expand
Bad (spies/mocks are duplicated on each test):
describe('SomeModule', () => {
it('must do X when myModule.doSomething() returns `true`', () => {
jest.spyOn(dateModule, 'isSaturday').mockReturnValueOnce(true);
const testResult = someFunctionUsingDateModuleIsSaturday();
expect(testResult).toEqual(true);
});
it('must NOT do X when myModule.doSomething() returns `false`', () => {
jest.spyOn(dateModule, 'isSaturday').mockReturnValueOnce(false);
const testResult = someFunctionUsingDateModuleIsSaturday();
expect(testResult).toEqual(false);
});
});
Good (mocks/spies are all defined in a beforeEach
, and only overridden when necessary):
describe('SomeModule', () => {
let testResult: string;
beforeEach(() => {
// Return `true` by default - this is the happy path!
jest.spyOn(dateModule, 'isSaturday').mockReturnValue(true);
testResult = someFunctionUsingDateModuleIsSaturday();
});
it('must do X when myModule.doSomething() returns `true`', () => {
expect(testResult).toEqual(true);
});
it('must NOT do X when myModule.doSomething() returns `false`', () => {
jest.spyOn(dateModule, 'isSaturday').mockReturnValueOnce(false);
testResult = someFunctionUsingDateModuleIsSaturday();
expect(testResult).toEqual(false);
});
});
Do not mock redux
Chances are, if you're testing a component or module that either dispatches redux
actions or references the store through store.getState()
, then you need to run assertions on these interactions. The presence of a mock such as jest.mock('react-redux')
in your tests indicate your test is incomplete &/or wrong.
Click to expand
Bad:
jest.mock('react-redux', () => ({
useDispatch: jest.fn(() => jest.fn()),
useSelector: jest.fn(),
}));
const testComponent = render(<SomeComponentConsumingRedux />);
Good:
import configureMockStore from 'redux-mock-store';
import { getDefaultMiddleware } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
const mockStore = configureMockStore(getDefaultMiddleware());
const testComponent = render(
<Provider store={mockStore}>
<SomeComponentConsumingRedux />
</Provider>
);
...
expect(mockStore.getActions()).toEqual(...);
Alternatively if you are testing a standalone hook (or somethign else that isn't a component so you can't wrap it in a <Provider>
) which internally calls a Redux hook/action, you can use the spyOn
method:
import * as redux from 'react-redux';
(jest.spyOn(redux, 'useSelector') as jest.Mock).mockReturnValue(whatever_value_test_needs);
Use jest.mock
to mock entire modules
If the inner workings of a dependency are breaking a test suite but its outputs don't really affect the test expectations, it might make sense to auto-mock the dependency with jest.mock()
. Read the docs on jest.mock()
for more details.
Click to expand
// Use jest's auto-mocking capabilities
jest.mock('@app/hooks');
Use type casting when dealing with partial objects instead of any
When unit testing certain aspects of a component or a module you might need to stub an object that conforms to a certain Typescript type, but don't necessarily need all of its properties:
Click to expand
// Error: Error: Type '{ id: string; name: string; }' is not assignable to type 'RacingRace'.
// Property 'advertisedStart' is missing in type...
const race: RacingRace = {
id: '123',
name: 'My Race',
};
You can avoid these errors by type casting the value being assigned to this variable, like so:
// No errors!
const race: RacingRace = {
id: '123',
name: 'My Race',
} as RacingRace;
Do not use
any
! Treat unit tests like any other part of our codebase. Other developers will have to maintain this code and not having types is only making their lives harder.
Asynchronous State Updates
Chances are you'll encounter the following error very early in your testing journey:
Warning: An update to <ComponentNameHere> inside a test was not wrapped in act(...).
This happens because your component is re-rendering outside of the React callstack. Kent C. Dodds has a very thorough blog post explaining how to get rid of these warnings that's worth reading. tl;dr: interactions that cause a component to update need to be explicitly handled in your tests.
For example, if you have a button that sets component state when clicked, you need to explicitly handle that in your tests. The idiomatic way to do this using testing-library
is using waitFor
or waitForElementToBeRemoved
.
A simple example:
// our component. when we press the button, we'll render a loading spinner
// and call the passed in press handler. if we don't explicitly handle
// setting the state in our tests, we'll end up with an `act` warning.
const SomeButton = (props) => {
const [isLoading, setLoading] = useState(false)
const handlePress = () => {
setLoading(true);
props.onPress()
}
return (
<Button onPress={handlePress}>
<Text>Click me</Text>
{isLoading ? <LoadingSpinner testID="loading" > : null}
</Button>
);
}
// our test. notice how even though we are asserting on the press logic, we
// still make the effort to handle the loading state.
test('it calls the given press handler', async () => {
const handlePressSpy = jest.fn();
const { getByTestId, getByText, waitFor } = render(
<SomeButton onPress={handlePressSpy} />
);
fireEvent.press(getByText('Click me'));
expect(handlePressSpy).toHaveBeenCalledTimes(1)
// !!! without this an `act` warning would be added to the test log.
await waitFor(() => getByTestId('loading-spinner'))
});
A more obscure act
warning can be due to a component re-rendering after a dispatch
and state update. Caution should be taken as to not hide real warnings, but these can be resolved by simply waiting one tick using the following pattern:
await waitFor(() => true);
Since every component we have is written in a different way, there's no single solution we can suggest in this page. Instead, use your intuition and a mix of trial and error.