TDD в React фронтенде
Tags: react, testing, webdev, tutorial
Published: 2021-01-26 08:12:53

Если интересны переводы свежих статей на тему веб-разработки, то можно найти меня [в твиттере](https://twitter.com/BalaevArif). 

Разберемся в вольном переводе [статьи](https://startup-cto.net/tdd-in-a-react-frontend/)

В настоящее время осталось лишь несколько профессиональных разработчиков, которые сомневаются в ценности TDD (test driven development) разработки и TDD (test driven design) проектировании. Но реальность многих кодовых баз, которые я видел, такова, что tdd часто ограничивается бэкендом, где живет «бизнес-логика».

Частично это связано с тем, что разработка фронтенда не является «реальной разработкой программного обеспечения», хотя в большинстве случаев полностью функциональный бэкенд совершенно непригоден для использования без соответствующего фронтенда. Частично это связано с отсутствием навыков работы с tdd во фронтенде. Об этом и написана эта статья.

 Я беру React в качестве примера, потому что это фреймворк, с которым я больше всего знаком, а декларативный стиль упрощает некоторые тесты, чем при использовании чистого JavaScript, HTML и CSS. Но большинство идей из этой статьи справедливы и в других контекстах.
 
 Если вас интересуют статьи и новости о разработке веб-продуктов и предпринимательстве, [подписывайтесь на меня в Twitter](https://twitter.com/intent/follow?original_referer=https%253A%252F%252Fstartup-cto.net%252F&ref_src=twsrc%5Etfw&region=follow_link&screen_name=The_Startup_CTO&tw_p=followbutton).

## Почему тестирование фронтенда сложнее, чем бэкэнда?

Не всегда лень отталкивает фронтенд инженеров от tdd. Это становится особенно очевидным, если наблюдать за фулстек инженерами, которые неукоснительно практикуют tdd для своего бэкэнд-кода и не пишут ни одного теста во фронтенде.

По моему опыту, различия сводятся к трем пунктам:

1. Во фронтенде фичи обычно имеют значительно бОльшие интерфейсы. Хотя бэкенд API в его простейшей версии может быть определен простой структурой JSON, даже самая простая фича фронтенда будет определяться не только функциональностью, но также часто тысячами пикселей, отображаемых на экране.
2. Хуже того, у нас пока нет хорошего способа объяснить машине, какой из этих пикселей имеет значение. Для некоторых изменение пикселей на самом деле не имеет никакого значения, но при изменении неправильных пикселей фича становится совершенно непригодной.
3. Долгое время инструменты не позволяли проводить интеграционные тесты за секунды. Вместо этого тесты должны были быть ограничены чистой бизнес-логикой или запускаться в браузере, на настройку которых требовалось несколько минут.

Так как же это исправить?

## Написание тестируемого кода во фронтенде
Подобно тому, как вам часто нужно разделить код бэкенда и ввести dependency injection, чтобы иметь возможность его протестировать, код фронтенда также следует разделить, чтобы упростить тестирование. Существует примерно три категории фронтенд кода, каждая из которых имеет свой способ тестирования.

В качестве примера возьмем классическое [todo приложение на React](https://startup-cto.github.io/todos). Я рекомендую открыть [репозиторий](https://github.com/startup-cto/todos) на втором экране и следить за ним. Я добавил в эту статью отрывки кода для тех, кто может читать на мобильном телефоне или по другим причинам не имеет доступа к репозиторию во время чтения.

## Связующий код
[Компонент App](https://github.com/startup-cto/todos/blob/main/src/App/App.tsx) и [хук useTodos](https://github.com/startup-cto/todos/blob/main/src/App/useTodos.ts) - это то, что я называю связующим кодом. Он «связывает» остальную часть кода, чтобы реализовать функциональность:

```jsx
const TodoApp: FunctionComponent = () => {
  const { todos, addTodo, completeTodo, deleteTodo } = useTodos([]);

  return (
    <>
      <TodoList
        todos={todos}
        onCompleteTodo={completeTodo}
        onDeleteTodo={deleteTodo}
      />
      <AddTodo onAdd={addTodo} />
    </>
  );
};
```

```jsx
export function useTodos(initialTodos: Todo[]) {
  const [todos, dispatch] = useReducer(todosReducer, initialTodos);
  return {
    todos,
    addTodo: (description: string) =>
      dispatch(createAddTodoAction(description)),
    completeTodo: (id: Todo["id"]) => dispatch(createCompleteTodoAction(id)),
    deleteTodo: (id: Todo["id"]) => dispatch(createDeleteTodoAction(id)),
  };
}
```

Подобно контроллеру на бэкенде, лучше всего проверять с помощью [интеграционных тестов](https://github.com/startup-cto/todos/blob/main/src/App/App.test.tsx):

```javascript
describe("TodoApp", () => {
  it("shows an added todo", async () => {
    render(<App />);

    const todoInput = screen.getByLabelText("New todo");
    const todoDescription = "My new todo";
    userEvent.type(todoInput, todoDescription);
    const addTodoButton = screen.getByText("Add todo");
    userEvent.click(addTodoButton);
    
    expect(await screen.findByText(todoDescription)).toBeInTheDocument();
  });
});
```

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

Такие виды интеграционных тестов должны быть как можно более независимыми от используемой технологии. Приведенные выше тестовые примеры зависят от React (если бы я переписал приложение без React, мне бы пришлось изменить и тесты). Тесты будут работать независимо от того, использую ли я функциональные компоненты, компоненты классов, Redux state management, внешнюю библиотеку форм или использую 3 или 300 компонентов для создания todo приложения. Это очень важно, так как означает, что я могу безопасно реорганизовать код, не касаясь тестов.

Причина этого в том, что тесты написаны с точки зрения пользователя: найдите что-то с пометкой «New todo», введите в него новое todo, нажмите кнопку «Add todo» и убедитесь, что todo, которое я только что написал, теперь отображается на экране.

## Бизнес логика
Это тесты, с которыми больше всего знакомы люди, пришедшие из бэкенд тестирования. Бизнес-логика нашего todo приложения занимается созданием, удалением и пометкой todo как выполненных. То же самое можно использовать и в бэкэнде.

```javascript
export function todosReducer(todos: Todo[], action: TodoAction) {
  switch (action.type) {
    case TodoActionType.AddTodo:
      return [...todos, action.payload];
    case TodoActionType.CompleteTodo:
      return todos.map((todo) =>
        todo.id === action.payload.id ? { ...todo, completed: true } : todo
      );
    case TodoActionType.DeleteTodo:
      return todos.filter((todo) => todo.id !== action.payload.id);
  }
}
```

[Тесты для такого кода](https://github.com/startup-cto/todos/blob/main/src/model/reducer.test.ts) обманчиво просты:

```javascript
describe("todo reducer", () => {
  describe("addTodoAction", () => {
    it("adds a new todo to the list", () => {
      const description = "This is a todo";
      expect(todosReducer([], createAddTodoAction(description))).toContainEqual(
        expect.objectContaining({ description })
      );
    });

    it("does not remove an existing todo", () => {
      const existingTodo = new TodoMock();
      expect(
        todosReducer([existingTodo], createAddTodoAction("This is a todo"))
      ).toContainEqual(existingTodo);
    });
  });
});
```

Сложность тестирования бизнес-логики заключается не в написании тестов, а в отделении бизнес-логики от остального кода. Давайте посмотрим на [useTodos](https://github.com/startup-cto/todos/blob/main/src/App/useTodos.ts), который является связующим кодом, вызывающим этот редьюсер (todosReducer) в React:

```javascript
export function useTodos(initialTodos: Todo[]) {
  const [todos, dispatch] = useReducer(todosReducer, initialTodos);
  return {
    todos,
    addTodo: (description: string) =>
      dispatch(createAddTodoAction(description)),
    completeTodo: (id: Todo["id"]) => dispatch(createCompleteTodoAction(id)),
    deleteTodo: (id: Todo["id"]) => dispatch(createDeleteTodoAction(id)),
  };
}
```

Опасность здесь состоит в том, чтобы написать бизнес-логику так, чтобы ее можно было протестировать только путем полного тестирования хука. Использование хука только для связывания редьюсера и экшен creator’ов с логикой React’а избавляет нас от всей этой боли.

## Презентационные компоненты
И последнее, но не менее важное: давайте посмотрим на [презентационный код](https://github.com/startup-cto/todos/tree/main/src/components). Эти компоненты определяют интерфейс для пользователя, но сами по себе не содержат никакой бизнес-логики. Именно здесь и происходит большинство проблем, о которых я упоминал в начале статьи. И, честно говоря, я не нашел идеального решения для всех из них. Но есть подходящая концепция:

Story - это визуальный эквивалент unit теста. Основным остающимся недостатком является то, что этап подтверждения успешности теста должен выполняться вручную.

Например, [story для кнопки](https://github.com/startup-cto/todos/blob/main/src/components/shared/Button.stories.tsx):

```javascript
const Template: Story<Props> = (args) => <Button {...args} />;

const actionArgs = {
  onClick: action("onClick"),
};

export const Default = Template.bind({});

Default.args = {
  ...actionArgs,
  children: "Click me!",
  color: ButtonColor.Success,
};
```

И далее сама кнопка:

```jsx
export enum ButtonColor {
  Alert = "Alert",
  Success = "Success",
}

export enum ButtonType {
  Submit = "submit",
  Reset = "reset",
  Button = "button",
}

export interface Props {
  children: ReactNode;
  color: ButtonColor;
  onClick?: () => void;
  type?: ButtonType;
}

export const Button: FunctionComponent<Props> = ({
  children,
  color,
  onClick,
  type,
}) => {
  const colorStyles = {
    [ButtonColor.Alert]: {
      border: "#b33 solid 1px",
      borderRadius: "4px",
      boxShadow: "2px 2px 2px rgba(100,0,0,0.8)",
      color: "white",
      backgroundColor: "#a00",
    },
    [ButtonColor.Success]: {
      border: "#3b3 solid 1px",
      borderRadius: "4px",
      boxShadow: "2px 2px 2px rgba(0,100,0,0.8)",
      color: "white",
      backgroundColor: "#0a0",
    },
  };
  return (
    <button
      style={{
        ...colorStyles[color],
        padding: "0.2rem 0.5rem",
      }}
      onClick={onClick}
      type={type}
    >
      {children}
    </button>
  );
};
```

Story отображает кнопку изолированно. Сначала я могу написать story, которая позволяет мне подумать о предполагаемом интерфейсе для этого компонента, а потом реализовать сам компонент. Если какие-либо детали реализации изменятся, то пока интерфейс останется прежним, мне не придется менять story. И я могу смотреть на визуализированную story изолированно, когда хочу убедиться, что она по-прежнему выглядит так, как задумано (это «ручная» часть, о которой я упоминал выше). Как только у меня будет версия, которой я доволен, я могу даже настроить автоматическое регрессионное тестирование с помощью инструмента визуальной регрессии.

![Storybook](https://dev-to-uploads.s3.amazonaws.com/i/ryd3zl9u6yp55pcuslyp.png)

## Всё вместе
Как бы выглядело на практике при разработке этого todo приложения в стиле tdd? 

1. Напишите интеграционный тест, чтобы текст «No todos» был виден, если их нет. 
2. Выполните тест, реализовав компонент “App”, чтобы он просто возвращал “No todos”. 
3. Извлеките "No todos" в отдельный компонент 
4. Добавьте к нему story 
5. Используйте story для визуальных изменений, пока часть «No todos» не будет выглядеть так, как должно 
6. Добавьте интеграционный тест о добавлении todo
7. Начните выполнение теста и пойми, что понадобится какое-то управление состоянием 
8. Закомментируйте интеграционный тест 
9. Напишите unit тест для редьюсера состояния 
10. Выполните тест, написав простую первую версию редьюсера 
11. Напишите story для отображения списка todo 
12. Используйте story для реализации компонента TodoList 
13. Раскомментируйте интеграционный тест  
14. Выполните интеграционный тест, связав редьюсер и компонент. 
15. ... 

Очевидно, есть много других способов сделать это. Но, надеюсь, показал один из возможных рабочих процессов для использования tdd во фронтенде.