You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用Chart.js的React组件Jest测试报错:如何正确Mock Chart.js?

Chart.js 3.9.1 + React组件Jest测试报错:TypeError: undefined is not iterable

问题详情

React组件DataLineChart基于Chart.js 3.9.1开发,搭配自定义useUser Hook。编写Jest测试用例时,组件渲染阶段触发错误,定位到Chart.register(...registerables)行,错误信息:

TypeError: undefined is not iterable (cannot read property Symbol(symbol.iterator))

已成功Mock自定义Hook,但尝试多种Chart.js Mock方式均无效,其他组件测试正常,当前代码覆盖率95%,问题已持续10天。

组件代码

import React, { useEffect, useRef, useState } from 'react';
import { Chart, ChartConfiguration, registerables } from 'chart.js';
import './EmailLineChart.styles.css';
import { useUser } from '../../common/context/context';

const DataLineChart: React.FC = () => {
    const chartRef = useRef<HTMLCanvasElement>(null);
    const chartInstanceRef = useRef<Chart | null>(null);
    const [data, setData] = useState<{ month: string; success: number; failed: number; processing?: number }[]>([]);
    const [downloadFormat, setDownloadFormat] = useState<string>('chart');
    const [filterMonth, setFilterMonth] = useState<string>('');
    const [chartType, setChartType] = useState<'line'|'bar'>('line');

    const baseUrl = process.env.REACT_APP_BASE_URL;
    // 注:原代码中tag和servicename变量未定义,需确保实际代码中有正确赋值
    let url = `${baseUrl}/email/reports/count?type=year&tag=${tag}&service_name=${servicename}`;
    const tenantData = useUser();

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch(url);
                const apiData = await response.json();
                const monthlyReports = apiData.monthlyReports;

                if (!monthlyReports) {
                    console.error('No monthlyReports data in API response');
                    return;
                }

                const chartData = Object.keys(monthlyReports).map((month) => ({
                    month,
                    success: monthlyReports[month].SUCCESS,
                    failed: monthlyReports[month].FAILED,
                    processing: monthlyReports[month].PROCESSING || 0,
                }));
                chartData.sort((a, b) => new Date(a.month).getTime() - new Date(b.month).getTime());
                setData(chartData);
            } catch (error) {
                console.error('Error fetching data:', error);
            }
        };
        fetchData();
    }, []);

    useEffect(() => {
        if (chartInstanceRef.current) {
            chartInstanceRef.current.destroy();
        }
        if (chartRef.current && downloadFormat === 'chart') {
            Chart.register(...registerables);
            const ctx = chartRef.current.getContext('2d');
            const filteredData = filterMonth ? data.filter((item) => item.month === filterMonth) : data;
            if (ctx) {
                const gradient1 = ctx.createLinearGradient(0, 0, 0, 400);
                gradient1.addColorStop(0, 'rgba(75, 192, 192, 0.6)');
                gradient1.addColorStop(1, 'rgba(75, 192, 192, 0)');
              
                const gradient2 = ctx.createLinearGradient(0, 0, 0, 400);
                gradient2.addColorStop(0, 'rgba(255, 99, 132, 0.6)');
                gradient2.addColorStop(1, 'rgba(255, 99, 132, 0)');
                
                const config: ChartConfiguration<'line'|'bar'> = {
                    type: chartType,
                    data: {
                        labels: filteredData.map((item) => item.month),
                        datasets: [
                            {
                                label: 'Success',
                                data:  filteredData.map((item) => item.success),
                                borderColor:  'rgba(75, 192,198, 0.9)',
                                fill: true,
                                backgroundColor:'rgba(75, 192, 192, 0.3)',
                                pointBackgroundColor: 'rgba(75, 192, 192, 0.9)',
                                pointBorderColor:'rgba(75, 250, 192, 0.9)',
                                pointHoverBackgroundColor: 'rgba(75, 350, 192, 0.9)',
                                pointHoverBorderColor: 'rgba(75, 350, 192, 0.9)',
                                pointHoverRadius: 8,
                                pointHoverBorderWidth: 5,
                                tension: 0.1,
                            },
                            {
                                label: 'Failed',
                                data:  filteredData.map((item) => item.failed),
                                borderColor: 'rgba(255, 99, 132, 0.4)',
                                fill: true,
                                backgroundColor: 'rgba(255, 99, 132, 0.2)',
                                pointBackgroundColor: 'rgba(255, 99, 132, 0.3)',
                                pointBorderColor: 'rgba(255, 99, 132, 0.9)',
                                pointHoverBackgroundColor: 'rgba(255, 99, 132, 0.3)',
                                pointHoverBorderColor: 'rgba(255, 99, 132, 0.3)',
                                pointHoverRadius: 8,
                                pointHoverBorderWidth: 5,
                                tension: 0.1,
                            },
                        ],
                    },
                    options: {
                        responsive: true,
                        animation: {
                            duration: 2000,
                            easing: 'easeInOutBounce',
                        },
                        scales: {
                            x: {
                                title: {
                                    display: true,
                                    text: 'Month',
                                },
                                grid: {
                                    color: 'gray',
                                    display: false,
                                },
                                ticks: {
                                    color: 'black',
                                    font: {
                                        size: 10,
                                    }
                                },
                                stacked:true
                            },
                            y: {
                                title: {
                                    display: true,
                                    text: 'Number of Emails',
                                },
                                ticks: {
                                    color: 'black',
                                    font: {
                                        size: 10,
                                    }
                                },
                                grid: {
                                    color: 'gray',
                                    display: false,
                                },
                                stacked:true,
                            },
                        }
                    }                      
                };
                chartInstanceRef.current = new Chart(ctx, config);
            }
        }
    }, [downloadFormat, data, filterMonth]); 

    useEffect(() => {
        if (chartInstanceRef.current) {
            const chart = chartInstanceRef.current;
            chart.data.labels = data.map((item) => item.month);
            chart.data.datasets[0].data = data.map((item) => item.success||0);
            chart.data.datasets[1].data = data.map((item) => item.failed||0);
            chart.update();
        }
    }, [data]);

    const filteredData = filterMonth ? data.filter((item) => item.month === filterMonth) : data;
    const isChartSelected = downloadFormat === 'chart';

    return (
        <div className='chart'>
                <canvas ref={chartRef} className='chart-wrapper'></canvas>           
        </div>
    );
};
export default DataLineChart;

