【问题标题】:Actions passed as props in Redux connected component are not called when form submit is simulated during testing在测试期间模拟表单提交时,不会调用在 Redux 连接组件中作为 props 传递的操作
【发布时间】:2020-08-22 16:15:55
【问题描述】:

我正在测试我的第一个应用,但在测试 Redux 连接组件时遇到了问题。

更具体地说,我正在测试Search.js。这个想法是在子组件DisplaySearcgBar.js中模拟一个表单提交,然后测试是否调用了setAlertgetRestaurants

在测试 #3 中,因为在提交表单时输入为空,Search.js 应该调用 OnSubmit(),而在 #4 中它应该调用 getRestaurants,因为提供了输入。

两个测试都因相同的错误而被拒绝:

Search › 3 - setAlert called if search button is pressed with no input

    expect(jest.fn()).toHaveBeenCalled()

    Expected number of calls: >= 1
    Received number of calls:    0

      37 |     wrapper.find('[data-test="search"]').simulate('click');
      38 |     //expect(store.getActions().length).toBe(1);
    > 39 |     expect(wrapper.props().children.props.props.setAlert).toHaveBeenCalled();
         |                                                           ^
      40 |   });
      41 | 
      42 |   test('4 - getRestaurant called when inputs filled and search button clicked ', () => {

      at Object.<anonymous> (src/Components/restaurants/Search/__tests__/Search.test.js:39:59)

  ● Search › 4 - getRestaurant called when inputs filled and search button clicked 

    expect(jest.fn()).toHaveBeenCalled()

    Expected number of calls: >= 1
    Received number of calls:    0

      55 |     wrapper.find('[data-test="search"]').simulate('click');
      56 | 
    > 57 |     expect(wrapper.props().children.props.props.getRestaurants).toHaveBeenCalled();
         |                                                                 ^
      58 |   });
      59 | });
      60 | 

      at Object.<anonymous> (src/Components/restaurants/Search/__tests__/Search.test.js:57:65)

我是测试新手,我不确定自己做错了什么。

我尝试了不同的方法来选择这两个函数,但要么我得到了上面相同的错误,要么找不到它们。 我觉得我在兜圈子,我一定是错过了什么,但我不明白是什么。

这里是 Search.test.js

import React from 'react';
import { mount } from 'enzyme';
import configureStore from 'redux-mock-store';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';

import Search from './../Search';
import DisplaySearchBar from '../../../layout/DisplaySearchBar/DisplaySearchBar';

const mockStore = configureStore([thunk]);
const initialState = {
  restaurants: { restaurants: ['foo'], alert: null },
};
const store = mockStore(initialState);
const mockSetAlert = jest.fn();
const mockGetRestaurants = jest.fn();
const onSubmit = jest.fn();
const wrapper = mount(
  <Provider store={store}>
    <Search setAlert={mockSetAlert} getRestaurants={mockGetRestaurants} />
  </Provider>
);

describe('Search', () => {
  /* beforeEach(() => {
    const form = wrapper.find('form').first();
    form.simulate('submit', {
      preventDefault: () => {},
    });
  }); */

  afterEach(() => {
    jest.clearAllMocks();
  });

  test('1 - renders without errors', () => {
    expect(wrapper.find(DisplaySearchBar)).toHaveLength(1);
  });

  test('2 - if restaurants clearButton is rendered', () => {
    expect(wrapper.find('[data-test="clear"]')).toBeTruthy();
  });

  test('3 - setAlert called if search button is pressed with no input', () => {
    wrapper.find('form').simulate('submit', { preventDefault: () => {} });

    expect(mockSetAlert).toHaveBeenCalled();
  });

  test('4 - getRestaurant called when inputs filled and search button clicked ', () => {
    wrapper
      .find('[name="where"]')
      .at(0)
      .simulate('change', { target: { value: 'foo' } });

    wrapper
      .find('[name="what"]')
      .at(0)
      .simulate('change', { target: { value: 'foo' } });

    wrapper
      .find('[data-test="best_match"]')
      .at(0)
      .simulate('click');

    wrapper.find('form').simulate('submit', { preventDefault: () => {} });

    expect(mockGetRestaurants).toHaveBeenCalledWith({
      name: 'foo',
      where: 'foo',
      sortBy: 'best_match',
    });
  });
});

搜索.js

import React, { useState } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';

import { handleScriptLoad } from '../../../helpers/Autocomplete';
import { getRestaurants, setAlert } from '../../../actions/restaurantAction';
import DisplaySearchBar from '../../layout/DisplaySearchBar/DisplaySearchBar';

import styles from './Search.module.scss';

