Тестирование D3-компонентов: jsdom и d3-selection

Библиотека D3.js тесно связана с DOM: большинство её операций опираются на выборки, атрибуты и манипуляции SVG/HTML-узлами. Это создаёт ключевую проблему при модульном тестировании — необходимость воспроизводимого DOM-окружения вне браузера.

В серверной среде отсутствует полноценный DOM, поэтому используется jsdom, которая эмулирует браузерную модель документа. В связке с d3-selection это позволяет тестировать поведение компонентов без реального рендеринга.

Основная цель тестирования D3-компонентов заключается не в проверке визуального результата, а в валидации:

  • структуры DOM-дерева после выполнения кода;
  • корректности привязки данных (data join);
  • изменений атрибутов и стилей;
  • реакций на входные данные и обновления состояния.

Базовая конфигурация jsdom для D3-тестов

Инициализация виртуального документа требует создания глобальных объектов window и document, которые ожидаются D3.

import { JSDOM } from "jsdom";

const dom = new JSDOM(`<!DOCTYPE html><body></body>`);
global.window = dom.window;
global.document = dom.window.document;

После этого d3-selection начинает работать так же, как в браузере, поскольку получает доступ к DOM API через document.

Важный момент: многие методы D3 используют document.createElementNS, особенно при работе с SVG. jsdom поддерживает SVG namespace, но иногда требует явного использования createElementNS.


Тестирование базовых выборок d3-selection

d3-selection предоставляет механизм связывания данных с DOM через select, selectAll, append, attr, text.

Пример теста создания элемента:

import * as d3 from "d3";

document.body.innerHTML = "<div id='root'></div>";

d3.select("#root")
  .append("p")
  .text("Hello D3");

const result = document.querySelector("p").textContent;

expect(result).toBe("Hello D3");

Здесь проверяется не визуальный результат, а факт изменения DOM-структуры.


Проверка data join (ключевой механизм D3)

Механизм связывания данных с элементами — основа D3. Он тестируется через проверку количества созданных DOM-узлов и их соответствия входному массиву.

const data = [10, 20, 30];

d3.select("#root")
  .selectAll("div")
  .data(data)
  .enter()
  .append("div")
  .text(d => d);

Проверка:

const nodes = document.querySelectorAll("div");
expect(nodes.length).toBe(3);

Ключевой аспект: проверяется не только количество элементов, но и корректность привязки данных через __data__.

expect(nodes[0].__data__).toBe(10);

Это внутренний механизм D3, который сохраняет данные прямо в DOM-узле.


Тестирование обновлений (update pattern)

D3 активно использует паттерн enter–update–exit. Проверка этого поведения особенно важна при изменении данных.

Пример обновления:

let selection = d3.select("#root")
  .selectAll("p")
  .data([1, 2, 3]);

selection.enter()
  .append("p")
  .text(d => d);

selection = d3.select("#root")
  .selectAll("p")
  .data([2, 3, 4]);

selection.text(d => d);

Проверка:

const texts = [...document.querySelectorAll("p")].map(el => el.textContent);

expect(texts).toEqual(["2", "3", "4"]);

При тестировании обновлений важно учитывать:

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

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

Ключевые функции (key function) критичны для стабильных обновлений DOM.

const data = [
  { id: "a", value: 10 },
  { id: "b", value: 20 }
];

d3.select("#root")
  .selectAll("div")
  .data(data, d => d.id)
  .enter()
  .append("div")
  .text(d => d.value);

Тестирование стабильности:

const firstNodes = document.querySelectorAll("div");

d3.select("#root")
  .selectAll("div")
  .data([{ id: "b", value: 200 }], d => d.id)
  .text(d => d.value);

const updatedNodes = document.querySelectorAll("div");

expect(updatedNodes.length).toBe(1);
expect(updatedNodes[0].textContent).toBe("200");

Ключ гарантирует, что DOM-узлы не пересоздаются без необходимости.


Тестирование SVG-элементов в jsdom

SVG требует namespace:

const svg = d3.select("#root")
  .append("svg")
  .attr("width", 100)
  .attr("height", 100);

svg.append("circle")
  .attr("cx", 50)
  .attr("cy", 50)
  .attr("r", 10);

Проверка:

const circle = document.querySelector("circle");

expect(circle.getAttribute("cx")).toBe("50");
expect(circle.getAttribute("r")).toBe("10");

jsdom корректно хранит SVG-узлы, но не выполняет рендеринг, поэтому тестируется только структура.


Тестирование атрибутов и стилей

d3-selection активно управляет атрибутами и стилями DOM-элементов.

d3.select("#root")
  .append("div")
  .style("color", "red")
  .attr("data-type", "test");

Проверка:

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

expect(el.style.color).toBe("red");
expect(el.getAttribute("data-type")).toBe("test");

Важно учитывать, что style() в D3 изменяет inline styles, а не CSS классы.


Проверка событий и обработчиков

D3 поддерживает привязку событий через .on().

let clicked = false;

d3.select("#root")
  .append("button")
  .text("Click")
  .on("click", () => {
    clicked = true;
  });

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

const button = document.querySelector("button");
button.dispatchEvent(new window.Event("click"));

