Skip to main content

Writing Component Tests

Shallow rendering

When testing complex and/or deep render trees (for example, a screen component) you might come across the scenario where a child component needs not-so-obvious mocks in order to work properly. Consider the following example:

Continue reading...
ComponentBeingTested.tsx

return (
<Box>
<ChildComponentA /> // Depends on NativeModule.abc
<ChildComponentB /> // Calls RestAPI.fetchRaces()
<ChildComponentC /> // Calls a random GraphQL
</Box>
);

Setting up the required mocks for such component (ComponentBeingTested.tsx) would be extremely verbose and not self explanatory. On top of that, it would likely take 100s of lines of code. Now imagine if ChildComponentC suddenly dropped the usage of GrahQL: how do we know to clean up the mocks at the parent levels?

Enter shallow rendering.

Since we're unit testing a component, it makes no sense to render all of its children. In other words, we just need to mock the implementation of any immediate child components so that they are "empty" components. Using the same example above, this is how we would shallow test ComponentBeingTested:

jest.mock('./childComponentA', () => ({ ChildComponentA: () => 'ChildComponentA' }));
jest.mock('./childComponentB', () => ({ ChildComponentA: () => 'ChildComponentB' }));
jest.mock('./childComponentC', () => ({ ChildComponentA: () => 'ChildComponentC' }));

Note: The factory parameter (second parameter of jest.mock) needs to return an exact representation of the ACTUAL file being mocked. In other words, if your component is exported with export default, the factory parameter would look like this:

jest.mock('./childComponentA', () => 'ChildComponentA');

Do not repeat yourself

Extract as much of the mocking and rendering logic to a beforeEach block instead of repeating yourself in every single it('') block.

Click to expand

Bad:

it('must do X', () => {
const mockVuexDispatch = jest.fn();
mockUseVuexDispatch.mockImplementation(() => mockVuexDispatch);
when(mockVuexDispatch).calledWith(API.FETCH_BANK_ACCOUNTS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_STORED_CREDIT_CARDS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_PAYPAL_ACCOUNT_SUMMARY).mockRejectedValue({});

const { getByTestId } = render(
<Provider store={ someStore }>
<SomeComponent propA={123} />
</Provider>,
);

expect(getByTestId('component_a')).toHaveTextContent('some text 2');
});

it('must do Y', () => {
const mockVuexDispatch = jest.fn();
mockUseVuexDispatch.mockImplementation(() => mockVuexDispatch);
when(mockVuexDispatch).calledWith(API.FETCH_BANK_ACCOUNTS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_STORED_CREDIT_CARDS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_PAYPAL_ACCOUNT_SUMMARY).mockRejectedValue({});

const { getByTestId } = render(
<Provider store={ someStore }>
<SomeComponent propA={123} />
</Provider>,
);

expect(getByTestId('component_b')).toHaveTextContent('some text');
});

... <repeats 10 times the same rendering block> ...

Good:

import { RenderAPI } from '@testing-library/react-native';

describe('MyComponent', () => {
let testWrapper: RenderAPI;

const defaultProps: React.ComponentProps<typeof MyComponent> = {
foo: 'bar'
}

const createTestWrapper = (props = defaultProps) => {
return (
<Provider store={ someStore }>
<SomeComponent {...props} />
</Provider>
);
}

beforeEach(() => {
const mockVuexDispatch = jest.fn();
mockUseVuexDispatch.mockImplementation(() => mockVuexDispatch);
when(mockVuexDispatch).calledWith(API.FETCH_BANK_ACCOUNTS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_STORED_CREDIT_CARDS).mockReturnValue({});
when(mockVuexDispatch).calledWith(API.FETCH_PAYPAL_ACCOUNT_SUMMARY).mockRejectedValue({});

testWrapper = createTestWrapper();
});

it('must do X', () => {
expect(getByTestId('component_a')).toHaveTextContent('some text 2');
});

it('must do Y', () => {
when(mockVuexDispatch).calledWith(API.FETCH_PAYPAL_ACCOUNT_SUMMARY).mockResolvedValueOnce(123);

expect(getByTestId('component_b')).toHaveTextContent('some text');
});
});

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(...).