const Search = ({ getRestaurants, setAlert }) => {
  const [where, setWhere] = useState('');
  const [what, setWhat] = useState('');
  const [sortBy, setSortBy] = useState('rating');

  const sortByOptions = {
    'Highest Rated': 'rating',
    'Best Match': 'best_match',
    'Most Reviewed': 'review_count',
  };

  // give active class to option selected
  const getSortByClass = (sortByOption) => {
    if (sortBy === sortByOption) {
      return styles.active;
    } else {
      return '';
    }
  };

  // set the state of a sorting option
  const handleSortByChange = (sortByOption) => {
    setSortBy(sortByOption);
  };

  //handle input changes
  const handleChange = (e) => {
    if (e.target.name === 'what') {
      setWhat(e.target.value);
    } else if (e.target.name === 'where') {
      setWhere(e.target.value);
    }
  };

  const onSubmit = (e) => {
    e.preventDefault();
    if (where && what) {
      getRestaurants({ where, what, sortBy });
      setWhere('');
      setWhat('');
      setSortBy('best_match');
    } else {
      setAlert('Please fill all the inputs');
    }
  };

  // displays sort options
  const renderSortByOptions = () => {
    return Object.keys(sortByOptions).map((sortByOption) => {
      let sortByOptionValue = sortByOptions[sortByOption];
      return (
        <li
          className={`${sortByOptionValue} ${getSortByClass(
            sortByOptionValue
          )}`}
          data-test={sortByOptionValue}
          key={sortByOptionValue}
          onClick={() => handleSortByChange(sortByOptionValue)}
        >
          {sortByOption}
        </li>
      );
    });
  };

  return (
    <DisplaySearchBar
      onSubmit={onSubmit}
      handleChange={handleChange}
      renderSortByOptions={renderSortByOptions}
      where={where}
      what={what}
      handleScriptLoad={handleScriptLoad}
    />
  );
};

Search.propTypes = {
  getRestaurants: PropTypes.func.isRequired,
  setAlert: PropTypes.func.isRequired,
};

export default connect(null, { getRestaurants, setAlert })(Search);

按钮所在的子组件

import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { clearSearch } from '../../../actions/restaurantAction';
//Import React Script Libraray to load Google object
import Script from 'react-load-script';
import Fade from 'react-reveal/Fade';
import Alert from '../Alert/Alert';

import styles from './DisplaySearchBar.module.scss';

const DisplaySearchBar = ({
  renderSortByOptions,
  onSubmit,
  where,
  handleChange,
  what,
  handleScriptLoad,
  restaurants,
  clearSearch,
}) => {
  const googleUrl = `https://maps.googleapis.com/maps/api/js?key=${process.env.REACT_APP_GOOGLE_API_KEY}&libraries=places`;
  // {googleUrl && <Script url={googleUrl} onLoad={handleScriptLoad} />}
  return (
    <section className={styles.searchBar}>
      <form onSubmit={onSubmit} className={styles.searchBarForm}>
        <legend className="title">
          <Fade left>
            <h1>Where are you going to eat tonight?</h1>
          </Fade>
        </legend>
        <Fade>
          <fieldset className={styles.searchBarInput}>
            <input
              type="text"
              name="where"
              placeholder="Where do you want to eat?"
              value={where}
              onChange={handleChange}
              id="autocomplete"
            />

            <input
              type="text"
              name="what"
              placeholder="What do you want to eat?"
              onChange={handleChange}
              value={what}
            />
            <div data-test="alert-holder" className={styles.alertHolder}>
              <Alert />
            </div>
          </fieldset>

          <fieldset className={styles.searchBarSubmit}>
            <input
              data-test="search"
              className={`${styles.myButton} button`}
              type="submit"
              name="submit"
              value="Search"
            ></input>

            {restaurants.length > 0 && (
              <button
                data-test="clear"
                className={`${styles.clearButton} button`}
                onClick={clearSearch}
              >
                Clear
              </button>
            )}
          </fieldset>
        </Fade>
      </form>
      <article className={styles.searchBarSortOptions}>
        <Fade>
          <ul>{renderSortByOptions()}</ul>
        </Fade>
      </article>
    </section>
  );
};

DisplaySearchBar.propTypes = {
  renderSortByOptions: PropTypes.func.isRequired,
  where: PropTypes.string.isRequired,
  handleChange: PropTypes.func.isRequired,
  what: PropTypes.string.isRequired,
  handleScriptLoad: PropTypes.func.isRequired,
  restaurants: PropTypes.array.isRequired,
  clearSearch: PropTypes.func.isRequired,
};

const mapStatetoProps = (state) => ({
  restaurants: state.restaurants.restaurants,
});

export default connect(mapStatetoProps, { clearSearch })(DisplaySearchBar);

RestaurantActions.js

