Тестирование анимаций

Особенности тестирования анимаций в браузере

Анимации в JavaScript создают дополнительный слой сложности для тестирования, поскольку их поведение зависит от времени, кадрового цикла и браузерного движка. Библиотека Motion One работает поверх Web Animations API и requestAnimationFrame, что делает её поведение чувствительным к:

  • асинхронному выполнению кадров
  • таймерам
  • особенностям реализации браузера
  • оптимизациям композитинга

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

Архитектура Motion One с точки зрения тестирования

Motion One предоставляет несколько ключевых сущностей:

  • animate() — базовый запуск анимации
  • timeline() — последовательности анимаций
  • motion() — декларативные анимации (в React/DOM-обвязках)
  • controls API — управление (play, pause, stop, finish)

С точки зрения тестов важно, что каждая анимация возвращает контроллер с состоянием:

  • playState
  • finished
  • currentTime
  • cancel()
  • finish()

Эти свойства позволяют переводить анимацию в детерминированные состояния без ожидания реального времени.

Базовая стратегия тестирования

Основной подход строится вокруг трёх уровней:

  1. Unit-тестирование логики запуска анимации
  2. Интеграционное тестирование DOM-изменений
  3. E2E тестирование поведения интерфейса

Ключевая идея — устранение реального времени через фейковые таймеры и ручное управление кадрами.


Управление временем в тестах

Использование fake timers

При тестировании Motion One часто применяется подмена таймеров:

  • Jest fake timers
  • Vitest fake timers
import { animate } from "motion";

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
});

Однако Motion One в большей степени зависит не от setTimeout, а от requestAnimationFrame, поэтому одних таймеров недостаточно.


Подмена requestAnimationFrame

Полный контроль анимационного цикла

Для детерминированных тестов требуется управление кадрами вручную:

function createRafMock() {
  let time = 0;
  const callbacks = new Set();

  global.requestAnimationFrame = (cb) => {
    callbacks.add(cb);
    return 1;
  };

  global.cancelAnimationFrame = (id) => {
    callbacks.clear();
  };

  return {
    step(ms) {
      time += ms;
      callbacks.forEach((cb) => cb(time));
    }
  };
}

Такой подход позволяет:

  • шагать по анимации постфактум
  • фиксировать состояния на конкретных миллисекундах
  • избегать флаки-тестов

Тестирование animate()

Проверка конечного состояния элемента

Основная цель — убедиться, что итоговое состояние DOM соответствует ожиданиям.

import { animate } from "motion";

test("animate изменяет opacity до 1", () => {
  const raf = createRafMock();

  const el = document.createElement("div");
  document.body.appendChild(el);

  animate(el, { opacity: 1 }, { duration: 1000 });

  raf.step(1000);

  expect(el.style.opacity).toBe("1");
});

Ключевой момент — проверка не промежуточных кадров, а конечного состояния.


Проверка промежуточных кадров

Иногда требуется контроль интерполяции.

test("анимация изменяет значение по пути", () => {
  const raf = createRafMock();

  const el = document.createElement("div");
  document.body.appendChild(el);

  animate(el, { x: 100 }, { duration: 1000 });

  raf.step(500);

  const transform = getComputedStyle(el).transform;

  expect(transform).not.toBe("none");
});

В таких случаях важно учитывать:

  • браузер может возвращать матрицы трансформаций
  • значения могут округляться
  • часть свойств вычисляется через compositor

Контроль завершения анимации

Использование промиса finished

Motion One возвращает объект с Promise:

test("анимация завершается", async () => {
  const raf = createRafMock();

  const el = document.createElement("div");
  document.body.appendChild(el);

  const animation = animate(el, { opacity: 0 }, { duration: 300 });

  raf.step(300);

  await animation.finished;

  expect(animation.playState).toBe("finished");
});

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


Тестирование timeline()

Проверка последовательностей

Timeline объединяет несколько анимаций с временными оффсетами.

import { timeline } from "motion";

