Что я делаю сейчас
В настоящее время я тестирую реализацию кода. Такие тесты ломаются каждый раз после рефакторинга, особенно в компонентах пользовательского интерфейса. В итоге я провожу кучу времени, копаясь в файлах .test.js
, паралельно приследуюя магическую цифру в 80% для Test Coverage.
Что я должен делать
При написании любого типа тестов, включая модульное тестирование, я должен меньше думать о коде который я тестирую, а больше о том, что делает данный код. Это означает писать тесты, имитирующие поведение пользователя. Даже на самом низком уровне.
Пример
Представим стандартный юай компонент аккордеон. При нажатии он раскрывается или закрывается. Контент передается в компонента как children
.
Функционал нашего, тестового, компонента выглядит следующим образом:
- Первые 3 аккордеона развернуты, все оставшиеся закрыты.
- При нажатии на аккордеон срабатывает модуль
publishAccordionAnalytics
, который трекерит аналитику. Данный модуль мы импортируем из пакета@myProject/analyticsHelpers
- Если пользователь нажимает на скрытый аккордеон в нижней части приложения, и после раскрытия содержание аккордеона не находится в поле зрения пользователя, срабатывает анимация и контент компонента выезжает в видимую часть экрана.
...
import { publishAccordeonAnalytics } from '@myProject/analyticsHelpers';
class Accordion extends Component {
positionReference: RefObjectType;
constructor(props) {
super(props);
this.positionReference = React.createRef();
}
scrollToRef = (ref) => {
const wrapper = document.querySelector('.app');
return isInViewPort(ref, wrapper)
? null
: setTimeout(() => {
return ref.current
&& wrapper
&& wrapper.scrollTo(wrapper.scrollTop, wrapper.scrollTop + ref.current.offsetTop)
}, 300);
};
render() {
const { headerName, children, index } = this.props;
const { scrollToRef, positionReference } = this;
const defaultOpenAccordions = index >= 3;
return (
<Accordion
defaultOpen={!defaultOpenAccordions}
headerName={headerName}
onChange={(open) => {
publishAccordionAnalytics(open, headerName);
if (defaultOpenAccordions) {
scrollToRef(positionReference);
}
}}
id={headerName}
>
{children}
{defaultOpenAccordions && <div data-testId="referenced-div" ref={positionReference} />}
</Accordion>
);
}
}
Как я напишу тест сейчас:
jest.mock('@myProject/analyticsHelpers');
describe('Аккордеон', () => {
test('воспроизводится верно', () => {
const jsx = (
<Accordion headerName="Имя аккордеона">
<div>Test</div>
</Accordion>
);
const tree = renderer
.create(jsx)
.toJSON();
expect(tree).toMatchSnapshot();
});
test('аналитика вызвана', () => {
const wrapper = mount(
<Accordion headerName="Имя аккордеона" index={1}>
<div>Test</div>
</Accordion>
);
wrapper.find('Header').simulate('click');
expect(publishAccordeonAnalytics).toHaveBeenCalledTimes(1);
});
test('функция scrollToRef вызвана', () => {
const wrapper = shallow(
<Accordion headerName="Имя аккордеона" index={7}>
<div>Test</div>
</Accordion>);
const component = wrapper.instance();
component.scrollToRef = jest.fn();
wrapper.find('Header').simulate('click');
expect(publishAccordeonAnalytics).toHaveBeenCalledTimes(1);
expect(component.scrollToRef).toHaveBeenCalled();
})
});
Я тестирую структуру компонента при помощи снапшота, а также вызовы функции при клике.
Данный аккордеон полностью отвечает ожидаемому функционалу и тесты это подтверждают. Но теперь я сделаю рефакторинг, заменив React.Component
на functional component
, и вынесу метод компонента scrollToRef
в отдельную функцию.
function Accordion ({ marketName, children, index }) {
const positionReference = React.createRef();
const defaultOpenAccordions = index >= config.defaultOpenAccordions;
return (
<Accordion
defaultOpen={!defaultOpenAccordions}
headerName={headerName}
onChange={(open) => {
publishAccordeonAnalytics(open, marketName);
if (defaultOpenAccordions) {
scrollToRef(positionReference);
}
}}
id={marketName}
>
{children}
{defaultOpenAccordions && <div data-testId="referenced-div" ref={positionReference} />}
</Accordion>
);
}
Мои тесты посыпались… тест'функция scrollToRef вызвана'
терпит неудачу, т.к. функция scrollToRef
больше не является частным методом компонента. То же самое случилось бы с тестом 'аналитика вызвана'
, но он является импортом из модуля, так сейчас он pass.
Чтобы написать хороший тест, мне надо понять, как юзер использует мой компонент. Юзер:
- Нашел аккордеон, который содержит нужную ему информацию
- Нажал на название аккордеона, чтобы его открыть
- Прочитал ифну
- Закрыл аккордеон
Я понял, что его вообще не волнует, как называется мой компонент, на что именно он нажимает и так далее. Следуя этому принципу, я должен был написать что-то вроде этого:
import '@testing-library/jest-dom/extend-expect';
import { render, fireEvent, screen } from '@testing-library/react';
jest.mock('@myProject/analyticsHelpers');
describe('Аккордеон', () => {
test('полная функциональность компонента', () => {
const child = <div>Ребенок</div>;
// аккордеон № 10 в списке
const { getByText } = render(
<Accordion headerName="аккордеон номер 10" index={9}>
{child}
</Accordion>
);
// открываю аккордеон
fireEvent.click(getByText(/аккордеон номер 10/i));
expect(publishAccordeonAnalytics).toHaveBeenCalled();
expect(screen.queryByText('Ребенок')).toBeInTheDocument();
expect(screen.getByTestId('referenced-div')).toBeInTheDocument();
// закрываю аккордеон
fireEvent.click(getByText(/аккордеон номер 10/i));
expect(publishAccordeonAnalytics).toHaveBeenCalled();
});
});
Теперь я воспроизвел поведение пользователя. Нашел 10й аккордеон, нажал на него, прочитал контент, закрыл его — всё!
Этот тест визуально намного чище, выдержит рефакторинг и имитирует взаимодействие пользователей. И мне была их намного проще написать.
Используя данный паттерн мы сможем избежать использования энзаймовских нативных instance()
, state()
, find('ComponentName')
и иных функций, тестирующих реализацию кода.