7

希望有人能指出我正确的方向。基本上我已经创建了一个使用钩子的反应应用程序,特别是 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);
  });
});

布局.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;

应用程序.jsx

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

export default App;

上下文.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;

减速器.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 道具。

新上下文.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" });

  });
});

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

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

4

1 回答 1

3

几个月前,我还尝试为应用程序的 reducer + context 编写单元测试。所以,这是我测试 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 }
      }]
    });
  });
});
于 2021-11-04T07:19:53.373 回答