Long story short, this happens because your component is re-rendering outside of a React callstack. Kent C. Dodds has a very thorough blog post explaining how to get rid of these warnings: https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning.

Long story short: any interactions with a component that result in asynchronous state changes should be wrapped with act(). 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.

Each UNIT test should ideally test just ONE thing

Lots of tests look like this:

it('should work', () => {
fireEvent.press(view.getByTestId('some-test-id'));

expect(store.getActions()).toEqual(...);
expect(analytics.trackEvent).toHaveBeenCalledWith(...);
expect(view.getByTestId('some-test-id')).toHaveTextContent('gleeb');
});

It's recommended people avoid this at all costs. A much better solution would look like...

it('should dispatch an XXXX action when tapping the YYYYY button', () => {
fireEvent.press(view.getByTestId('some-test-id'));

expect(store.getActions()).toEqual(...);
});

it('should track an analytics events when tapping the YYYYY button', () => {
fireEvent.press(view.getByTestId('some-test-id'));

expect(analytics.trackEvent).toHaveBeenCalledWith(...);
});

it('should change the YYYYY button text to `gleeb`', () => {
fireEvent.press(view.getByTestId('some-test-id'));

expect(view.getByTestId('some-test-id')).toHaveTextContent('gleeb');
});

Tests are a good opportunity to document code

This goes hand in hand with the previous item. Try to include as much detail about a test within the first it() parameter. There's no reason not to do so.

// Instead of...
it('must open the "ModalJoin" screen', () => {});

// Include more details!
it('must open the "ModalJoin" screen when tapping "Join Now" button', () => {});

A good rule of thumb when choosing test names is to include:

  • The intended outcome (e.g. must do XXXXX)
  • Any trigger (e.g. when the button XXXX is pressed)
  • Any conditions (e.g. when the 'enabled' prop is 'true')

Bringing these all together, you could end up with something like:

it('must NOT invoke the 'onPress' prop when the button is pressed and the 'enabled' prop is 'false').

Shallow testing

Consider the following component tree:

SomethingSomethingScreen
|-> SomeComplexHeaderBranchComponent
|--> SomeConditionallyRenderedHeaderComponent1
|--> SomeConditionallyRenderedHeaderComponent2

When testing the SomethingSomethingScreen component, it is recommended to mock the SomeComplexHeaderBranchComponent. The reasoning for this is simple: we don't care what this child component does, ESPECIALLY if it's not a leaf (see 📔  below) component. This would ensure that the tests for SomethingSomethingScreen would not fail if someone a few days later jumped in and decided to change SomeConditionallyRenderedHeaderComponent1.

As an example, if someone was to include a call to useNavigation (from react-navigation) in one of these SomeConditionallyRenderedHeaderComponent components, the tests for SomethingSomethingScreen would break, even though SomethingSomethingScreen didn't change at all. Developers need to avoid this at all costs.

📔 Leaf component: different developers will have different terms for this. By definition a leaf component is a component that sits at the end of a render tree. These will usually be a Box, a Text or any other of our components sitting under @app/components/core.

For people familiar with the Atomic Design Methodology, a leaf component would be the same as an Atom.

If a component is conditionally rendering ComponentX or ComponentY, then it is NOT a leaf component, but instead a Branch (or Molecule/Organism) component.

Standalone function tests

Leverage it.each or test.each when testing a function with multiple inputs

Click to expand

Bad:

describe('.sum', () => {
it('returns 2 when given (1, 1)', () => {
expect(sum(1, 1)).toEqual(2);
})

it('returns 5 when given (-1, 6)', () => {
expect(sum(-1, 6)).toEqual(5);
})

it('returns 10 when given (5, 5)', () => {
expect(sum(5, 5)).toEqual(10);
})
});

Good:

test.each([
[2, 1, 1],
[5, -1, 6],
[10, 5, 5]
])('must return %i when given (%i, %i)', (expected, a, b) => {
expect(a + b).toBe(expected);
});

A better example:

test.each`
input | result
${125} | ${'125'}
${12555} | ${'12.6k'}
`('should return "$result" when input is "$input"', ({ input, result }) => {
expect(getFormattedNumber(input)).toEqual(result);
});

// result:
// should return "125" when input is "125"
// should return "12.6k" when input is "12555"

Thanks @geoff.whatley for originally composing this section