测试代码(原错误版本)

import React from 'react';
import { render } from '@testing-library/react';
import { Chart, registerables } from 'chart.js';
import DataLineChart from './DataLineChart';

jest.mock('../../common/context/context', () => ({
  useUser: jest.fn(() => ({ tenantData: { tenantId: 'mockedTenantId' } })),
}));

jest.mock('chart.js')

describe('DataLineChart', () => {
  it('renders without errors', () => {
    render(<DataLineChart />);
  });
});

错误信息

TypeError: undefined is not iterable (cannot read property Symbol(symbol.iterator))

解决方案

方案1:修复Jest的Chart.js Mock

默认jest.mock('chart.js')会将所有导出替换为undefined,导致registerables为undefined,展开运算符...registerables触发错误。需手动Mock所需导出:

import React from 'react';
import { render } from '@testing-library/react';
import DataLineChart from './DataLineChart';

// Mock自定义Hook
jest.mock('../../common/context/context', () => ({
  useUser: jest.fn(() => ({ tenantData: { tenantId: 'mockedTenantId' } })),
}));

// 正确Mock Chart.js
jest.mock('chart.js', () => ({
  Chart: jest.fn().mockImplementation(() => ({
    destroy: jest.fn(),
    update: jest.fn()
  })),
  registerables: [], // 必须给registerables赋值为可迭代对象(空数组即可)
  register: jest.fn()
}));

// Mock fetch请求,避免组件请求真实接口
global.fetch = jest.fn(() => 
  Promise.resolve({
    json: () => Promise.resolve({ monthlyReports: {} })
  })
);

describe('DataLineChart', () => {
  it('renders without errors', () => {
    render(<DataLineChart />);
  });
});

方案2:组件代码优化(从根源避免重复注册)

Chart.register(...registerables)只需全局注册一次,无需在useEffect中重复执行。将注册逻辑移到组件外部:

// 组件顶部,import之后立即注册
import { Chart, ChartConfiguration, registerables } from 'chart.js';
Chart.register(...registerables); // 全局注册一次即可

const DataLineChart: React.FC = () => {
  // 组件原有逻辑...
};

此优化不仅能避免测试时的Mock问题,还能提升实际运行时的性能,减少不必要的重复注册操作。

内容的提问来源于stack exchange,提问作者rashmi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.06 22:14:57