测试反应自定义挂钩与模拟 blob 响应

Test react custom hook with mocking blob response

我创建了一个钩子,如下所示:

import { AxiosResponse } from 'axios';
import { DEFAULT_EXPORT_FILE_NAME } from 'constants';
import { useRef, useState } from 'react';

export interface ExportFileProps {
  readonly apiDefinition: () => Promise<AxiosResponse<Blob | string>>;
  readonly preExport: () => void;
  readonly postExport: () => void;
  readonly onError: () => void;
}

export interface ExportedFileInfo {
  readonly exportHandler: () => Promise<void>;
  readonly ref: React.MutableRefObject<HTMLAnchorElement | null>;
  readonly name: string | undefined;
  readonly url: string | undefined;
}

export const useExportFile = ({
  apiDefinition,
  preExport,
  postExport,
  onError,
}: ExportFileProps): ExportedFileInfo => {
  const ref = useRef<HTMLAnchorElement | null>(null);
  const [url, setFileUrl] = useState<string>();
  const [name, setFileName] = useState<string>();

  const exportHandler = async () => {
    try {
      preExport();
      const response = await apiDefinition();
      const objectURL = URL.createObjectURL(
        new Blob([response.data], { type: response.headers['content-type'] }),
      );
      setFileUrl(objectURL);
      const fileName =
        response.headers['content-disposition'].match(/filename="(.+)"/)[1] ??
        DEFAULT_EXPORT_FILE_NAME;
      setFileName(fileName);
      ref.current?.click();
      postExport();
      if (url) URL.revokeObjectURL(url);
    } catch (error) {
      onError();
    }
  };

  return { exportHandler, ref, url, name };
};

这个钩子被这个组件使用:

interface ExportFileProps {
  readonly apiDefinition: () => Promise<AxiosResponse<Blob | string>>;
}

const ExportFile: React.FC<ExportFileProps> = (props: ExportFileProps): JSX.Element => {
  const { apiDefinition } = props;
  const [buttonState, setButtonState] = useState<ExportButtonState>(ExportButtonState.Primary);

  const preExport = () => setButtonState('loading');
  const postExport = () => setButtonState('primary');


  const onErrorDownloadFile = () => {
    setButtonState('primary');
  };

  const { ref, url, exportHandler, name } = useExportFile({
    apiDefinition,
    preExport,
    postExport,
    onError: onErrorDownloadFile,
  });

  return (
    <div>
      <Box sx={{ display: 'none' }}>
        <a href={url} download={name} ref={ref}>
          &nbsp;
        </a>
      </Box>
      <ExportButton
        clickHandler={exportHandler}
        buttonState={buttonState}
      >
        Download XLS
      </ExportButton>
    </div>
  );
};

export default ExportFile;

所以这个挂钩的作用是创建一个锚元素并单击它从 apiDefinition 下载 blob 响应。

我想做的是测试这个钩子,所以我需要用一个定义了 headers['content-disposition']response.headers['content-type'] 的 blob 文件来模拟响应。

然后测试 exportHandlerrefurlname 的 returned 值。

这是我试过的:

import { renderHook } from '@testing-library/react-hooks';
import { useExportFile } from './useExportFile';

describe('useExportFile', () => {
  const apiDefinition = jest.fn();
  const preExport = jest.fn();
  const postExport = jest.fn();
  const onError = jest.fn();

  test('is initialized', async () => {
    const { result } = renderHook(() =>
      useExportFile({
        apiDefinition,
        preExport,
        postExport,
        onError,
      }),
    );

    const exportHandler = result?.current?.exportHandler;
    expect(typeof exportHandler).toBe('function');
  });
});

我遇到的问题是在这种情况下我不知道如何模拟 api 调用,因为它应该 return 一个定义了 headers 的 blob。

我该如何解决这个问题?

您可以尝试如下模拟 apiDefinition

const blob: Blob = new Blob(['']);

const axiosResponse: AxiosResponse = {
  data: blob,
  status: 200,
  statusText: "OK",
  config: {},
  headers: {
    "content-disposition": 'filename="some-file"',
    "content-type": "application/json"
  }
};

const apiDefinition = () => Promise.resolve(axiosResponse);

测试代码:

import { act } from 'react-dom/test-utils';
import { AxiosResponse } from 'axios';
import { render } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks';
import { useExportFile } from './Export';

describe('useExportFile', () => {
  const FILE_NAME = "some-file"
  const URL = "some-url";
  const blob: Blob = new Blob(['']);

  const axiosResponse: AxiosResponse = {
    data: blob,
    status: 200,
    statusText: "OK",
    config: {},
    headers: {
      "content-disposition": `filename="${FILE_NAME}"`,
      "content-type": "text"
    }
  };

  global.URL.createObjectURL = () => URL;
  global.URL.revokeObjectURL = () => null;
  const onBtnClick = jest.fn();
  const apiDefinition = () => Promise.resolve(axiosResponse);
  const preExport = jest.fn();
  const postExport = jest.fn();
  const onError = jest.fn();

  test('is initialized', async () => {
    const { result } = renderHook(() =>
      useExportFile({
        apiDefinition,
        preExport,
        postExport,
        onError,
      }),
    );

    const container = render(<a href={url} ref={ref} onClick={onBtnClick}></a>);
    const exportHandler = result?.current?.exportHandler;
    expect(typeof exportHandler).toBe('function');
    await act(async () => {
      await exportHandler();
    });
    expect(result?.current?.url).toEqual(URL);
    expect(result?.current?.name).toEqual(FILE_NAME);
    expect(result?.current?.ref?.current).toEqual(await container.findByRole("anchor-btn"))
    expect(onBtnClick).toHaveBeenCalled();
  });
});

由于代码是异步的,我建议使用像这样的 Axios 异步模拟来测试它:https://www.npmjs.com/package/axios-mock-adapter

这样测试会更接近真实世界。

您将必须实现 ApiDefinition 接口,但测试会更加真实。此外,您可以使用这种方法添加延迟并在需要时测试 retries/timeouts。