如何测试组件中的react useContext useReducer调度

Posted

技术标签:

【中文标题】如何测试组件中的react useContext useReducer调度【英文标题】:How to test react useContext useReducer dispatch in component 【发布时间】:2020-11-18 09:04:10 【问题描述】:

希望有人能给我指出正确的方向。基本上我已经创建了一个使用钩子的反应应用程序,特别是 useContext、useEffect 和 useReducer。我的问题是我似乎无法通过测试来检测相关组件的点击或调度事件。

可以在以下位置找到我的应用程序的精简版:https://github.com/atlantisstorm/hooks-testing 测试与 layout.test.js 脚本相关。

我尝试了各种方法、模拟调度、useContext 等的不同方式,但对它并不满意。最新版本。

layout.test.js

import React from 'react';
import  render, fireEvent  from "@testing-library/react";
import Layout from './layout';
import App from './app';
import  Provider, initialState  from './context';

const dispatch = jest.fn();

const spy = jest
  .spyOn(React, 'useContext')
  .mockImplementation(() => (
    state: initialState,
    dispatch: dispatch
));

describe('Layout component', () => 
  it('starts with a count of 0', () => 
    const  getByTestId  = render(
      <App>
        <Provider>
          <Layout />
        </Provider>
      </App>
    );

    expect(dispatch).toHaveBeenCalledTimes(1);

    const refreshButton = getByTestId('fetch-button');

    fireEvent.click(refreshButton);

    expect(dispatch).toHaveBeenCalledTimes(3);
  );
);

layout.jsx

import React,  useContext, useEffect  from 'react';
import  Context  from "./context";

const Layout = () => 
  const  state, dispatch  = useContext(Context);
  const  displayThings, things  = state;

  const onClickDisplay = (event) => 
    // eslint-disable-next-line
    event.preventDefault;
    dispatch( type: "DISPLAY_THINGS" );
  ;

  useEffect(() => 
    dispatch( type: "FETCH_THINGS" );
  , [displayThings]);

  const btnText = displayThings ? "hide things" : "display things";
  return (
    <div>
        <button data-testid="fetch-button" onClick=onClickDisplay>btnText</button>
         displayThings ? 
            <p>We got some things!</p>
          :
            <p>No things to show!</p>
        
         displayThings && things.map((thing) =>
            <p> thing </p>
        )
    </div>
  )


export default Layout;

app.jsx

import React from 'react';
import Provider from "./context";
import Layout from './layout';
const App = () => 
  return (
    <Provider>
      <Layout />
    </Provider>
  )


export default App;

context.jsx

import React,  createContext, useReducer  from "react";
import  reducer  from "./reducer";

export const Context = createContext();

export const initialState = 
  displayThings: false,
  things: []
;

export const Provider = ( children ) => 
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <Context.Provider value= state, dispatch >
      children
    </Context.Provider>
  );
;

export default Provider;

reducer.jsx

export const reducer = (state, action) => 
  switch (action.type) 
    case "DISPLAY_THINGS": 
      const displayThings = state.displayThings ? false : true; 
      return  ...state, displayThings ;
    

    case "FETCH_THINGS": 
      const things = state.displayThings ? [
          "thing one",
          "thing two"            
      ] : [];
      return  ...state, things ;
    

    default: 
      return state;
    
  
;

我确信当我看到它时答案会很容易,但只是想弄清楚我可以检测到点击事件并检测到“调度”事件吗? (我已经在主应用程序中进行了单独的测试,以正确测试调度响应/操作)

提前谢谢你。

编辑 好的,我想我有一个合理但不完美的解决方案。首先,我只是在 context.jsx 模块中添加了可选的 testDispatch 和 testState 属性。

新的 context.jsx

import React,  createContext, useReducer  from "react";
import  reducer  from "./reducer";

export const Context = createContext();

export const initialState = 
  displayThings: false,
  things: []
;