test("timeline выполняет последовательность", () => {
  const raf = createRafMock();

  const el = document.createElement("div");
  document.body.appendChild(el);

  const sequence = timeline([
    [el, { x: 100 }, { duration: 200 }],
    [el, { y: 100 }, { duration: 200, at: "+0" }]
  ]);

  raf.step(200);
  raf.step(200);

  expect(sequence.playState).toBe("finished");
});

Ключевая сложность — корректная эмуляция временной шкалы, особенно при использовании at, offset, delay.


Моки Web Animations API

Подмена animate()

В средах без полноценного WAAPI требуется полифилл:

global.Element.prototype.animate = function () {
  return {
    finished: Promise.resolve(),
    cancel: () => {},
    play: () => {},
    pause: () => {},
    currentTime: 0
  };
};

Такая заглушка используется только для логических тестов, не затрагивающих реальную анимацию.


Проверка стилей и трансформаций

Особенности transform

Motion One часто работает с transform-матрицами:

  • translateX → matrix
  • scale → matrix
  • rotate → matrix

Проверка через getComputedStyle:

test("transform применяется корректно", () => {
  const el = document.createElement("div");
  document.body.appendChild(el);

  animate(el, { x: 50 });

  const style = getComputedStyle(el).transform;

  expect(style).toContain("matrix");
});

Для более точных проверок часто требуется нормализация матриц или использование вспомогательных утилит.


Тестирование событий анимации

Motion One предоставляет события жизненного цикла:

  • onComplete
  • onUpdate
  • onPlay
  • onCancel
test("onComplete вызывается", () => {
  const raf = createRafMock();

  let called = false;

  animate(
    document.createElement("div"),
    { opacity: 0 },
    {
      duration: 100,
      onComplete: () => {
        called = true;
      }
    }
  );

  raf.step(100);

  expect(called).toBe(true);
});

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


Тестирование прерывания анимации

cancel()

test("анимация отменяется", () => {
  const raf = createRafMock();

  const el = document.createElement("div");

  const animation = animate(el, { opacity: 0 }, { duration: 1000 });

  raf.step(200);

  animation.cancel();

  expect(animation.playState).toBe("idle");
});

Критично проверять:

  • сброс стилей
  • остановку RAF-цикла
  • отсутствие дальнейших обновлений DOM

Проверка производительности

Базовые метрики

В тестах производительности анимаций анализируются:

  • количество кадров
  • частота обновлений
  • количество layout recalculation

Пример упрощённого подсчёта вызовов:

let frames = 0;

global.requestAnimationFrame = (cb) => {
  frames++;
  cb(performance.now());
};

animate(document.createElement("div"), { x: 100 }, { duration: 1000 });

expect(frames).toBeLessThan(100);

Тестирование в E2E среде

Playwright

E2E тесты позволяют проверять реальное поведение в браузере:

test("анимация завершает переход", async ({ page }) => {
  await page.goto("/");

  await page.click("#start");

  await page.waitForSelector(".finished");

  const opacity = await page.$eval("#box", el =>
    getComputedStyle(el).opacity
  );

  expect(opacity).toBe("0");
});

В отличие от unit-тестов, здесь важна интеграция с реальным движком браузера.


Флэк-тесты и их устранение

Основные источники нестабильности:

  • реальный RAF
  • различия в FPS
  • неопределённость вычислений transform
  • задержки промисов

Стратегии устранения:

  • полная подмена времени
  • фиксация FPS
  • отказ от промежуточных проверок DOM
  • проверка только конечных состояний

Тестирование с reduced motion

Motion One учитывает prefers-reduced-motion.

test("учёт reduced motion", () => {
  window.matchMedia = () => ({
    matches: true,
    addEventListener: () => {}
  });

  const animation = animate(
    document.createElement("div"),
    { opacity: 1 }
  );

  expect(animation.duration).toBe(0);
});

Проверяется:

  • отключение анимаций
  • мгновенное завершение
  • отсутствие промежуточных кадров

Структурирование тестового покрытия

Полноценное покрытие анимаций включает:

  • логика запуска (animate, timeline)
  • жизненный цикл (playState, finished)
  • DOM эффекты
  • отмена и пауза
  • производительность
  • адаптация к системным настройкам

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