Hey guys, I'm wondering how you tackle testing React-based apps. Particularly, I'd like to hear your thoughts about testing rapidly changing products like MVPs.
For a long time I was a big fan of e2e tests. However, many of my past teams struggled setting them up or/and were underestimating their value. Instead, the most common way of testing I observed is unit (I suppose) testing with jest + testing library + axios-mock-adapter (or some other request mocking libs). And here is my inner struggle: in my opinion, very granular unit testing on a MVP isn't the most efficient as often its implementation radically changes. I believe the main purpose of tests on MVP is to lock the current state of UI so the future implementation changes don't break what's been already working. Of course, one will argue that the more tests the better, but the reality is that we need to choose what will work best in a given time-frame (often very limited). Therefore, I worked out my own pattern which is a sort of hybrid:
- I test entire pages (mocking routing)
- I mock auth-related action(s)
- I mock actions which manipulate URL
- I even mock Web Workers if necessary
- I mock all AJAX requests with axios-mock-adapter in a way which lets me wait for those calls (a combination of spies and waitFor)
- My tests are driven by AJAX calls i.e. it's AJAX calls which indicate when certain interaction has been completed
- I often use snapshots and treat them carefully when they fail
See this stripped-out real world example:
import React from 'react';
import { ExamplePage } from '../pages';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import mocks from '../mocks/someCollectionEdit.json';
import renderPage from './helpers/renderPage';
const API_BASE_URL = '/api';
jest.mock('../actions/getters/user.ts', () => {
const actions = jest.requireActual('../actions/getters/user.ts');
actions.authenticateUser = jest.fn();
return actions;
});
jest.mock('../workers/someWorker/someWorker.client.ts');
jest.mock('../actions/setters/url.ts');
describe('render example page', () => {
let mock;
const mockRequests = () => {
// used by waitFor() in tests
const spies = {
[`${API_BASE_URL}/user`]: jest.fn(),
[`${API_BASE_URL}/organizations`]: jest.fn(),
[`${API_BASE_URL}/some-collection/example-id?someFilter=filter1&organizationId=2`]: jest.fn(),
[`${API_BASE_URL}/some-filters/example-id`]: jest.fn(),
[`${API_BASE_URL}/some-collection/details/example-id`]: jest.fn(),
// ...
};
// mocking calls which may include query strings
((url) =>
mock.onGet(url).reply((config) => {
process.nextTick(() => spies[config.url]());
return [200, mocks[config.url]];
}))(new RegExp(`${API_BASE_URL}/user$`));
((url) =>
mock.onGet(url).reply((config) => {
process.nextTick(() => spies[config.url]());
return [200, mocks[config.url]];
}))(new RegExp(`${API_BASE_URL}/organizations$`));
((url) =>
mock.onGet(url).reply((config) => {
process.nextTick(() => spies[config.url]());
return [200, mocks[config.url]];
}))(
new RegExp(
`${API_BASE_URL}/some-collection/example-id\\?.*`,
),
);
((url) =>
mock.onGet(url).reply((config) => {
process.nextTick(() => spies[config.url]());
return [200, mocks[config.url]];
}))(
new RegExp(
`${API_BASE_URL}/some-filters/example-id$`,
),
);
((url) =>
mock.onPost(url).reply((config) => {
process.nextTick(() => spies[config.url]());
return [200, mocks[config.url]];
}))(
new RegExp(
`${API_BASE_URL}/some-collection/example-id/data-draft$`,
),
);
((url) =>
mock.onPut(url).reply((config) => {
process.nextTick(() => spies[config.url](), 0);
return [200, mocks[config.url]];
}))(
new RegExp(
`${API_BASE_URL}/some-collection/example-id/data$`,
),
);
// ...
return spies;
};
beforeAll(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.reset();
});
it('should edit some form with a confirmation modal', async () => {
const spies = mockRequests();
renderPage(ExamplePage, {
route: '/organizations/:organizationId/some-collection/:collectionId/record/edit',
url: '/organizations/2/some-collection/example-id/record/edit',
search: '?someFilter=filter1',
});
await waitFor(() => // page has been rendered with all the necessary data
expect(
spies[
`${API_BASE_URL}/some-collection/example-id?someFilter=filter1&organizationId=2`
],
).toHaveBeenCalledTimes(1),
);
const inputField = screen.getByDisplayValue(/example value/i);
const saveChangesButton = screen.getByText(/Save changes/i);
fireEvent.change(inputField, { target: { value: 'updated value' } }); // user action
fireEvent.click(saveChangesButton); // user action
await waitFor(() => // data draft has been sent
expect(
spies[
`${API_BASE_URL}/some-collection/example-id/data-draft`
],
).toHaveBeenCalledTimes(1),
);
expect(screen.getByText(/Save some collection changes changes\?/i)).toBeInTheDocument();
expect(screen.getByText(/updated value/i)).toBeInTheDocument();
fireEvent.click(screen.getByText(/Confirm/i)); // user action
await waitFor(() => // data has been submitted
expect(
spies[
`${API_BASE_URL}/some-collection/example-id/data`
],
).toHaveBeenCalledTimes(1),
);
expect(
screen.getByText(
/Some collection records has been successfully changed./i,
),
).toBeInTheDocument();
});
// ...
});
Please share your thoughts about this matter and feel free to criticise my approach and suggest what would be better based on your commercial experience. Also, Happy New Year!
Top comments (2)
Testing rapidly changing products like MVPs:
I very much agree. Tests that focus on a spy of some prop function being called arenโt going to do you any good when a new requirement means you need to hoist that function into a context or hook. Those are implementation details and I donโt care if the function is called or even exists.
I focus on screens as well and ensure the screen can go through loading states as data comes in. From there, you can find ways to abstract your code that lets you test UI elements without the data loading, but interactions are better tested further up where they can provide protection and still allow refactoring.