Тестирование компонентов Nivo с Jest и Testing Library

Библиотека Nivo строится поверх React и активно использует SVG, Canvas, анимации, хуки и адаптивный рендеринг. Из-за этого тестирование визуализаций отличается от тестирования обычных UI-компонентов.

Основные сложности:

  • динамическая генерация DOM-узлов;
  • отсутствие полноценного layout в JSDOM;
  • использование ResizeObserver;
  • анимации через react-spring;
  • Canvas-графики, недоступные для прямой проверки DOM;
  • большое количество inline-стилей и SVG-элементов.

При тестировании Nivo-компонентов важно разделять:

  1. тестирование логики;
  2. тестирование структуры DOM;
  3. тестирование пользовательского поведения;
  4. визуальное regression-тестирование.

Установка окружения

Установка Jest и Testing Library

npm install --save-dev jest @testing-library/react @testing-library/jest-dom

Для TypeScript:

npm install --save-dev ts-jest @types/jest

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

Конфигурация jest.config.js

module.exports = {
    testEnvironment: 'jsdom',
    setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
}

Подключение jest-dom

Файл jest.setup.js

import '@testing-library/jest-dom'

Пакет добавляет удобные матчеры:

expect(element).toBeInTheDocument()
expect(element).toHaveTextContent()
expect(element).toHaveAttribute()

Простейший тест компонента Nivo

Компонент диаграммы

import { ResponsiveBar } from '@nivo/bar'

const data = [
    {
        country: 'USA',
        value: 100
    },
    {
        country: 'Germany',
        value: 80
    }
]

export function Chart() {
    return (
        <div style={{ width: 600, height: 400 }}>
            <ResponsiveBar
                data={data}
                keys={['value']}
                indexBy="country"
            />
        </div>
    )
}

Первый тест

import { render, screen } from '@testing-library/react'
import { Chart } from './Chart'

describe('Chart', () => {
    test('renders svg chart', () => {
        render(<Chart />)

        const svg = document.querySelector('svg')

        expect(svg).toBeInTheDocument()
    })
})

Проблема Responsive-компонентов

Большинство компонентов Nivo используют:

ResponsiveBar
ResponsiveLine
ResponsivePie
ResponsiveHeatMap

Они вычисляют размеры родителя через ResizeObserver.

В среде Jest этого API нет.


Ошибка ResizeObserver

Типичная ошибка:

ResizeObserver is not defined

Мок ResizeObserver

Решение через jest.setup.js

class ResizeObserver {
    observe() {}

    unobserve() {}

    disconnect() {}
}

global.ResizeObserver = ResizeObserver

После этого responsive-компоненты начинают корректно монтироваться.


Проблема нулевых размеров

JSDOM не вычисляет реальные размеры DOM-элементов.

Из-за этого:

<div style={{ width: 600, height: 400 }}>

не всегда приводит к реальной ширине и высоте.


Использование обычных компонентов вместо Responsive

Для unit-тестов удобнее применять:

Bar
Line
Pie

вместо:

ResponsiveBar
ResponsiveLine
ResponsivePie

Пример

import { Bar } from '@nivo/bar'

export function Chart() {
    return (
        <Bar
            width={600}
            height={400}
            data={[
                {
                    country: 'USA',
                    value: 100
                }
            ]}
            keys={['value']}
            indexBy="country"
        />
    )
}

Такой компонент значительно стабильнее тестируется.


Проверка SVG-элементов

Nivo генерирует SVG.

Можно проверять:

  • rect
  • path
  • circle
  • text
  • g

Проверка количества столбцов

test('renders bars', () => {
    render(<Chart />)

    const bars = document.querySelectorAll('rect')

    expect(bars.length).toBeGreaterThan(0)
})

Проверка подписей

test('renders labels', () => {
    render(<Chart />)

    expect(screen.getByText('USA')).toBeInTheDocument()
})

Проверка данных графика

Пример

const data = [
    { country: 'USA', value: 100 },
    { country: 'Germany', value: 80 },
    { country: 'France', value: 50 }
]

Тест:

test('renders all countries', () => {
    render(<Chart />)

    expect(screen.getByText('USA')).toBeInTheDocument()
    expect(screen.getByText('Germany')).toBeInTheDocument()
    expect(screen.getByText('France')).toBeInTheDocument()
})

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

Многие диаграммы Nivo отображают tooltip при hover.


Проверка hover

import { fireEvent } from '@testing-library/react'

test('shows tooltip', async () => {
    render(<Chart />)

    const bar = document.querySelector('rect')

    fireEvent.mouseEnter(bar)

    expect(await screen.findByText(/100/)).toBeInTheDocument()
})

Особенности tooltip в Nivo