import { getCurrentPosition } from '../helpers/GeoLocation';
import {
  getRestaurantsHelper,
  getRestaurantsInfoHelper,
  getDefaultRestaurantsHelper,
} from '../helpers/utils';

import {
  CLEAR_SEARCH,
  SET_LOADING,
  GET_LOCATION,
  SET_ALERT,
  REMOVE_ALERT,
} from './types';

// Get Restaurants
export const getRestaurants = (text) => async (dispatch) => {
  dispatch(setLoading());

  getRestaurantsHelper(text, dispatch);
};

// Get Restaurants Info
export const getRestaurantInfo = (id) => async (dispatch) => {
  dispatch(setLoading());
  getRestaurantsInfoHelper(id, dispatch);
};

// Get default restaurants
export const getDefaultRestaurants = (location, type) => async (dispatch) => {
  if (location.length > 0) {
    getDefaultRestaurantsHelper(location, type, dispatch);
  }
};

// Get location
export const fetchCoordinates = () => async (dispatch) => {
  try {
    const { coords } = await getCurrentPosition();
    dispatch({
      type: GET_LOCATION,
      payload: [coords.latitude.toFixed(5), coords.longitude.toFixed(5)],
    });
  } catch (error) {
    dispatch(setAlert('Location not available'));
  }
};

// Set loading
export const setLoading = () => ({ type: SET_LOADING });

// Clear search
export const clearSearch = () => ({ type: CLEAR_SEARCH });

// Set alert
export const setAlert = (msg, type) => (dispatch) => {
  dispatch({
    type: SET_ALERT,
    payload: { msg, type },
  });

  setTimeout(() => dispatch({ type: REMOVE_ALERT }), 5000);
};

这里是 Github 上的完整存储库:https://github.com/mugg84/RestaurantFinderRedux.git

提前感谢您的帮助!!