export const Provider = ( children, testDispatch, testState ) => 
  const [iState, iDispatch] = useReducer(reducer, initialState);

  const dispatch = testDispatch ? testDispatch : iDispatch;
  const state = testState ? testState : iState;
  return (
    <Context.Provider value= state, dispatch >
      children
    </Context.Provider>
  );
;

export default Provider;

然后在 layout.test.jsx 中,我只是简单地传入模拟的 jest 调度函数和必要的状态。还移除了外部 App 包装,因为这似乎可以防止道具通过。

新的 layout.test.jsx

import React from 'react';
import  render, fireEvent  from "@testing-library/react";
import Layout from './layout';
import  Provider  from './context';

describe('Layout component', () => 
  it('starts with a count of 0', () => 
    const dispatch = jest.fn();
    const state = 
      displayThings: false,
      things: []
    ;
    const  getByTestId  = render(
      <Provider testDispatch=dispatch testState=state>
        <Layout />
      </Provider>
    );

    expect(dispatch).toHaveBeenCalledTimes(1);
    expect(dispatch).toHaveBeenNthCalledWith(1,  type: "FETCH_THINGS" );

    const refreshButton = getByTestId('fetch-things-button');
    fireEvent.click(refreshButton);

    expect(dispatch).toHaveBeenCalledTimes(2);
    // Important: The order of the calls should be this, but dispatch is reporting them 
    // the opposite way around in the this test, i.e. FETCH_THINGS, then DISPLAY_THINGS... 
    //expect(dispatch).toHaveBeenNthCalledWith(1,  type: "DISPLAY_THINGS" );
    //expect(dispatch).toHaveBeenNthCalledWith(2,  type: "FETCH_THINGS" );
   
    // ... so as dispatch behaves correctly outside of testing for the moment I'm just settling for
    // knowing that dispatch was at least called twice with the correct parameters.
    expect(dispatch).toHaveBeenCalledWith( type: "DISPLAY_THINGS" );
    expect(dispatch).toHaveBeenCalledWith( type: "FETCH_THINGS" );

  );
);

不过,如上所述,有一点需要注意,当“fetch-things-button”被触发时,它会以错误的顺序报告调度。 :/ 所以我只是决定知道触发的正确调用,但是如果有人知道为什么调用顺序不符合预期,我会很高兴知道。

https://github.com/atlantisstorm/hooks-testing 如果有人感兴趣,请更新以反映上述内容。

【问题讨论】:

这个问题有更新吗? 您应该开始测试 UI 交互和结果,而不是实现本身。这是使用 react-testing-library 的正确方法 【参考方案1】:

几个月前,我还尝试为应用程序的 reducer + 上下文编写单元测试。所以,这是我测试 useReducer 和 useContext 的解决方案。

FeaturesProvider.js

    import React,  createContext, useContext, useReducer  from 'react';

    import  featuresInitialState, featuresReducer  from '../reducers/featuresReducer';

    export const FeatureContext = createContext();

    const FeaturesProvider = ( children ) => 
      const [state, dispatch] = useReducer(featuresReducer, featuresInitialState);

      return <FeatureContext.Provider value= state, dispatch >children</FeatureContext.Provider>;
    ;

    export const useFeature = () => useContext(FeatureContext);

    export default FeaturesProvider;

FeaturesProvider.test.js

    import React from 'react';
    import  render  from '@testing-library/react';
    import  renderHook  from '@testing-library/react-hooks';
    import FeaturesProvider,  useFeature, FeatureContext  from './FeaturesProvider';

    const state =  features: [] ;
    const dispatch = jest.fn();

    const wrapper = ( children ) => (
      <FeatureContext.Provider value= state, dispatch >
        children
      </FeatureContext.Provider>
    );

    const mockUseContext = jest.fn().mockImplementation(() => ( state, dispatch ));

    React.useContext = mockUseContext;

    describe('useFeature test', () => 
      test('should return present feature toggles  with its state and dispatch function', () => 
        render(<FeaturesProvider />);
        const  result  = renderHook(() => useFeature(),  wrapper );

        expect(result.current.state.features.length).toBe(0);
        expect(result.current).toEqual( state, dispatch );
      );
    );

