Unit-тестирование анимационной логики в Popmotion требует подхода, отличающегося от классического тестирования чистых функций. Причина в том, что анимации завязаны на время, интерполяцию, побочные эффекты и асинхронные потоки значений. Основная сложность заключается не в проверке результата как такового, а в контролируемом воспроизведении временных изменений состояния.
Popmotion строится вокруг концепции потоков значений и временных
трансформаций. Базовые сущности — это tween,
spring, keyframes, physics, а
также низкоуровневые action-потоки.
Каждая анимация в Popmotion фактически является функцией времени:
Тестирование таких систем требует изоляции временного фактора и детерминированного воспроизведения поведения.
Ключевая проблема:
requestAnimationFrame недоступен или непредсказуем в
среде тестированияОсновной принцип тестирования Popmotion — полная замена временного слоя.
Вместо реального времени используются:
performance.nowjest.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 является наиболее предсказуемой формой анимации,
так как использует линейную или заданную 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-функции в Popmotion являются чистыми математическими преобразованиями и идеально подходят для unit-тестов.
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 не является линейным процессом. Он стремится к состоянию равновесия, поэтому тестирование должно учитывать:
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 представляет собой последовательность
фиксированных точек с интерполяцией между ними.
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);
});
Ключевой аспект — проверка интервалов между кадрами:
При этом важно учитывать, что Popmotion распределяет время равномерно, если не задана кастомная логика.
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]);
});
Popmotion часто опирается на requestAnimationFrame,
поэтому тестовая среда должна его имитировать.
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);
});
В реальных сценариях несколько анимаций могут конкурировать за одно состояние.
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);
});
Наиболее уязвимые места анимационных систем:
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 должен быть изолирован:
jsdomelement.styletest('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);
});
Анимации часто зависят от:
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);
});