javascript

Зачем писать юнит-тесты на фронтенд?

  • пятница, 18 августа 2023 г. в 00:00:14
https://habr.com/ru/companies/nordclan/articles/755302/

Привет, хабр! Меня зовут Александр, я работаю фронтенд-разработчиком в компании Nord Clan.

Сегодня речь пойдет про тесты… Про юнит-тесты. Думаю, что почти все слышали про юнит-тесты, пробовали их писать, и, возможно бросали это «гиблое дело», как только сталкивались с непониманием того, что тестировать на фронтенде.

Тестировать UI? Тестировать функции? Тестировать классы? С каждым таким вопросом и попытках разобраться в них тает желание начать писать эти тесты, но растет мотивация вновь засучить рукава и начать прокликивать UI вручную перед каждым коммитом, до мозоли затирая клавиатуру и мышку:

Why so serious, тестируешь?
Why so serious, тестируешь?

Однако советую не торопиться удалять файл с тестом, попробуем разобраться и ответить на два вопроса: что тестировать на фронтенд и как писать тестируемый код?

Что тестировать на фронтенде?

Когда пишется приложение на фронтенд, то как правило реализуется 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, а порой на фронтенде часто не хватает именно этого.