【问题讨论】:

    标签: reactjs unit-testing react-redux jestjs enzyme


    【解决方案1】:

    Search.js 是一个连接组件。它的道具通过 mapDispatchToProps 来自商店。即使您模拟道具,生成的包装器也会从提供者的商店中获取相应的功能。所以解决方法是检查actions是否被调用了所需的类型和payload。

    test-4 中的另一个问题是你没有在event 中传递name。因此,这些值没有在该州设置。为避免此类情况,请使用控制台调试您的测试。

    import React from 'react';
    import { mount } from 'enzyme';
    import configureStore from 'redux-mock-store';
    import { Provider } from 'react-redux';
    import thunk from 'redux-thunk';
    
    import Search from './../Search';
    import DisplaySearchBar from '../../../layout/DisplaySearchBar/DisplaySearchBar';
    
    import {
      SET_LOADING,
      SET_ALERT,
    
    } from '../../../../actions/types';
    
    const mockStore = configureStore([thunk]);
    const initialState = {
      restaurants: { restaurants: ['foo'], alert: null },
    };
    const store = mockStore(initialState);
    const mockSetAlert = jest.fn();
    const mockGetRestaurants = jest.fn();
    
    const wrapper = mount(
      <Provider store={store}>
        <Search setAlert={mockSetAlert} getRestaurants={mockGetRestaurants} />
      </Provider>
    );
    
    describe('Search', () => {
      afterEach(() => {
        jest.clearAllMocks();
      });
    
      test('1 - renders without errors', () => {
        expect(wrapper.find(DisplaySearchBar)).toHaveLength(1);
      });
    
      test('2 - if restaurants clearButton is rendered', () => {
        expect(wrapper.find('[data-test="clear"]')).toBeTruthy();
      });
    
      test('3 - setAlert called if search button is pressed with no input', () => {
        wrapper.find('form').simulate('submit', { preventDefault: () => {} });
        const actions= store.getActions();
        const expected={
          type: SET_ALERT,
          payload: expect.objectContaining({msg:"Please fill all the inputs"})
        };
        expect(actions[0]).toMatchObject(expected);
      });
    
      test('4 - getRestaurant called when inputs filled and search button clicked ', () => {
        wrapper
          .find('[name="where"]')
          .at(0)
          .simulate('change', { target: { value: 'foo', name:"where" } });
    
        wrapper
          .find('[name="what"]')
          .at(0)
          .simulate('change', { target: { value: 'foo',name:"what" } });
    
        wrapper
          .find('[data-test="best_match"]')
          .at(0)
          .simulate('click');
    
        wrapper.find('form').simulate('submit', { preventDefault: () => {} });
        const actions= store.getActions();
        const expected={
          type: SET_LOADING,
        };
        expect(actions).toContainEqual(expected);
         });
    });
    

    【讨论】:

      【解决方案2】:

      那是因为酶的find() 返回一个 html 节点的集合。

      还记得这个古老的酶的错误吗?

      方法“simulate”意味着在 1 个节点上运行。

      像这样尝试:wrapper.find('...').at(0)

      此外,当您期望模拟的 'setAlert()andgetRestaurant()to have been called, you refer to them in a way that unables us to know if it's a right or wrong reference. So, please supply your relevantdebug()` 结果时,或者更好的是,像这样模拟它们:

      const mockSetAlert = jest.fn();
      const mockGetRestaurants = jest.fn();
      
      const wrapper = mount(
          <Search setAlert={mockSetAlert} getRestaurants={mockGetRestaurants} />
      );
      
      ...
      
      expect(mockSetAlert).toHaveBeenCalled();
      expect(mockGetRestaurants).toHaveBeenCalled();
      

      这是一个简化的例子,但你明白了......

      【讨论】:

      • @k-wasilewsky 我试过了,但我得到了和以前一样的错误..
      • @k-wasilewsky 我按照建议做了,(在包装器中我必须保留``` ``,否则我会收到错误),但是没有改变。还是一样的错误。
      • 当然。将其更改为const Search = (props) =&gt; {...} 并访问props.setAlert() 之类的功能怎么样?
      • @k-wasilewsky 已经尝试像那样访问它们,但同样的错误
      • 好的,所以我不确定您是否可以模拟输入上的“点击”。在这种情况下,更正确的方法是wrapper.find('form').simulate('submit')
      【解决方案3】:

      我相信我知道如何测试是否调用了 setAlertgetRestaurants。 我使用了默认公开的Search,而不是使用原始组件。

      所以即使我给了它 setAlertgetRestaurants 属性,默认组件的 connect 方法也会覆盖它并给它自己的 setAlertgetRestaurants,这就是它们从未被调用的原因。

      原始组件不支持 Redux,它只是从 Redux 存储中获取 props 并使用它们。由于测试需要关注原始组件而不是存储,因此我们需要将其单独导出以进行测试。

      DisplaySearchBar 被渲染时,我仍然使用mockstore

      正如我之前在Search.js 中提到的,我导出原始组件:

        // previous code
      export const Search = ({ getRestaurants, setAlert }) => {
         // rest of the code
      

      通过测试它而不是默认组件,我只需要检查是否正在调用作为模拟函数传递的 setAlertgetRestaurants。 (测试#3和#4)

      import React from 'react';
      import { mount } from 'enzyme';
      import configureStore from 'redux-mock-store';
      import { Provider } from 'react-redux';
      import thunk from 'redux-thunk';
      
      import { Search as BaseSearch } from './../Search';
      import { DisplaySearchBar as BaseDisplaySearchBar } from '../../../layout/DisplaySearchBar/DisplaySearchBar';
      
      const mockStore = configureStore([thunk]);
      const initialState = {
        restaurants: { restaurants: ['foo'], alert: null },
      };
      
      const getRestaurants = jest.fn();
      const setAlert = jest.fn();
      
      let wrapper, store;
      
      describe('Search', () => {
        beforeEach(() => {
          store = mockStore(initialState);
      
          wrapper = mount(
            <Provider store={store}>
              <BaseSearch setAlert={setAlert} getRestaurants={getRestaurants} />
            </Provider>
          );
        });
      
        afterEach(() => {
          jest.clearAllMocks();
        });
      
        test('1 - renders without errors', () => {
          expect(wrapper.find(BaseDisplaySearchBar)).toHaveLength(1);
        });
      
        test('2 - if restaurants clearButton is rendered', () => {
          expect(wrapper.find('[data-test="clear"]')).toBeTruthy();
        });
      
        test('3 - setAlert called if search button is pressed with no input', () => {
          wrapper.find('form').simulate('submit', { preventDefault: () => {} });
      
          expect(setAlert).toHaveBeenCalled();
        });
      
        test('4 - getRestaurants called when inputs filled and search button clicked ', () => {
          wrapper
            .find('[name="where"]')
            .at(0)
            .simulate('change', { target: { value: 'foo', name: 'where' } });
      
          wrapper
            .find('[name="what"]')
            .at(0)
            .simulate('change', { target: { value: 'foo', name: 'what' } });
      
          wrapper
            .find('[data-test="best_match"]')
            .at(0)
            .simulate('click');
      
          wrapper.find('form').simulate('submit', { preventDefault: () => {} });
      
          expect(getRestaurants).toHaveBeenCalled();
        });
      });
      

      【讨论】:

        猜你喜欢
        • 2019-09-11
        • 2019-08-14
        • 2018-12-08
        • 2019-08-30
        • 2021-04-03
        • 2017-10-27
        • 1970-01-01
        • 2018-09-07
        • 1970-01-01
        相关资源
        最近更新 更多