expect(clicked).toBe(true);

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


Ограничения jsdom при тестировании D3

Несмотря на функциональность, jsdom не реализует полноценный браузерный рендеринг:

  • отсутствует layout engine (нет вычисления размеров элементов);
  • нет реального SVG rendering pipeline;
  • нет поддержки Canvas API (без дополнительных моков);
  • нет визуальной отрисовки transitions.

Это влияет на тестирование:

  • getBBox() для SVG часто недоступен;
  • getComputedTextLength() может отсутствовать;
  • transition-анимации не выполняются.

Поэтому тесты ограничиваются проверкой DOM-состояния после синхронного выполнения D3-кода.


Тестирование transitions и асинхронного поведения

D3 transitions используют таймеры и requestAnimationFrame. В jsdom это поведение нужно эмулировать через фейковые таймеры.

jest.useFakeTimers();

d3.select("#root")
  .append("div")
  .transition()
  .duration(1000)
  .style("opacity", 0);

Продвижение времени:

jest.advanceTimersByTime(1000);

Проверка:

const el = document.querySelector("div");
expect(el.style.opacity).toBe("0");

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


Стратегия тестирования компонентов на D3

Компоненты, построенные на D3, обычно тестируются по слоям:

  1. Слой данных

    • проверка входных преобразований;
    • подготовка структуры данных.
  2. DOM слой

    • проверка создания элементов;
    • проверка data binding.
  3. Поведенческий слой

    • события;
    • обновления;
    • transitions (частично).
  4. Интеграционный слой

    • проверка взаимодействия нескольких selection-цепочек.

Паттерн тестируемого D3-компонента

Типичный компонент можно структурировать так, чтобы его тестирование не зависело от глобального состояния:

export function chart(selection, data) {
  const root = selection.append("g");

  root.selectAll("rect")
    .data(data)
    .enter()
    .append("rect")
    .attr("width", d => d)
    .attr("height", 10);
}

Тест:

const root = d3.select(document.body).append("svg");

chart(root, [10, 20]);

expect(document.querySelectorAll("rect").length).toBe(2);

Такой подход отделяет визуальную логику от окружения, делая её воспроизводимой в jsdom.


Проверка устойчивости selection-цепочек

d3-selection строится на цепочках вызовов. Ошибки часто возникают при неправильном порядке операций.

const sel = d3.select("#root")
  .append("div")
  .selectAll("span")
  .data([1, 2, 3])
  .enter()
  .append("span")
  .text(d => d);

Тестирование здесь сводится к проверке конечного DOM:

expect(document.querySelectorAll("span").length).toBe(3);

При этом важно учитывать, что промежуточные selection-объекты не всегда имеют смысл тестировать напрямую — они служат только для построения цепочки.


Особенности мокирования глобальных API

Некоторые D3-модули могут использовать:

  • performance.now
  • requestAnimationFrame
  • window.getComputedStyle

В jsdom их часто требуется замещать:

global.requestAnimationFrame = (cb) => setTimeout(cb, 0);

или

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

Без таких замен тесты transitions и таймингов становятся нестабильными.


Организация тестовой среды

Для стабильности тестирования D3-компонентов используется изоляция:

  • отдельный DOM для каждого теста;
  • сброс document.body.innerHTML;
  • переинициализация jsdom при необходимости;
  • отсутствие зависимости от глобального состояния selection.

Типичная схема:

beforeEach(() => {
  document.body.innerHTML = "<div id='root'></div>";
});

Проверка корректности удаления элементов (exit selection)

Exit-паттерн важен для динамических визуализаций.

let data = [1, 2, 3];

let sel = d3.select("#root")
  .selectAll("p")
  .data(data);

sel.enter().append("p").text(d => d);

data = [1];

sel = d3.select("#root")
  .selectAll("p")
  .data(data);

sel.exit().remove();

Тест:

expect(document.querySelectorAll("p").length).toBe(1);

Поведение d3-selection при повторных вызовах

Каждый вызов selectAll создаёт новую selection-обёртку, не мутируя DOM напрямую.

const s1 = d3.select("#root").selectAll("div");
const s2 = d3.select("#root").selectAll("div");

Обе переменные независимы, и тестирование должно опираться на DOM, а не на ссылки selection-объектов.


Проверка текстовых функций и callback-логики

D3 активно использует функции обратного вызова.

d3.select("#root")
  .selectAll("p")
  .data([1, 2, 3])
  .enter()
  .append("p")
  .text((d, i) => `${i}:${d}`);

Тест:

const texts = [...document.querySelectorAll("p")].map(d => d.textContent);

expect(texts).toEqual(["0:1", "1:2", "2:3"]);

Проверяется корректность индексов и передаваемых значений.


Проверка консистентности состояния DOM после нескольких циклов обновления

Многократные обновления данных часто выявляют скрытые ошибки.

function update(data) {
  const sel = d3.select("#root")
    .selectAll("div")
    .data(data);

  sel.enter().append("div");
  sel.exit().remove();
}

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

update([1, 2]);
update([1, 2, 3]);
update([1]);

expect(document.querySelectorAll("div").length).toBe(1);

Такие тесты фиксируют корректность работы join-механизма.