DEV Community

Arif Balaev
Arif Balaev

Posted on

TDD в React фронтенде

Если интересны переводы свежих статей на тему веб-разработки, то можно найти меня в твиттере.

Разберемся в вольном переводе статьи

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

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

Я беру React в качестве примера, потому что это фреймворк, с которым я больше всего знаком, а декларативный стиль упрощает некоторые тесты, чем при использовании чистого JavaScript, HTML и CSS. Но большинство идей из этой статьи справедливы и в других контекстах.

Если вас интересуют статьи и новости о разработке веб-продуктов и предпринимательстве, подписывайтесь на меня в Twitter.

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

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

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

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

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

Написание тестируемого кода во фронтенде

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

В качестве примера возьмем классическое todo приложение на React. Я рекомендую открыть репозиторий на втором экране и следить за ним. Я добавил в эту статью отрывки кода для тех, кто может читать на мобильном телефоне или по другим причинам не имеет доступа к репозиторию во время чтения.

Связующий код

Компонент App и хук useTodos - это то, что я называю связующим кодом. Он «связывает» остальную часть кода, чтобы реализовать функциональность:

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

  return (
    <>
      <TodoList
        todos={todos}
        onCompleteTodo={completeTodo}
        onDeleteTodo={deleteTodo}
      />
      <AddTodo onAdd={addTodo} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode
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)),
  };
}
Enter fullscreen mode Exit fullscreen mode

Подобно контроллеру на бэкенде, лучше всего проверять с помощью интеграционных тестов:

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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

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

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

Бизнес логика

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

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Тесты для такого кода обманчиво просты:

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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

Сложность тестирования бизнес-логики заключается не в написании тестов, а в отделении бизнес-логики от остального кода. Давайте посмотрим на useTodos, который является связующим кодом, вызывающим этот редьюсер (todosReducer) в React:

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)),
  };
}
Enter fullscreen mode Exit fullscreen mode

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

Презентационные компоненты

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

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

Например, story для кнопки:

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,
};
Enter fullscreen mode Exit fullscreen mode

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

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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

Storybook

Всё вместе

Как бы выглядело на практике при разработке этого 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 во фронтенде.

Top comments (0)