Unit тестирование

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

Popmotion строится вокруг концепции потоков значений и временных трансформаций. Базовые сущности — это tween, spring, keyframes, physics, а также низкоуровневые action-потоки.

Каждая анимация в Popmotion фактически является функцией времени:

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

Тестирование таких систем требует изоляции временного фактора и детерминированного воспроизведения поведения.

Ключевая проблема:

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

Изоляция времени и контроль анимационного цикла

Основной принцип тестирования Popmotion — полная замена временного слоя.

Вместо реального времени используются:

  • mock performance.now
  • fake timers (jest.useFakeTimers, vitest.useFakeTimers)
  • ручное продвижение времени

Пример базовой стабилизации времени

import { tween } from 'popmotion';

jest.useFakeTimers();

let currentTime = 0;

global.performance = {
  now: () => currentTime
};

function advance(ms) {
  currentTime += ms;
  jest.advanceTimersByTime(ms);
}

Такая схема позволяет синхронизировать внутренний тайминг Popmotion с тестовой средой.

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

tween является наиболее предсказуемой формой анимации, так как использует линейную или заданную easing-функцию.

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

import { tween } from 'popmotion';

test('tween interpolates values correctly', () => {
  const values = [];

  const animation = tween({
    from: 0,
    to: 100,
    duration: 1000
  }).start(v => values.push(v));

  jest.advanceTimersByTime(500);
  jest.advanceTimersByTime(500);

  expect(values[0]).toBeGreaterThan(0);
  expect(values[values.length - 1]).toBe(100);
});

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

Детерминированное тестирование easing-функций

Easing-функции в Popmotion являются чистыми математическими преобразованиями и идеально подходят для unit-тестов.

Пример проверки easing

import { easeInOut } from 'popmotion';

test('easeInOut returns expected boundary values', () => {
  expect(easeInOut(0)).toBe(0);
  expect(easeInOut(1)).toBe(1);
});

Проверка нелинейного поведения

test('easeInOut midpoint behavior', () => {
  const mid = easeInOut(0.5);

  expect(mid).toBeGreaterThan(0.3);
  expect(mid).toBeLessThan(0.7);
});

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

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

Spring-модель значительно сложнее, так как включает физическую симуляцию:

  • масса
  • жесткость
  • демпфирование
  • скорость

Особенности тестирования spring

Spring не является линейным процессом. Он стремится к состоянию равновесия, поэтому тестирование должно учитывать:

  • достижение цели
  • затухание колебаний
  • стабильность конечного значения

Пример базовой проверки

import { spring } from 'popmotion';

test('spring eventually stabilizes at target', () => {
  const values = [];

  spring({
    from: 0,
    to: 100,
    stiffness: 100,
    damping: 10
  }).start(v => values.push(v));

  jest.advanceTimersByTime(2000);

  const last = values[values.length - 1];

  expect(last).toBeGreaterThan(95);
  expect(last).toBeLessThanOrEqual(100);
});

Тестирование keyframes

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

Проверка переходов между ключевыми кадрами

import { keyframes } from 'popmotion';

test('keyframes respect sequence order', () => {
  const values = [];

  keyframes({
    values: [0, 50, 100],
    duration: 900
  }).start(v => values.push(v));

  jest.advanceTimersByTime(900);

  expect(values[0]).toBe(0);
  expect(values[values.length - 1]).toBe(100);
});

Контроль временных сегментов

Ключевой аспект — проверка интервалов между кадрами:

  • 0–50%
  • 50–100%

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

Тестирование Action streams

Actions в Popmotion представляют собой поток значений, который может включать:

  • события мыши
  • анимационные тики
  • пользовательские потоки

Моделирование потоков

import { action } from 'popmotion';

test('action emits custom sequence', () => {
  const output = [];

  const stream = action(({ update }) => {
    update(1);
    update(2);
    update(3);
  });

  stream.start(v => output.push(v));

  expect(output).toEqual([1, 2, 3]);
});

Проверка отмены потока

test('action can be stopped', () => {
  const output = [];

  const stream = action(({ update, complete }) => {
    update(1);
    complete();
    update(2);
  });

  stream.start(v => output.push(v));

  expect(output).toEqual([1]);
});

Работа с requestAnimationFrame

Popmotion часто опирается на requestAnimationFrame, поэтому тестовая среда должна его имитировать.

Mock RAF

let rafCallbacks = [];

global.requestAnimationFrame = (cb) => {
  rafCallbacks.push(cb);
};

function flushRAF() {
  const cbs = [...rafCallbacks];
  rafCallbacks = [];
  cbs.forEach(cb => cb(performance.now()));
}

Использование в тестах

test('raf-driven animation progresses', () => {
  const values = [];

  tween({
    from: 0,
    to: 10,
    duration: 1000
  }).start(v => values.push(v));

  flushRAF();
  flushRAF();

  expect(values.length).toBeGreaterThan(0);
});

Проверка прерывания и остановки анимаций

Popmotion предоставляет возможность остановки анимаций через stop().

Проверка корректного завершения

test('animation stops when requested', () => {
  const values = [];

  const animation = tween({
    from: 0,
    to: 1000,
    duration: 5000
  }).start(v => values.push(v));

  jest.advanceTimersByTime(1000);
  animation.stop();

  const snapshot = values.length;

  jest.advanceTimersByTime(4000);

  expect(values.length).toBe(snapshot);
});

Тестирование взаимодействия нескольких анимаций

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

Пример параллельных tween

test('parallel tweens do not interfere', () => {
  const a = [];
  const b = [];

  tween({ from: 0, to: 100, duration: 1000 })
    .start(v => a.push(v));

  tween({ from: 100, to: 0, duration: 1000 })
    .start(v => b.push(v));

  jest.advanceTimersByTime(1000);

  expect(a[a.length - 1]).toBe(100);
  expect(b[b.length - 1]).toBe(0);
});

Стратегия тестирования временных границ

Наиболее уязвимые места анимационных систем:

  • начало анимации (t = 0)
  • середина процесса (t = 0.5)
  • завершение (t = 1)
  • переход за пределы длительности

Проверка граничных значений

test('handles time boundaries correctly', () => {
  const values = [];

  tween({
    from: 0,
    to: 100,
    duration: 1000
  }).start(v => values.push(v));

  jest.advanceTimersByTime(0);
  jest.advanceTimersByTime(500);
  jest.advanceTimersByTime(500);
  jest.advanceTimersByTime(200);

  expect(values[values.length - 1]).toBe(100);
});

Изоляция побочных эффектов

Popmotion часто используется вместе с DOM-манипуляциями. В unit-тестах DOM должен быть изолирован:

  • использование jsdom
  • мокирование element.style
  • контроль подписок на события

Пример изоляции DOM-эффекта

test('updates style without real DOM dependency', () => {
  const element = { style: {} };

  tween({
    from: 0,
    to: 1,
    duration: 100
  }).start(v => {
    element.style.opacity = v;
  });

  jest.advanceTimersByTime(100);

  expect(element.style.opacity).toBe(1);
});

Тестирование математической стабильности

Анимации часто зависят от:

  • округления чисел
  • накопления ошибок floating point
  • частоты обновления

Защита от дрейфа значений

test('prevents floating point drift', () => {
  const values = [];

  tween({
    from: 0,
    to: 1,
    duration: 333
  }).start(v => values.push(v));

  jest.advanceTimersByTime(333);

  const last = values[values.length - 1];

  expect(Math.abs(last - 1)).toBeLessThan(0.0001);
});