Библиотека Nivo строится поверх React и активно использует SVG, Canvas, анимации, хуки и адаптивный рендеринг. Из-за этого тестирование визуализаций отличается от тестирования обычных UI-компонентов.
Основные сложности:
ResizeObserver;react-spring;При тестировании Nivo-компонентов важно разделять:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
Для TypeScript:
npm install --save-dev ts-jest @types/jest
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
}
import '@testing-library/jest-dom'
Пакет добавляет удобные матчеры:
expect(element).toBeInTheDocument()
expect(element).toHaveTextContent()
expect(element).toHaveAttribute()
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()
})
})
Большинство компонентов Nivo используют:
ResponsiveBar
ResponsiveLine
ResponsivePie
ResponsiveHeatMap
Они вычисляют размеры родителя через ResizeObserver.
В среде Jest этого API нет.
Типичная ошибка:
ResizeObserver is not defined
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserver
После этого responsive-компоненты начинают корректно монтироваться.
JSDOM не вычисляет реальные размеры DOM-элементов.
Из-за этого:
<div style={{ width: 600, height: 400 }}>
не всегда приводит к реальной ширине и высоте.
Для 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"
/>
)
}
Такой компонент значительно стабильнее тестируется.
Nivo генерирует SVG.
Можно проверять:
rectpathcircletextgtest('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()
})
Многие диаграммы Nivo отображают tooltip при 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 может:
Поэтому желательно использовать:
findByText()
вместо:
getByText()
Анимации часто вызывают нестабильные тесты.
<Bar
animate={false}
/>
Это особенно важно для CI-среды.
<Bar
width={600}
height={400}
data={data}
keys={['value']}
indexBy="country"
animate={false}
/>
Nivo поддерживает Canvas-версии:
BarCanvas
LineCanvas
ScatterPlotCanvas
Canvas сложнее тестировать, поскольку DOM-элементы отсутствуют.
Для Canvas обычно тестируют:
test('renders canvas', () => {
render(<Chart />)
const canvas = document.querySelector('canvas')
expect(canvas).toBeInTheDocument()
})
Иногда появляется ошибка:
HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = jest.fn()
Snapshot полезен для:
import { render } from '@testing-library/react'
test('matches snapshot', () => {
const { container } = render(<Chart />)
expect(container).toMatchSnapshot()
})
Nivo может генерировать:
Из-за этого snapshot может постоянно изменяться.
animate={false}
width={600}
height={400}
Иногда применяют кастомные сериализаторы.
Nivo поддерживает:
onClick
onMouseEnter
onMouseLeave
<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()
})
expect(handleClick).toHaveBeenCalledWith(
expect.objectContaining({
indexValue: 'USA'
}),
expect.anything()
)
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()
const ticks = document.querySelectorAll('.tick')
expect(ticks.length).toBeGreaterThan(0)
Nivo поддерживает объект 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} />
)
}
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()
})
Для интеграционных тестов лучше применять Mock Service Worker.
Основные преимущества:
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()
})
SVG-компоненты требуют дополнительной accessibility-настройки.
<Bar
role="img"
ariaLabel="Sales chart"
/>
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)
}))
}
test('normalizes values', () => {
const result = normalizeData([
{
value: '100'
}
])
expect(result[0].value).toBe(100)
})
Nivo активно использует:
valueFormat
axisBottom.format
axisLeft.format
labelFormat
export const currencyFormatter = value =>
`$${value}`
test('formats currency', () => {
expect(currencyFormatter(100))
.toBe('$100')
})
Графики могут быть тяжёлыми для рендера.
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)
})
Некоторые графики реагируют на resize с debounce.
jest.useFakeTimers()
test('handles resize', () => {
render(<Chart />)
window.dispatchEvent(new Event('resize'))
jest.runAllTimers()
})
Для сложных графиков часто применяют:
Unit-тесты не проверяют:
Visual regression закрывает эти проблемы.
test('chart screenshot', async ({ page }) => {
await page.goto('/chart')
await expect(page).toHaveScreenshot()
})
Иногда полезно добавлять test id.
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()
animate={false}
width={600}
height={400}
Не следует snapshot-тестировать огромные SVG-деревья без необходимости.
Предпочтительно проверять:
вместо внутренних DOM-структур.
Логику трансформации данных следует тестировать отдельно от графиков.
SVG-компоненты значительно проще тестируются.
Для production-графиков visual regression практически обязателен.