Привет, хабр! Меня зовут Александр, я работаю фронтенд-разработчиком в компании Nord Clan.
Сегодня речь пойдет про тесты… Про юнит-тесты. Думаю, что почти все слышали про юнит-тесты, пробовали их писать, и, возможно бросали это «гиблое дело», как только сталкивались с непониманием того, что тестировать на фронтенде.
Тестировать UI? Тестировать функции? Тестировать классы? С каждым таким вопросом и попытках разобраться в них тает желание начать писать эти тесты, но растет мотивация вновь засучить рукава и начать прокликивать UI вручную перед каждым коммитом, до мозоли затирая клавиатуру и мышку:
Однако советую не торопиться удалять файл с тестом, попробуем разобраться и ответить на два вопроса: что тестировать на фронтенд и как писать тестируемый код?
Что тестировать на фронтенде?
Когда пишется приложение на фронтенд, то как правило реализуется UI и поведение этого UI, то бишь логика, и, порой трудно отделить мух от котлет, логику от представления.
Создаются компоненты, они один за другим выстраивают приложение словно по кирпичику, блестят пропсы, салютуют хуки, гремят фанфа… Ну, примерно так и может видеть свой код разработчик поначалу, пока внезапно не придется сделать доработку, которая выявит явные недостатки кода, включая плохую расширяемость, размытые границы между логикой и представлением и, конечно же, вследствие всего этого, плохую тестируемость.
Легко сказать, да трудно написать плохой код показать, а поэтому попробуем создать элемент игры «Судоку», начав конечно же писать все без тестов. Далее попробуем написать тесты поверх реализации. Уже потом проведем рефакторинг через TDD.
Тестируем приложение
Начальная реализация без тестов поможет выявить проблемы, которые приводят к плохой тестируемости компонентов и всем тем проблемам, упомянутым в начале главы: плохой расширяемости и плохого разделения логики и представления.
Первым делом создадим игровое поле:
export const App = () => (
<div className={classes.fields}>
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
<div className={classes.field} />
</div>
);
Окей, теперь по идее неплохо бы сделать ввод значений в клетку:
export const App = () => {
const [fieldValues, setFieldValues] = useState<Record<number, string>>({});
return (
<div className={classes.fields}>
{fields.map((_, fieldIndex) => (
<div
key={fieldIndex}
className={classes.field}
>
<input
className={classes.input}
onChange={(event) => {
event.persist();
setFieldValues((values) => ({
...values,
[fieldIndex]: event.target?.value,
}));
}}
value={fieldValues[fieldIndex]}
type="text"
/>
</div>
))}
</div>
);
}
Разберем код, здесь его будет побольше. Значение каждой ячейки храним в состоянии компонента:
const [fieldValues, setFieldValues] = useState<Record<number, string>>({});
В каждой ячейке теперь рендерится input, который при onChange устанавливает значение ячейки по ее индексу. Я использовал 16-ю версию React, так что event.persist прилагается:
<input
className={classes.input}
onChange={(event) => {
event.persist();
setFieldValues((values) => ({
...values,
[fieldIndex]: event.target?.value,
}));
}}
value={fieldValues[fieldIndex]}
type="text"
/>
Итак, на данный момент получается такой UI:
Все отлично работает, пора писать тесты.
Распланируем тесты с помощью it.todo:
describe('when rendered', () => {
it.todo('should render the nine sudoku cells');
it.todo('should change sudoku cells values');
});
В общем, написать надо два теста: тест на рендеринг девяти ячеек судоку и тест на изменение значений в каждой ячейке.
Реализуем первый тест:
describe('when rendered', () => {
it('should render the nine sudoku cells', () => {
render(<App />);
const cells = screen.queryAllByTestId('value');
expect(cells).toHaveLength(9);
});
});
Думаю, что тут все довольно-таки понятно. Используем testing-library, рендерим компонент в jsdom, получаем ячейки с data-аттрибутом data-testid. Далее проверяем, что ячеек действительно девять.
Однако, заметим первый тревожный звоночек. А что если поле будет не 3x3. Ну, многие скажут, рано загадывать, но сейчас выходит так, что у нас появилось хардкод-значение, которое не позволяет улучшить тест и, скажем, протестировать построение поля 4x4 и т.д. (пример, выходит за рамки правил игры в судоку, но пожертвуем правилами в угоду показательности). Следовательно, возникает первая проблема: плохая расширяемость написанного кода.
Так что давайте исправим то, чего можно было бы скорее всего избежать, напиши мы поначалу тест, а потом уже имплементацию:
export const App: FC<Props> = (props) => {
const { fieldSize = DEFAULT_FIELD_SIZE } = props;
const fields = new Array(fieldSize).fill(null);
}
Добавили новый аргумент fieldSize и перенесли создание начальных полей внутрь компонента, чтобы использовать fieldSize.
Также теперь рассчитываем квадратный корень из переданного размера поля, чтобы динамически построить сетку:
// …
const fieldSizeSqrt = Math.sqrt(fieldSize);
return (
<div
className={classes.fields}
style={{
gridTemplateColumns: `repeat(${fieldSizeSqrt}, 50px)`,
gridTemplateRows: `repeat(${fieldSizeSqrt}, 50px)`,
}}
/>
);
Опустим проверку на целое число из корня ради простоты.
Так, мы ввели некоторую универсальность в компонент. Кент Бек в своей книге «Экстремальное программирование. Разработка через тестирование» назвал такой подход «триангуляцией в тестировании», обобщением нескольких тест-кейсов.
Напишем второй тест на проверку изменения значений клеток:
it('should change sudoku cells values', async () => {
render(<App />);
const cell = await screen.findByTestId('value');
userEvent.type(cell, '9');
// протестировать сохранение значения трудно...
// expect()
});
Находим первую попавшуюся ячейку, вводим туда значение через userEvent и… Задумчиво смотрим в экран, пытаясь понять, как протестировать внутренности компонента, вспоминая последний коммит без тестов, к которому уже хочется откатиться.
Да, оказалось, что вытащить значение из компонента непростая затея. Именно затея, не иначе.
Так этот тест показал вторую проблему: проблему разделения логики и UI. Сейчас выходит так, что компонент на все руки мастер: он совмещает рендеринг UI с логикой его поведения.
Значит логику надо вынести из компонента. На это есть кастомные хуки и container-компоненты. Многие, например, Дэн Абрамов утверждают, что container-компоненты стали не нужны с приходом хуков.
Отчасти это так, но не всегда выходит, что нашими container-компонентами будут страницы напрямую, да и использование container-компонента предлагает тот вариант, когда очень трудно сделать из них них какого-то монстра.
Хотя возможно они и могут послужить аналогом божественного класса в фронтенде.
Поэтому проведем рефакторинг и создадим container-компонент первым делом:
// App/container.tsx
import { App } from '.';
export default () => (
<App />
);
Отлично, однако сейчас стакан все так же наполовину пуст как и этот пустой контейнер. Где хранить логику? Конечно же в кастомном хуке. Пишем… тест. Ага, реализация подождет.
Для начала вспомним, что за тест мы не смогли написать:
it('should change sudoku cells values', async () => {
render(<App />);
const cell = await screen.findByTestId('value');
userEvent.type(cell, '9');
// протестировать сохранение значения трудно...
// expect()
});
Поэтому создадим файл useFields.ts, который и будет отвечать за именения значений клеток:
// useFields.ts
export default () => {
};
Начнем с написания теста на то, что useFields возвращает нужные нам данные, а именно объект, где ключ является индексом поля с его значением:
describe('Test useFields', () => {
it('should return field values', async () => {
const { result } = renderHook(useFieldValues);
expect(result.current).toEqual({
0: '',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
});
});
Использовал isEqual, так как это наиболее удобный метод для проверки отображения вывода результатов тестов.
Естественно, все падает:
result.current возвращает undefined, так как реализация функции не написана. Сделаем максимально простой шаг к тому, чтобы тест заработал:
export default () => ({
0: '',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
Тест прошел:
Можно провести небольшой рефакторинг. Ничего необычного, просто сделал из обычной функции настоящий хук:
export default () => {
const [fieldValues, setFieldValues] = useState({
0: '',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
return fieldValues;
};
Тест все так же проходит, отлично:
Напишем второй тест на проверку изменения значения поля:
it('should change field value', () => {
const { result } = renderHook(useFieldValues);
result.current.changeFieldValue(0, '1');
expect(result.current.fieldValues).toEqual({
0: '1',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
});
Заметьте, вызываем changeFieldValue, а проверяем уже свойство fields, то есть используем паттерн Arrange-Act-Assert.
Такой функции у нас нет, так же как и свойства fields и тест со скрипом (да-да, мне это именно так и представляется) падает:
Так, сделаем максимально простую реализацию, чтобы тест прошел:
const [fieldValues, setFieldValues] = useState({
0: '1',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
return {
changeFieldValue: (fieldKey: number, fieldValue: string) => null,
fieldValues,
};
Создали noop-функцию и еще присвоили нулевой ячейке «1».
Второй тест проходит из-за хардкода в нулевой ячейке. Однако, тут же сломался первый тест, так как он проверяет прямой возврат из хука result.current,
В этом случае возникает патовая ситуация: рефакторинг продолжать невозможно с красной лампочкой, но и исправить красную лампочку никак нельзя без рефакторинга.
Но, если присмотреться, то мы заметим, что второй тест по сути покрывает кейс первого, а значит первый тест вовсе не нужен. Ну или не нужен отчасти, так как возможен случай, когда дефолтные значения полей совпадают с ожиданием теста, «тест-пустышка».
Вернемся к этому позже, а пока тест прошел:
Бежим делать рефакторинг, интересует больше всего функция changeFieldValue:
export default () => {
const [fieldValues, setFieldValues] = useState({
0: '1',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
function changeFieldValue(fieldKey: number, fieldValue: string) {
setFieldValues((values) => ({
...values,
[fieldKey]: fieldValue,
}));
}
return {
changeFieldValue,
fieldValues,
};
};
Возможно уже видно проблему, о которой упоминалось выше… Имеется в виду «тест-пустышка», то есть тест, который пропускает определенные баги/мутации в коде, оставаясь зеленым.
Тест пройдет, даже если закомментировать содержимое changeFieldValues, так как значения полей прописаны хардкодом и отследить, поменялось то или иное полей или нет, не так просто.
Поэтому хук будет использовать свойство initialFieldValues. Держимся, не пишем реализацию, но пишем тест:
it('should map default field values into returned ones', () => {
const initialFieldValues = {
0: '',
1: '2',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
};
const { result } = renderHook(useFieldValues, {
initialProps: initialFieldValues,
});
expect(result.current.fieldValues).toEqual(initialFieldValues);
});
Загорается красная лампочка, конечно же в хуке хардкод:
Правим максимально просто и быстро:
export default (initialFieldValues: Record<string, string>) => {
const [fieldValues, setFieldValues] = useState(initialFieldValues);
function changeFieldValue(fieldKey: number, fieldValue: string) {
setFieldValues((values) => ({
...values,
[fieldKey]: fieldValue,
}));
}
return {
changeFieldValue,
fieldValues,
};
};
Указываем параметр initialFieldValues и прокидываем его в хук.
Только теперь упал первый тест.
Благо поправить его совсем не трудно, нужно лишь передать свои начальные значения.
Передаем:
it('should change field value', () => {
const { result } = renderHook(useFieldValues, {
initialProps: {
0: '',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
},
});
result.current.changeFieldValue(0, '1');
expect(result.current.fieldValues).toEqual({
0: '1',
1: '',
2: '',
3: '',
4: '',
5: '',
6: '',
7: '',
8: '',
9: '',
});
});
Кто-то возразит: «мы же опять дублируем тесты». И отчасти он будет прав. Однако теперь каждый тест проводит тестирование отдельных частей хука: установка дефолтных полей и изменение значения поля.
Можно было бы написать тест на кол-во ререндеров, при обновлении значения поля, но оставим эту задачу самому любопытному читателю.
Все тесты проходят, проведем небольшой рефакторинг: создадим именованные типы для значения поля и полей в папке types/index.ts:
export type FieldValue = string;
export type FieldValues = Record<string, FieldValue>;
Далее применим эти типы в useFields и вынесем useFields из App в hooks:
import { FieldValue, FieldValues } from 'client/types';
export default (initialFieldValues: FieldValues) => {
const [fieldValues, setFieldValues] = useState(initialFieldValues);
function changeFieldValue(fieldKey: number, fieldValue: FieldValues) {
// ...
}
// ...
};
Окинем широким взглядом текущую структуру папок:
Итак, осталось прикрепить хук hooks/useFields к контейнеру App/container.tsx и прокинуть API в презентационный компонент.
Для этого опишем новый пропс для презентационного компонента – useFields:
type Props = {
useFields: () => {
fieldValues: FieldValues;
changeFieldValue: (fieldKey: number, fieldValue: FieldValue) => void;
};
fieldSize?: number;
}
Можно было бы сделать и проще: вывести тип useFields через ReturnType, но тогда наш станет более связанным и презентационный компонент будет зависеть от изменений в хуке useFields. Поэтому делам так, что презентационный компонент «требует» соответствие своим типам, а «не использует» типы извне, то есть делаем инверсию зависимостей.
Тесты из App.test.tsx упадут, так как теперь требуется передавать реализацию useFields:
Напишем заглушку и используем ее в тестах, чтобы починить их:
function useFields() {
return {
changeFieldValue: () => null,
fieldValues: {},
};
}
// ..
render(
<App
fieldSize={9}
useFields={useFields}
/>,
);
Вспомним, что мы не могли толком протестировать сохранения значения в поле, так как логика сохранения была прописана внутри компонента, то есть была тесно связана с представлением. Теперь этой проблемы нет, компонент потребляет логику работы с полями извне.
А значит мы спокойно можем передать любую нужную нам реализацию. Главное, чтобы эта реализация имплементировала интерфейс пропса useFields.
А поэтому для начала протестируем то, что компонент действительно вызывает changeFieldValue с нужным номером поля и введенным значением. Для этого создадим мок-функцию changeFieldValue и присвоим ее в хуке:
const changeFieldValueMock = jest.fn();
function useFields() {
return {
changeFieldValue: changeFieldValueMock,
fieldValues: {},
};
}
Не будем подробно останавливаться на разнице между моками, стабами и фикстурами, я просто оставлю это здесь.
Теперь напишем сам тест:
it('should change field value', () => {
render(
<App
fieldSize={1}
useFields={useFields}
/>,
);
const cell = screen.getByTestId('value');
userEvent.type(cell, '1');
expect(changeFieldValueMock).toHaveBeenCalledWith(0, '1');
});
Само собой тест падает, changeFieldValue не вызывается в компоненте, так как пока что он не использует передаваемый useFields:
Сделаем максимально простой шаг к починке теста:
export const App: FC<Props> = (props) => {
const { fieldSize = DEFAULT_FIELD_SIZE, useFields } = props;
const { changeFieldValue } = useFields();
changeFieldValue(0, '1');
// ..
}
Просто вызываем changeFieldValue в лоб. Теперь тест прошел:
Можно сделать полноценную реализацию, то есть провести рефакторинг:
<input
className={classes.input}
onChange={(event) => {
event.persist();
changeFieldValue(fieldIndex, event.target?.value);
}}
value={fieldValues[fieldIndex]}
type="text"
data-testid="value"
/>
Теперь changeFieldValue вызывается при вводе нового значения в поле, а тест все так же проходит:
Итак, в статье были рассмотрены наиболее частые случаи, с которыми может столкнуться разработчик, который только-только начал писать тесты. Некоторые из них могут завести в тупик и навести на вопрос по типу «А оно мне надо?».
И, отвечая на этот вопрос, можно смело сказать, что тесты помогают выбрать правильное направление в создании расширяемых компонентов путем выстраивания четких границ между логикой и UI, а порой на фронтенде часто не хватает именно этого.
Комментарии (15)
dlc
18.08.2023 11:06+1Как тестировать фронтенд, кратко:
Не пишем бизнес логику внутри представлений
Вообще никогда не пишем бизнес логику внутри представлений
Совсем никогда не пишем бизнес логику внутри представлений
Пишем юнит-тесты на бизнес логику
Если вам всё ещё хочется писать тесты на фронтенд, возможно вам нужно тестирование скриншотами
P.S. Исключения могут составлять общие компоненты со сложной собственной UI логикой. Например, самописный tooltip.
melkor_morgoth Автор
18.08.2023 11:06Да, в целом юнит-тесты стоит писать на сложную разветвленную бизнес-логику, например, в библиотеках.
noodles
18.08.2023 11:06Пишем юнит-тесты на бизнес логику
Во фронте почти не бывает бизнес-логики. Разве сам фронт и является продуктом - игра, онлайн-фотошоп, етс.
dopusteam
18.08.2023 11:06+1Вполне себе зависит от терминологии. Показать контрол выбора паспорта и сделать его обязательным, если возраст больше 14 - вполне себе бизнес логика
dlc
18.08.2023 11:06Смотря что это за приложение и что именно считать бизнес логикой. Например, сложный wizard, экраны и поля которого динамически определяются на основе данных на клиенте и который должен породить некую бизнес сущность.
noodles
18.08.2023 11:06Показать третий шаг формы, вместо второго, на основе поля ввода в первом шаге;
динамическая смена валидации полей на основе других полей и тд. - это всё логика UI конкретно этого клиента (фронта).
Настоящие бизнес-правила (источник истины) - находятся вне браузера, на бекенде, в схеме бд если угодно. Конечно же если мы говорим про распределённые системы, а не бекенд-лесс-браузерные инструменты (игры, редакторы, и т.д.).
Внутри фронта может быть сколь угодно много форм, и их связей, порядок показа попапов, т.е. фронт может быть большим и очень большим с накрученной ui логикой, но процессы обычно простые:отреагировать на действия юзера по каким-то ui-правилам
отправить этот результат действия на бек
там произойдёт настоящее бизнес-правило и валидация
отправить результат назад на фронт, отреагировать на это по каким-то ui-правилам
radist2s
18.08.2023 11:06Меня прямо иногда удивляет безапелляционность подобных заявлений.
Чем по вашему глобально отличается компонент реакта от любой другой функции? Я с таким же успехом могу и домены построить на компонентах, и бизнес логика там будет уместна.
dlc
18.08.2023 11:06Можете.
Бля меня глобально отличается тем, что это view-компонент конкретной библиотеки представления.
Если вы пишите бизнес логику (возьмём пример с контролом паспорта выше) в таком компоненте, а потом тестируете его, то, по факту, вы пишите интеграционный тест, ведь нужно ещё эмулировать виртуальный дом, работу рантайма реактуа и тд и всё это ради чего? Чтобы проверить разметку при двух вариантах if? Вы реально сомневаетесь в способностях реакта к условному рендерингу?Но вот код, который генерирует тот самый bool для контрола на основе входящих данных протестить хочется, но как это сделать, не делая тест на компонент реакта? Вынести логику и использовать её как сторонний сервис по отношению к компоненту.
В этом случае код автоматически разделяется по слоям, представление зависит от логики, а мы получаем маленький и простой тест.
noodles
18.08.2023 11:06-1засучить рукава и начать прокликивать UI вручную
Наиболее оптимальный способ для бизнеса, и как ни странно для DX программиста (чтоб не выгорел от бесполезности бытия).
На месте руководителя - я бы запретил юнит-тесты на фронте, и нанял бы пару qa-мануальщиков на этапе активной разработки.
После оживления проекта, т.е. выкатки в прод и хоть какой-то генерации прибыли - добавил бы пару синьйоров на e2e и интеграционные тесты. С покрытием только багов, которые реально встретились в проде.
Юнит-тесты на фронте - прожигание ресурсов как человеческих, так и машинных. UI может меняться слишком быстро, чтобы писать на него тесты. Только если ваш продукт и есть сам UI - тогда ок, можно даже тестирование скриншотами добавить.выстраивания четких границ между логикой и UI
Делать религию и городить абстракции между "логикой" и UI внутри фронта - также оверхед. Фронт - это всего-лишь view-слой всего MVC-приложения. Этот слой может меняться быстро и часто. Настоящая бизнес-логика\транзакции вот это всё - вне браузера. Внутри фронта - может быть сколько угодно сложная ui-ая логика, но это всего-лишь view-слой системы вцелом.
melkor_morgoth Автор
18.08.2023 11:06+2Да, ставить юнит-тесты во главу угла действительно не стоит. Однако, через них можно выявить некоторые изъяны в приложении, например, проблемы с расширяемостью компонентов.
Так сказать, взглянуть со стороны на свой код через призму тестов:)
markelov69
А потом меняются бизнес требование, начинается изменение функционала и все тесты и потраченное на них время псу под хвост.
Более того, юнит тесты дают ровно 0% гарантий что проект реально работает и не сломался, это фронтенд, тут всё со всем связано и очень много сайд эффектов. Нажатие кнопочки в одном месте может очень сильно изменить поведение во многих других местах в рамках бизнес логики.
Отсюда вывод - зачем тратить время в пустоту? Когда вместо этого можно делать куда более полезные вещи.
dopusteam
А что конкретно имеете в виду?
Потому что они не для этого)
Зато упавший юнит говорит, что что-то сломалось :)
markelov69
Полезные вещи вместо бессмысленных юнит-тестов фронта - новые фичи, новые проекты и т.п. Время - ресурс не возобновляемый и его нельзя купить, тратить его на бессмысленные юниты для фронт приложения это нелепость. Я могу понять тесты для библиотек, это да, must have. Но вот именно для фронтового проекта это просто время в пустоту.
Кто сказал что что-то сломалось? Бизнес логика изменилась и всё, тест упал, с чего вы взяли что что-то сломалось, наоборот работает так, как задумано, это просто бестолковый тест сломался и не более.
dopusteam
А с чего вы взяли что бизнес логика изменилась? Кто то поменял что то в компоненте просто.
В вашем мире чем отличается компонент из библиотеки и компонент из фронтового проекта?
Я вот пишу юниты для компонентов во фронтовом приложении и часто они помогают отловить ошибки при внесении изменений.
melkor_morgoth Автор
Соглашусь, починка тестов может занять некоторое время + все 100% тестами не покроешь.
Однако, львиная доля пользы от юнит-тестирования приходится на TDD, то есть написание тестов до реализации. Не зря говорят: "Хороший код – тестируемый код".