Tooltip может:

  • рендериться через portal;
  • появляться асинхронно;
  • зависеть от анимации.

Поэтому желательно использовать:

findByText()

вместо:

getByText()

Отключение анимаций

Анимации часто вызывают нестабильные тесты.


Свойство animate

<Bar
    animate={false}
/>

Это особенно важно для CI-среды.


Полный пример стабильного тестируемого графика

<Bar
    width={600}
    height={400}
    data={data}
    keys={['value']}
    indexBy="country"
    animate={false}
/>

Тестирование Canvas-компонентов

Nivo поддерживает Canvas-версии:

BarCanvas
LineCanvas
ScatterPlotCanvas

Canvas сложнее тестировать, поскольку DOM-элементы отсутствуют.


Что можно проверять

Для Canvas обычно тестируют:

  • наличие canvas;
  • вызовы API;
  • пользовательские события;
  • snapshot;
  • бизнес-логику.

Проверка canvas

test('renders canvas', () => {
    render(<Chart />)

    const canvas = document.querySelector('canvas')

    expect(canvas).toBeInTheDocument()
})

Мок Canvas API

Иногда появляется ошибка:

HTMLCanvasElement.prototype.getContext

Решение

HTMLCanvasElement.prototype.getContext = jest.fn()

Snapshot-тестирование

Snapshot полезен для:

  • SVG;
  • структуры графика;
  • регрессий DOM.

Пример snapshot

import { render } from '@testing-library/react'

test('matches snapshot', () => {
    const { container } = render(<Chart />)

    expect(container).toMatchSnapshot()
})

Проблемы snapshot-тестов

Nivo может генерировать:

  • случайные id;
  • inline-style;
  • динамические transform;
  • анимированные значения.

Из-за этого snapshot может постоянно изменяться.


Как стабилизировать snapshot

Отключение анимации

animate={false}

Фиксированные размеры

width={600}
height={400}

Удаление динамических свойств

Иногда применяют кастомные сериализаторы.


Тестирование пользовательских обработчиков

Nivo поддерживает:

onClick
onMouseEnter
onMouseLeave

Проверка onClick

Компонент

<Bar
    data={data}
    keys={['value']}
    indexBy="country"
    width={600}
    height={400}
    animate={false}
    onCl ick={handleClick}
/>

Тест

test('calls click handler', () => {
    const handleClick = jest.fn()

    render(
        <Bar
            width={600}
            height={400}
            data={[
                {
                    country: 'USA',
                    value: 100
                }
            ]}
            keys={['value']}
            indexBy="country"
            animate={false}
            onCl ick={handleClick}
        />
    )

    const bar = document.querySelector('rect')

    fireEvent.click(bar)

    expect(handleClick).toHaveBeenCalled()
})

Проверка аргументов callback

expect(handleClick).toHaveBeenCalledWith(
    expect.objectContaining({
        indexValue: 'USA'
    }),
    expect.anything()
)

Тестирование кастомных tooltip

Компонент

tooltip={({ value }) => (
    <div data-testid="tooltip">
        {value}
    </div>
)}

Тест

test('renders custom tooltip', async () => {
    render(<Chart />)

    const bar = document.querySelector('rect')

    fireEvent.mouseEnter(bar)

    expect(
        await screen.findByTestId('tooltip')
    ).toBeInTheDocument()
})

Проверка легенд

Nivo автоматически рендерит legends.


Тест легенды

expect(screen.getByText('value')).toBeInTheDocument()

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

Оси содержат подписи и tick-элементы.


Пример

expect(screen.getByText('USA')).toBeInTheDocument()
expect(screen.getByText('Germany')).toBeInTheDocument()

Проверка количества tick-элементов

const ticks = document.querySelectorAll('.tick')

expect(ticks.length).toBeGreaterThan(0)

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

Nivo поддерживает объект theme.


Пример theme

const theme = {
    text: {
        fill: '#ffffff'
    }
}

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

const text = document.querySelector('text')

expect(text).toHaveAttribute('fill', '#ffffff')

Тестирование условного рендера

Компоненты графиков часто рендерятся только при наличии данных.


Пример

if (!data.length) {
    return <div>No data</div>
}

Тест

test('renders empty state', () => {
    render(<Chart data={[]} />)

    expect(screen.getByText('No data'))
        .toBeInTheDocument()
})

Тестирование загрузки данных

Компонент

function ChartContainer() {
    const [data, setData] = useState([])

    useEffect(() => {
        fetch('/api/chart')
            .then(res => res.json())
            .then(setData)
    }, [])

    return (
        <Chart data={data} />
    )
}

Мок fetch

global.fetch = jest.fn(() =>
    Promise.resolve({
        json: () =>
            Promise.resolve([
                {
                    country: 'USA',
                    value: 100
                }
            ])
    })
)

