Тестирование интерфейса: flaky-тесты рендеринга и анимации

Плавный рендеринг и приятная анимация UI = удобство и интерактивность приложения. Конечно, отзывчивый интерфейс это непросто, и без тестирования не обойтись. Здесь и возникают проблемы. Например, у вас spring-анимация отображения кнопки. При тестировании она привести к сбоям, а все потому что анимация завершилась на несколько миллисекунд позже чем нужно, из-за случайно запущенного фонового процесса.

В этом гайде мы будем использовать библиотеки тестирования Jest и React, чтобы описать реальные примеры тестов. Однако примеры релевантны независимо от вашего стека.

Причины

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

Тайминг

Анимации запускаются с дефолтными setTimeout задержками или transition-duration свойствами. Эти некорректные тайминги приводят к тому, что не срабатывают тестовые ассерты (утверждения), что в итоге вызывает моргание тестов.

Посмотрим на этот тест компонента с анимацией, его ширина увеличивается при нажатии кнопки:

test("Box width increases", () => {
  render(<Box />);
  const box = screen.getByTestId("box");
  expect(box).toHaveStyle({ width: "70px" });

  fireEvent.click(screen.getByRole("button"));
  expect(box).toHaveStyle({ width: "100px" });
});

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

Таким образом, основная проблема при тестировании рендеринга и анимации сводится к тому, чтобы убедиться, что утверждения выполняются после завершения выполнения анимации, а не раньше. Правильным подходом было бы дождаться завершения анимации; в React-тестировании этого можно добиться с помощью waitFor.

Зависимости окружения

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

Для мощных машин это не проблема. Однако если ресурсы машины ограничены, рендеринг анимации может быть медленным и не плавным, что приведет к рендомным сбоям тестов.

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

Зависимости библиотек

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

Например в React существует масса библиотек анимации, таких как Framer Motion, React Spring и т. д. Однако эти библиотеки могут стать причиной нестабильности тестов, когда:

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

Фикс

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

Рендеринг запроса на рассылку новостей

В этом примере компонент отображает простую форму с вводом email и кнопкой подписки. После отправки он регистрирует письмо и сбрасывает состояние.

function NewsletterPrompt() {
  const [email, setEmail] = useState("");
  const [subscribed, setSubscribed] = useState(false);

  const handleSubmit = (e) => {
    e.preventDefault();
    setSubscribed(true);
  };

  return (
    <div>
      {!subscribed ? (
        <div>
          <h2>Subscribe to Newsletter</h2>
          <input
            type="email"
            placeholder="Email address..."
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
          <button type="submit" onClick={handleSubmit}>
            Subscribe
          </button>
        </div>
      ) : (
        <p>Subscribed successfully</p>
      )}
    </div>
  );
}

Теперь напишем тест для компонента, который показывает, как возникает нестабильность:

test("Newsletter prompt renders and can be subscribed to", () => {
  render(<NewsletterPrompt />);
  const emailInput = screen.getByPlaceholderText("Email address...");
  const subscribeButton = screen.getByText("Subscribe");

  fireEvent.change(emailInput, { target: { value: "xyz@example.com" } });
  fireEvent.click(subscribeButton);

  expect(screen.getByText("Subscribed successfully")).toBeInTheDocument();
});

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

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

Фикс для этого теста выглядит следующим образом:

test("Newsletter prompt renders and can be subscribed to", async () => {
  render(<NewsletterPrompt />);
  const emailInput = screen.getByPlaceholderText("Email address...");
  const subscribeButton = screen.getByText("Subscribe");

  fireEvent.change(emailInput, { target: { value: "xyz@example.com" } });
  fireEvent.click(subscribeButton);

  expect(
    await screen.findByText("Subscribed successfully"),
  ).toBeInTheDocument();
});

В этом исправлении мы использовали async/await и метод screen.findByText, чтобы ожидать появления в DOM элемента с текстовым содержимым, который подтвердит, что компонент действительно перерендерился с сообщением «Subscribed successfully».

Анимированная кнопка

Допустим, у вас есть кнопка, которая при нажатии анимирует свой цвет с оранжевого на синий, а при повторном нажатии возвращается к оранжевому.

export default function AnimatedButton() {
  const [isAnimated, setIsAnimated] = useState(false);

  const handleClick = () => {
    setIsAnimated(!isAnimated);
  };

  const buttonStyle = {
    width: "100px",
    height: "50px",
    backgroundColor: isAnimated ? "blue" : "orange",
    transition: "backgroundColor 0.5s ease",
  };

  return (
    <button style={buttonStyle} onClick={handleClick}>
      Animate
    </button>
  );
}

Вот распространенный, но неправильный способ написать тест изменения цвета кнопки, так как он может вести себя нестабильно:

import { render, fireEvent } from "@testing-library/react";
import AnimatedButton from "./AnimatedButton";

test("background color changes", () => {
  render(<AnimatedButton />);
  const button = screen.getByRole("button", { name: "Animate" });

  fireEvent.click(button);
  expect(button).toHaveStyle({ backgroundColor: "blue" });

  fireEvent.click(button);
  expect(button).toHaveStyle({ backgroundColor: "orange" });
});

