Best Practices
When writing tests there are a few best practices you should follow to ensure your tests are as high value and easy to maintain as possible.
The Arrange, Act, Assert (AAA) Pattern
The AAA pattern aims to make tests easier to read and maintain by breaking tests into three areas:
- Arrange: This is the setup phase of the test. This is where you would set the conditions necessary to execute your test, including mocking, spying and rendering.
- Act: This is the execution phase of the test. This is where you run your actions and simulate user behaviour such as press events.
- Assert:. This phase verifies (or asserts) that the expected results have occurred, such as UI updating or side effects being fired.
describe('', () => {
it('Should show a success message to the user when they press redeem', () => {
// Arrange
const { getByText } = render(<RedeemButton />)
// Act
fireEvent.press(getByText('Redeem bonus'))
// Assert
expect(getByText('Success!')).toBeTruthy()
})
})
By following this pattern your tests are far easier to maintain, reason about, and have newcomers modify.
Don't test the framework
Tests should focus on testing the behaviour specific to our application. React is already thoroughly tested, and we should be able to trust that it works as intended. This means instead of writing tests that check that React is doing what it's supposed to, we should write tests that check our code does what it's supposed to. For example, this is an incredibly common test:
it('should render correctly', () => {
const { getByTestId } = render(<RedeemCodeScreen />)
expect(getByTestId('screen-container')).toBeTruthy()
})
It's easy to think this sort of test is necessary, of course we want to make sure our component renders, but really this is just testing that React itself works, and on top of that it's vague and doesn't really tell us anything about the expected behaviour of the component beyond it should show up. Whether or not a component renders will already be captured by a more detailed, behaviour based test because if it doesn't render the test will fail anyway. We don't need to explicitly test for this. Taking our redemption success test from above, let's do better:
it('Should show a success message to the user when they press redeem', () => {
const { getByText } = render(<RedeemCodeScreen />)
fireEvent.press(getByText('Redeem bonus'))
expect(getByText('Success!')).toBeTruthy()
})
In this, we can assume the component has rendered correctly because the test has passed and the UI has correctly updated. So we've both covered the "render check" of the initial test, but also created a much higher value, and more descriptive test for our code.
Similarly, things like "should have x props" and "should have x state" are also testing the framework, not the expected behaviour of our code. If we want to test that a component has a certain prop, we should be testing that the prop is used in the expected way, not that it exists. For example, if we have a component that renders a button with a certain text, we should test that the button renders with the correct text, not that the component has a prop called text
with the correct value. This also strongly relates to the next point.
Avoid testing implementation details
Implementation details are the specifics of how our code works. Some examples of this might be expecting useState
or dispatch
to have been called when a button is pressed, expecting certain props to be named certain things, or state to have certain values.
The issue with these sort of tests is that they tend to be very brittle, and can give us false negatives when refactoring our code. A great example of this is changing the name of a prop, if your test is making sure a component receives a name
prop, but you rename it to username
, then your tests will break even if your component still works as expected.
Ultimately the user doesn't care that redux dispatches an action, or that internal component state changes, or that a component receives certain props etc, they just care that the component behaves as expected. So our tests should match that.
it('Shows a success message when the bonus is redeemed', () => {
const { getByText } = render(<RedeemButton />)
fireEvent.press(getByText('Redeem bonus'))
// These are implementation details, they can easily break in refactoring even if our component still works.
// What about when we don't want to use redux anymore? Or no longer need that component state?
expect(dispatch).toHaveBeenCalledWith({ type: 'REDEEM_BONUS' })
expect(useState).toHaveValue('redeemed')
// We just care that regardless of implementation details, the success message is shown when the bonus is redeemed.
expect(getByText('Success!')).toBeTruthy()
})
Another good rule here too is that if you're exporting a function for the sole purpose of testing, it's probably an implementation detail. This will often look like this:
export const formatText = (text) => {
return text.toUpperCase()
}
const RedeemCodeButton = ({ onPress, buttonText }) => {
return (
<Button onPress={onPress} title={formatText(buttonText)} />
)
}
In this example, if formatText
is being exported just to be tested then it's probably an implementation detail. If expected component output is for the text to be all capitals, just assert that in your component test. This means you can refactor how you capitalise it as much as you want, and the test won't needlessly break.
Avoid snapshot testing
This in a way relates to the previous point about implementation details. Snapshot testing, while it has its place in some tests, isn't a good fit for most UI tests. Snapshots test the structure of a component, rather than the outcome, and as a result tend to be very brittle. They also require an incredibly intimate knowledge of the output of a component to be accurately verified, and ultimately they just add noise to MRs and tests as developers will just update snapshots when they fail rather than review their reason for failing.
In almost all ways snapshot tests can be replaced with tests similar to ones we have mentioned above.
Prefer to assert on things the user interacts with
Because our tests aim to resemble how our application is interacted with, it's important to utilise methods that align with that. This means querying for elements that the user would use, such as text, display values, and accessibility state/values. It's easy to over-reach for test IDs, but users don't interact with UI via test IDs and as such they should be only be used when no other matcher is suitable. The main exception is E2E, where the matcher API isn't as robust as it is in unit tests.
When writing tests, look at your component and think "how would I interact with this when using it?" For example, when you want to submit a form you would press the button that says "submit", not the button that has a test ID of "submit-button" because you can't actually see that.
The React Native Testing Library talks about this in their how should I query page.
it('Shows a success message when the bonus is redeemed', () => {
const { getByText } = render(<RedeemButton />)
// here we are pressing a button based on text because that's what the user will do, not test ID
fireEvent.press(getByText('Redeem bonus'))
// same thing, the user will see the success message, not the test ID
expect(getByText("Success!")).toBeTruthy()
})
Prioritse integration tests over unit tests
Drawing hard lines on what tests you should write is difficult, because it's all dependent on the work being done. As a general rule though, it's good to aim to prioritise integration tests, and then unit test reused components and functions.
Integration tests will check that the individual units of code that make up the application work, which makes specific unit tests redundant. They also give us much more confidence in our work.
Using our bonus code redemption example, how might this look? Say our app is comprised of the following structure:
app/
├── shared/
│ └── components/
│ ├── Input.tsx
│ ├── Input.test.tsx
│ ├── Button.tsx
│ └── Button.test.tsx
└── features/
└── bonus-offers/
├── screens/
│ ├── RedeemCodeScreen.test.tsx
│ └── RedeemCodeScreen.tsx
└── components/
├── BonusCodeInput.tsx
├── SubmitBonusButton.tsx
└── SubmissionMessage.tsx
In this, we've written tests for our screen, and our shared components which are used both in the components inside the RedeemCodeScreen
as well as other components throughout the app. By writing an integration test for our screen, we are testing that both the comprising components are working, as well as the screen as a whole. That means we've saved effort and execution time by avoiding needlessly testing the components used only within the RedeemCodeScreen
, but also maximised our confidence because we can be sure the feature as a whole is working correctly.
We've also elected to unit test the shared components, because they're used in numerous places, and we want to be sure they work as expected in isolation. This approach is the most bang for buck in terms of confidence given for effort spent writing and running tests.
Prioritise E2E tests over all else
As of writing these docs our E2E isn't at a point where this can be easily done, but it's still something to keep in mind and will be the rule as our E2E suite evolves.
As shown by the testing pyramid, end to end tests are king. They most closely resemble how users interact with our application, and as such are by far the most valuable tests we can write. Where possible you should be shooting for as much E2E coverage as can be achieved.
Write clear and descriptive test cases
Good tests will make it clear exactly what we expect code to do. Tests shouldn't assume the reader has an understanding of the technical context surrounding the test, instead they should explain it to them. This goes hand in hand with tests being a great way to document code, because well written test cases will describe the inputs and outputs of a function.
// Instead of
it('should render correctly' () => {});
it('should call navigate' () => {});
// Be more descriptive
it('shows the user a dismissable alert message' () => {});
it('navigates away when the users presses the dismiss button' () => {});
A good rule of thumb when writing 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').
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');
});
});