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

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

Ключевая задача — превратить недетерминированное поведение (тайминги, кадры, easing-функции) в управляемый поток состояний, который можно проверять шаг за шагом.

Для этого анимации рассматриваются как функция перехода состояния DOM:

  • начальное состояние элемента
  • промежуточные изменения стилей
  • финальное состояние после завершения
  • побочные эффекты (callbacks, promises)

Velocity.js изменяет свойства напрямую через style, поэтому тестирование опирается на проверку DOM-узлов.


Настройка тестовой среды

На практике используется сочетание следующих инструментов:

  • Jest или Mocha как тестовый раннер
  • jsdom как DOM-эмуляция
  • fake timers для управления временем
  • requestAnimationFrame mock (при необходимости)

Базовая настройка Jest:

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

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

Важно учитывать, что Velocity.js использует requestAnimationFrame, поэтому иногда требуется дополнительная стабилизация:

global.requestAnimationFrame = (cb) => setTimeout(cb, 16);
global.cancelAnimationFrame = (id) => clearTimeout(id);

Проверка мгновенного состояния DOM

Unit-тесты часто проверяют начальное состояние перед запуском анимации. Это позволяет убедиться, что библиотека корректно применяет стартовые стили.

import Velocity from 'velocity-animate';

test('initial state before animation', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

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

  expect(el.style.opacity).toBe('0');
});

Velocity.js по умолчанию устанавливает начальные значения в зависимости от текущего состояния DOM, поэтому тесты должны учитывать computed styles:

window.getComputedStyle = (el) => el.style;

Управление временем через fake timers

Основная техника unit-тестирования анимаций — искусственное продвижение времени.

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

test('animation completes after duration', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 1000 });

  jest.advanceTimersByTime(1000);

  expect(el.style.opacity).toBe('0');
});

При необходимости проверяется промежуточное состояние:

jest.advanceTimersByTime(500);

В этом случае значение opacity должно находиться между начальным и конечным, но точное значение зависит от easing-функции.


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

Easing влияет на распределение значений во времени. Unit-тесты обычно не проверяют точные дробные значения, но проверяют монотонность изменения.

test('opacity decreases monotonically', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 1000, easing: 'linear' });

  const values = [];

  for (let t = 0; t <= 1000; t += 250) {
    jest.advanceTimersByTime(250);
    values.push(parseFloat(el.style.opacity));
  }

  for (let i = 1; i < values.length; i++) {
    expect(values[i]).toBeLessThanOrEqual(values[i - 1]);
  }
});

Проверка завершения анимации через Promise и callbacks

Velocity.js поддерживает завершение через callback и Promise-like интерфейс.

Callback вариант

test('callback is called on finish', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  const done = jest.fn();

  Velocity(el, { opacity: 0 }, {
    duration: 500,
    complete: done
  });

  jest.advanceTimersByTime(500);

  expect(done).toHaveBeenCalledTimes(1);
});

Promise вариант

test('promise resolves after animation', async () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  const p = Velocity(el, { opacity: 0 }, { duration: 500 });

  jest.advanceTimersByTime(500);

  await expect(p).resolves.toBe(el);
});

Последовательные анимации и цепочки

Velocity.js поддерживает цепочки анимаций, которые выполняются последовательно.

test('sequential animations', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 300 });
  Velocity(el, { translateX: 100 }, { duration: 300 });

  jest.advanceTimersByTime(600);

  expect(el.style.transform).toContain('translateX');
});

При тестировании цепочек важно учитывать очередь (queue). Velocity по умолчанию использует queue “fx”, поэтому параллельные и последовательные анимации могут конфликтовать.

Отключение очереди:

Velocity(el, { opacity: 0 }, { duration: 300, queue: false });

Проверка transform-значений

Transform представляет собой строку, что усложняет сравнение.

test('transform applied correctly', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { translateX: 100 }, { duration: 0 });

  expect(el.style.transform).toContain('translateX(100px)');
});

При сложных трансформациях используется нормализация строки:

const normalizeTransform = (str) =>
  str.replace(/\s/g, '').toLowerCase();

Моки requestAnimationFrame и стабильность кадров

Некоторые тестовые среды требуют явного контроля requestAnimationFrame.

let rafCallbacks = [];

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

global.runFrames = () => {
  const callbacks = [...rafCallbacks];
  rafCallbacks = [];
  callbacks.forEach(cb => cb());
};

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

test('frame-based animation progression', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 1000 });

  runFrames();
  runFrames();

  expect(parseFloat(el.style.opacity)).toBeLessThan(1);
});

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

Velocity.js позволяет останавливать анимацию с помощью stop.

test('animation can be stopped', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 1000 });

  jest.advanceTimersByTime(300);

  Velocity(el, 'stop');

  const valueAtStop = el.style.opacity;

  jest.advanceTimersByTime(1000);

  expect(el.style.opacity).toBe(valueAtStop);
});

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

Анимации часто меняют не только стили, но и состояние UI: классы, visibility, display.

test('display change after animation', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, {
    duration: 300,
    complete: () => {
      el.style.display = 'none';
    }
  });

  jest.advanceTimersByTime(300);

  expect(el.style.display).toBe('none');
});

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

Повторный запуск анимации на одном элементе может перезаписывать state.

test('restarting animation resets state', () => {
  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 500 });
  Velocity(el, { opacity: 1 }, { duration: 500 });

  jest.advanceTimersByTime(500);

  expect(parseFloat(el.style.opacity)).toBeGreaterThan(0.5);
});

Тестирование групповых анимаций

Velocity поддерживает списки элементов.

test('multiple elements animation', () => {
  document.body.innerHTML = `
    <div class="item"></div>
    <div class="item"></div>
  `;

  const items = document.querySelectorAll('.item');

  Velocity(items, { opacity: 0 }, { duration: 400 });

  jest.advanceTimersByTime(400);

  items.forEach(el => {
    expect(parseFloat(el.style.opacity)).toBe(0);
  });
});

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

Хотя unit-тесты не являются инструментом профилирования, можно выявить чрезмерные циклы обновления:

test('no excessive frame updates', () => {
  let frameCount = 0;

  global.requestAnimationFrame = (cb) => {
    frameCount++;
    cb();
  };

  document.body.innerHTML = `<div id="box"></div>`;
  const el = document.getElementById('box');

  Velocity(el, { opacity: 0 }, { duration: 200 });

  expect(frameCount).toBeLessThan(20);
});

Проверка корректности начальных вычислений Velocity

Velocity.js может вычислять стартовые значения через computed styles. В тестовой среде это часто мокается.

window.getComputedStyle = (el) => ({
  opacity: el.style.opacity || '1'
});

Это позволяет избежать зависимости от реального layout-движка браузера и делает тесты воспроизводимыми.