Этот тест выглядит очень простым: отрисовывает кнопку, нажимает на нее, а затем подтверждает ожидаемое изменение цвета. Однако здесь есть слабое место, в котором и проявляется flakiness.

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

Правильнее было бы поступить следующим образом:

test("background color changes", async () => {
  render(<AnimatedButton />);
  const button = screen.getByRole("button", { name: "Animate" });

  fireEvent.click(button);
  await waitFor(() => expect(button).toHaveStyle({ backgroundColor: "blue" }));

  fireEvent.click(button);
  await waitFor(() =>
    expect(button).toHaveStyle({ backgroundColor: "orange" }),
  );
});

Благодаря такому простому изменению, как использование waitFor, мы можем быть уверены, что тест позволит завершить анимацию, прежде чем проверять ассертом цвет фона кнопки. Это гарантирует, что ассерт в тесте будет синхронизирован с обновлением рендеринга UI.

Рендеринг базового модала

В этом примере мы используем простой модальный компонент, который при нажатии на кнопку изменяет видимость модального окна:

function AnimatedModal() {
  const [isVisible, setIsVisible] = useState(false);
  const toggleVisibility = () => setIsVisible(!isVisible);

  return (
    <div>
      <button onClick={toggleVisibility}>Toggle Modal</button>
      {isVisible && <div data-testid="animated-modal">Animated Modal</div>}
    </div>
  );
}

Как бы ни был прост этот компонент, проверка правильности его рендеринга может стать непростой задачей из-за анимации.

Неправильный способ написать тест:

test("Toggling animated modal", () => {
  render(<AnimatedModal />);
  const toggleButton = screen.getByRole("button", { name: "Toggle Modal" });

  expect(screen.queryByTestId("animated-modal")).not.toBeInTheDocument();
  fireEvent.click(toggleButton);
  expect(screen.getByTestId("animated-modal")).toBeInTheDocument();
});

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

Правильный способ:

test("Toggling animated modal", async () => {
  render(<AnimatedModal />);
  const toggleButton = screen.getByRole("button", { name: "Toggle Modal" });
  expect(screen.queryByTestId("animated-modal")).not.toBeInTheDocument();

  fireEvent.click(toggleButton);
  await waitFor(() => {
    expect(screen.queryByTestId("animated-modal")).toBeVisible();
  });

  fireEvent.click(toggleButton);
  await waitFor(() => {
    expect(screen.queryByTestId("animated-modal")).not.toBeInTheDocument();
  });
});

Здесь мы использовали waitFor для проверки видимости наличия или отсутствия модала только после завершения анимации. Это дает уверенность в том, что тест будет более надежен.

Стратегии уменьшения нестабильности

Если вы пишете тесты только для рендеринга и анимации UI, вот некоторые вещи, на которые следовало бы обратить внимание:

  • Всегда проверяйте написанную вами логику теста, поскольку это одна из распространенных ошибок, о которых говорилось выше.
  • Учитывайте задержки или нестабильность подключения, и для их устранения мокируйте сетевые функции.
  • При тестировании рендеринга UI избегайте использования селекторов DOM типа .querySelector() для получения элементов, вместо этого используйте запросы, предоставляемые библиотекой тестирования, например getBy, queryBy или findBy.
  • Убедитесь, что версия используемой библиотеки анимации совместима с версией используемого технологического стека.
  • Проводите тесты рендеринга или анимации небольшими блоками, то есть старайтесь тестировать только один компонент за раз. Это более эффективно и легче отладка.
  • Грамотно используйте матчеры утверждений, например, при тестировании анимации toBeVisible() подходит лучше, чем toBeInTheDocument().
  • Используйте waitFor для утверждений, а не для запуска событий.
  • Можете использовать jest.useFakeTimers() как альтернативу waitFor для лучшего контроля тестов.
  • Некоторые разработчики вообще пропускают или отключают анимацию при тестировании, хотя это и не рекомендуется — но это тоже вариант.

Semaphore

Тестирование рендеринга и анимации UI

Какой была ваша первая зарплата в QA и как вы искали первую работу?

Мега обсуждение в нашем телеграм-канале о поиске первой работы. Обмен опытом и мнения.

Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии

Мы в Telegram

Наш официальный канал
Полезные материалы и тесты
Готовимся к собеседованию
Project- и Product-менеджмент

? Популярное

? Telegram-обсуждения

Наши подписчики обсуждают, как искали первую работу в QA. Некоторые ищут ее прямо сейчас.
Наши подписчики рассказывают о том, как не бояться задавать тупые вопросы и чувствовать себя уверенно в новой команде.
Обсуждаем, куда лучше податься - в менеджмент или по технической ветке?
Говорим о конфликтных ситуациях в команде и о том, как их избежать
$1100*
медианная зарплата в QA в июне 2023

*по результатам опроса QA-инженеров в нашем телеграм-канале

Собеседование

19%*
IT-специалистов переехало или приняло решение о переезде из России по состоянию на конец марта 2022

*по результатам опроса в нашем телеграм-канале

live

Обсуждают сейчас