featuresReducer.js

    import ApplicationConfig from '../config/app-config';
    import actions from './actions';

    export const featuresInitialState = 
      features: [],
      environments: ApplicationConfig.ENVIRONMENTS,
      toastMessage: null
    ;

    const  INITIALIZE_DATA, TOGGLE_FEATURE, ENABLE_OR_DISABLE_TOAST  = actions;

    export const featuresReducer = (state,  type, payload ) => 
      switch (type) 
        case INITIALIZE_DATA:
          return 
            ...state,
            [payload.name]: payload.data
          ;

        case TOGGLE_FEATURE:
          return 
            ...state,
            features: state.features.map((feature) => (feature.featureToggleName === payload.featureToggleName
              ? 
                ...feature,
                environmentState:
                   ...feature.environmentState, [payload.environment]: !feature.environmentState[payload.environment] 
              
              : feature))
          ;

        case ENABLE_OR_DISABLE_TOAST:
          return  ...state, toastMessage: payload.message ;

        default:
          return  ...state ;
      
    ;

featuresReducer.test.js

import  featuresReducer  from './featuresReducer';
import actions from './actions';

const  INITIALIZE_DATA, TOGGLE_FEATURE, ENABLE_OR_DISABLE_TOAST  = actions;

describe('Reducer function test', () => 
  test('should initialize data when INITIALIZE_DATA action is dispatched', () => 
    const featuresState = 
      features: []
    ;

    const action = 
      type: INITIALIZE_DATA,
      payload: 
        name: 'features',
        data: [
          featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: true, replica: true, prod: false 
        ]
      
    ;

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual(
      features: [
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: true, replica: true, prod: false 
      ]
    );
  );

  test('should toggle the feature for the given feature and environemt when TOGGLE_FEATURE action is disptched', () => 
    const featuresState = 
      features: [
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: true, replica: true, prod: false 
      , 
        featureId: '23458', featureName: 'WO photo download', featureToggleName: '23458_WOPhotoDownload', environmentState:  sit: true, replica: true, prod: false 
      ]
    ;

    const action = 
      type: TOGGLE_FEATURE,
      payload:  featureToggleName: '23456_WOPhotoDownload', environment: 'sit' 
    ;

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual(
      features: [
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: false, replica: true, prod: false 
      , 
        featureId: '23458', featureName: 'WO photo download', featureToggleName: '23458_WOPhotoDownload', environmentState:  sit: true, replica: true, prod: false 
      ]
    );
  );

  test('should enable the toast message when ENABLE_OR_DISABLE_TOAST action is dispatched with the message as part of payload', () => 
    const featuresState = 
      toastMessage: null
    ;

    const action = 
      type: ENABLE_OR_DISABLE_TOAST,
      payload:  message: 'Something went wrong!' 
    ;

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual( toastMessage: 'Something went wrong!' );
  );

  test('should disable the toast message when ENABLE_OR_DISABLE_TOAST action is dispatched with message as null as part of payload', () => 
    const featuresState = 
      toastMessage: null
    ;

    const action = 
      type: ENABLE_OR_DISABLE_TOAST,
      payload:  message: null 
    ;

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual( toastMessage: null );
  );

  test('should return the current state when the action with no specific type is dispatched', () => 
    const featuresState = 
      features: [
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: false, replica: true, prod: false 
      ]
    ;

    const action = ;

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual(
      features: [
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState:  sit: false, replica: true, prod: false 
      ]
    );
  );
);

【讨论】:

以上是关于如何测试组件中的react useContext useReducer调度的主要内容,如果未能解决你的问题,请参考以下文章

如何在类组件中使用 React.useContext(AuthContext) 来触发 index.js 文件中的条件集

React:useContext,如何从外部组件中检索它?

如何将 React 钩子(useContext、useEffect)与 Apollo 反应钩子(useQuery)结合起来

React Context 和 useContext

在一个 React 组件中使用多个“useContext”

React - useContext 返回值后加载组件