Refactoring тестового кода

Когда тесты становятся более сложными, их код может начать терять читаемость, а также трудности с поддержанием растут. Для улучшения качества тестов и повышения их удобства чтения и поддержки необходим рефакторинг тестов. В контексте работы с React Testing Library это особенно актуально, так как библиотеки и компоненты с каждым новым проектом становятся более многообразными.

Зачем нужен рефакторинг тестов?

  1. Читаемость. Тесты, написанные без должного внимания к структуре и повторяющимся паттернам, могут стать трудными для понимания, особенно для других разработчиков, которые будут работать с этим кодом.
  2. Поддерживаемость. Если в проекте происходит обновление логики компонентов, то старые тесты могут стать неактуальными. Регулярный рефакторинг помогает поддерживать тесты актуальными и простыми для модификации.
  3. Переиспользуемость. Тесты могут содержать дублирующийся код, который можно вынести в отдельные функции или модули, что упростит поддержку и добавление новых тестов.

Признаки необходимости рефакторинга

  • Повторение кода. Когда несколько тестов содержат одинаковые части, например, общие setUp- или tearDown-функции, это сигнализирует о необходимости вынести эти части в отдельные утилиты.
  • Сложные тесты. Если тесты содержат большое количество действий, проверок или вложенных асинхронных операций, это снижает их читаемость.
  • Невозможность изолировать логику теста. Когда логика теста выходит за пределы одной функции, возникает трудность в разборе и поддержке.

Основные техники рефакторинга

1. Вынесение повторяющегося кода в утилиты

Если в нескольких тестах требуется одинаковая настройка компонента или выполнение общих действий (например, отрисовка компонента с определёнными пропсами), лучше вынести это в отдельные функции или моки. Это повысит читаемость и уменьшит количество повторяющегося кода.

Пример:

const renderWithProviders = (ui, { providerProps, ...renderOptions } = {}) => {
  return render(
    <SomeContext.Provider value={providerProps}>
      {ui}
    </SomeContext.Provider>,
    renderOptions
  );
};

test('should render with context', () => {
  renderWithProviders(<Component />);
  expect(screen.getByText('Some Text')).toBeInTheDocument();
});

2. Использование beforeEach и afterEach

Для настройки и очистки данных можно использовать хуки beforeEach и afterEach. Это позволяет избежать дублирования кода, а также гарантирует, что каждый тест будет изолирован от других.

Пример:

let mockFunction;

beforeEach(() => {
  mockFunction = jest.fn();
});

test('should call mock function', () => {
  mockFunction();
  expect(mockFunction).toHaveBeenCalledTimes(1);
});

3. Использование вспомогательных функций для взаимодействия с элементами

При работе с React Testing Library рекомендуется писать вспомогательные функции для взаимодействия с элементами UI. Это улучшает читаемость тестов и делает код более декларативным.

Пример:

const getButton = () => screen.getByRole('button', { name: /submit/i });

test('should call submit on button click', () => {
  render(<Form />);
  fireEvent.click(getButton());
  expect(handleSubmit).toHaveBeenCalled();
});

4. Упрощение асинхронных операций

Асинхронные тесты часто становятся сложными и трудными для чтения, если в них не соблюдается порядок выполнения операций. Использование findBy* методов, которые автоматически ожидают появления элементов, помогает упростить код.

Пример:

test('should render data after async fetch', async () => {
  render(<AsyncComponent />);
  const dataElement = await screen.findByText(/loaded data/i);
  expect(dataElement).toBeInTheDocument();
});

Кроме того, важно избегать использования wait и других низкоуровневых решений, если можно обойтись встроенными методами ожидания. Это делает тесты более стабильными и предсказуемыми.

5. Использование псевдоклассов и пользовательских событий

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

Пример:

const user = userEvent.setup();

test('should add item to cart', async () => {
  render(<Cart />);
  await user.click(screen.getByRole('button', { name: /add to cart/i }));
  expect(screen.getByText('Item added to cart')).toBeInTheDocument();
});

6. Упрощение ассертов

Иногда тесты могут содержать сложные ассерты, которые можно упростить, разбив их на несколько шагов. Это поможет сделать тесты более понятными и предотвратит ошибки в будущем.

Пример:

test('should display error message if input is empty', () => {
  render(<Form />);
  fireEvent.change(screen.getByLabelText(/name/i), { target: { value: '' } });
  fireEvent.click(screen.getByText(/submit/i));
  expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});

Разбиение сложных ассертов на несколько более простых позволяет легче отслеживать, на каком этапе возникает ошибка.

7. Уменьшение количества тестов

Иногда бывает полезно уменьшить количество тестов, оставив только те, которые проверяют критически важные моменты. Удаление лишних тестов помогает снизить время выполнения тестов и уменьшить их сложность.

Применение принципов рефакторинга

Рефакторинг тестов не должен быть редким или случайным. В идеале, его следует выполнять регулярно. Применение принципов рефакторинга в процессе разработки тестов помогает поддерживать чистоту и удобство кода. Важно понимать, что рефакторинг не должен изменять поведение тестов; его цель — улучшить структуру и читаемость.

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

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