Проверка загрузки

test('loads chart data', async () => {
    render(<ChartContainer />)

    expect(
        await screen.findByText('USA')
    ).toBeInTheDocument()
})

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

Для интеграционных тестов лучше применять Mock Service Worker.

Основные преимущества:

  • реалистичная сеть;
  • отсутствие ручных моков;
  • одинаковое поведение browser/node;
  • удобное переиспользование handlers.

Тестирование ошибок загрузки

Пример

if (error) {
    return <div>Error</div>
}

Тест

test('renders error state', async () => {
    global.fetch = jest.fn(() =>
        Promise.reject(new Error('Network error'))
    )

    render(<ChartContainer />)

    expect(
        await screen.findByText('Error')
    ).toBeInTheDocument()
})

Проверка accessibility

SVG-компоненты требуют дополнительной accessibility-настройки.


Добавление aria-label

<Bar
    role="img"
    ariaLabel="Sales chart"
/>

Тест accessibility

expect(
    screen.getByLabelText('Sales chart')
).toBeInTheDocument()

Тестирование кастомных слоёв

Nivo позволяет добавлять custom layers.


Пример

const CustomLayer = () => (
    <text x={10} y={10}>
        Custom
    </text>
)

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

<Bar
    layers={[
        'grid',
        'axes',
        CustomLayer,
        'bars'
    ]}
/>

Тест слоя

expect(screen.getByText('Custom'))
    .toBeInTheDocument()

Изоляция логики от визуализации

Хорошая практика — выносить:

  • трансформацию данных;
  • вычисления;
  • форматирование;
  • агрегацию;

в отдельные функции.


Пример

export function normalizeData(data) {
    return data.map(item => ({
        ...item,
        value: Number(item.value)
    }))
}

Unit-тест функции

test('normalizes values', () => {
    const result = normalizeData([
        {
            value: '100'
        }
    ])

    expect(result[0].value).toBe(100)
})

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

Nivo активно использует:

valueFormat
axisBottom.format
axisLeft.format
labelFormat

Пример formatter

export const currencyFormatter = value =>
    `$${value}`

Тест

test('formats currency', () => {
    expect(currencyFormatter(100))
        .toBe('$100')
})

Тестирование React.memo

Графики могут быть тяжёлыми для рендера.


Оптимизация

export default React.memo(Chart)

Проверка повторного рендера

const renderSpy = jest.fn()

function Chart() {
    renderSpy()

    return <div>Chart</div>
}

Тест

test('avoids rerender', () => {
    const MemoChart = React.memo(Chart)

    const { rerender } = render(
        <MemoChart value={1} />
    )

    rerender(<MemoChart value={1} />)

    expect(renderSpy).toHaveBeenCalledTimes(1)
})

Тестирование debounce и resize

Некоторые графики реагируют на resize с debounce.


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

jest.useFakeTimers()

Пример

test('handles resize', () => {
    render(<Chart />)

    window.dispatchEvent(new Event('resize'))

    jest.runAllTimers()
})

Visual Regression Testing

Для сложных графиков часто применяют:

  • Chromatic;
  • Loki;
  • Playwright;
  • Cypress component testing.

Почему unit-тестов недостаточно

Unit-тесты не проверяют:

  • реальный layout;
  • рендер браузера;
  • шрифты;
  • clipping;
  • переполнение;
  • наложение элементов.

Visual regression закрывает эти проблемы.


Тестирование через Playwright

Пример

test('chart screenshot', async ({ page }) => {
    await page.goto('/chart')

    await expect(page).toHaveScreenshot()
})

Проверка SVG через data-testid

Иногда полезно добавлять test id.


Пример custom bar

barComponent={({ bar }) => (
    <rect
        data-testid={`bar-${bar.indexValue}`}
        x={bar.x}
        y={bar.y}
        width={bar.width}
        height={bar.height}
    />
)}

Тест

expect(
    screen.getByTestId('bar-USA')
).toBeInTheDocument()

Лучшие практики тестирования Nivo

Отключение анимации

animate={false}

Использование фиксированных размеров

width={600}
height={400}

Минимизация snapshot

Не следует snapshot-тестировать огромные SVG-деревья без необходимости.


Тестирование поведения, а не реализации

Предпочтительно проверять:

  • наличие данных;
  • пользовательские действия;
  • callback;
  • текст;
  • accessibility;

вместо внутренних DOM-структур.


Изоляция вычислений

Логику трансформации данных следует тестировать отдельно от графиков.


Использование Canvas только при необходимости

SVG-компоненты значительно проще тестируются.


Использование visual regression

Для production-графиков visual regression практически обязателен.