首页 > 解决方案 > 如何使用 jest 模拟在 React 功能组件中调度动作的函数

问题描述

我有一个组件可以获取表单数据并使用数据发送操作。此操作最终向服务器发出 ajax 请求,以使用 javascriptfetch函数发送该数据。

import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Editor } from 'react-draft-wysiwyg';
import { EditorState } from 'draft-js';
import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css';
import { getCookie } from '../../../utils/cookies';

import { postJobAction } from './redux/postJobActions';

const PostJobComponent = () => {
  const dispatch = useDispatch();
  const [editorState, setEditorState] = useState(() => EditorState.createEmpty());
  const [department, setDepartment] = useState('');

  const postJob = (event) => { // Here is happens and I am testing this function now.
    event.preventDefault();

    const jobPosterId = getCookie('id');
    const title = event.target.title.value;
    const location = event.target.location.value;
    const jobDescription = editorState.getCurrentContent().getPlainText();

    dispatch(postJobAction({
      jobPosterId,
      title,
      location,
      department,
      jobDescription,
    }));
  };

  const onDepartmentChange = (event) => {
    setDepartment(event.target.value);
  };

  return (
    <div className='post-job'>
      <form onSubmit={postJob}>
        <div>
          <label>Job Title</label>
          <input
            type='text'
            name='title'
            defaultValue=''
            className='job__title'
            placeholder='e.g. Frontend Developer, Project Manager etc.'
            required
          />
        </div>
        <div>
          <label>Job Location</label>
          <input
            type='text'
            name='location'
            defaultValue=''
            className='job__location'
            placeholder='e.g. Berlin, Germany.'
            required
          />
        </div>
        <div>
          <label>Department</label>
          <select className='job__department' required onChange={onDepartmentChange}>
            <option value=''>Select</option>
            <option value='Customer Success'>Customer Success</option>
            <option value='Professional Services'>Professional Services</option>
            <option value='Service Support'>Service And Support</option>
          </select>
        </div>
        <div style={{ border: '1px solid black', padding: '2px', minHeight: '400px' }}>
          <Editor
            required
            editorState={editorState}
            onEditorStateChange={setEditorState}
          />
        </div>
        <div>
          <button>Save</button>
        </div>
      </form>
    </div>
  );
};

export default PostJobComponent;

这是 postJob 功能的玩笑和酶测试。

it('should submit job post form on save button click', () => {
        const onPostJobSubmit = jest.fn();
        const instance = wrapper.instance();
        wrapper.find('form').simulate('submit', {
      target: {
        jobPosterId: {
            value: '12312jkh3kj12h3k12h321g3',
        },
        title: {
          value: 'some value',
        },
        location: {
          value: 'some value',
        },
        department: {
            value: 'Customer',
        },
        jobDescription: {
            value: 'This is Job description.',
        },
      },
    });
        expect(onPostJobSubmit).toHaveBeenCalled();
    });

该代码运行良好,但测试失败并出现以下错误。

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

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

       98 |       },
       99 |     });
    > 100 |         expect(onPostJobSubmit).toHaveBeenCalled();
          |                                 ^
      101 |     });
      102 | });
      103 |

      at Object.<anonymous> (src/components/employer/jobs/postJob.test.js:100:27)

这是postJob调度动作的函数的动作。

export const postJobAction = (payload) => {
  return {
    type: 'POST_JOB_REQUEST',
    payload,
  }
};

这里是传奇。

import { put, call } from 'redux-saga/effects';
import { postJobService } from '../services/postJobServices';

export function* postJobSaga(payload) {
  try {
    const response = yield call(postJobService, payload);
    yield [
      put({ type: 'POST_JOB_SUCCESS', response })
    ];
  } catch(error) {
    yield put({ type: 'POST_JOB_ERROR', error });
  }
}

这是服务。

import { getCookie } from '../../../../utils/cookies';

export const postJobService = (request) => {
  return fetch('http://localhost:3000/api/v1/employer/jobs', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': getCookie('token'),
      },
      body: JSON.stringify(request.payload)
    })
    .then(response => {
      return response.json();
    })
    .then(json => {
      return json;
    })
    .catch(error => {
      return error;
    });
};

知道如何解决这个问题吗?我是测试新手。

标签: javascriptreactjsjestjsenzymeredux-saga

解决方案


Don't mock the useDispatch hook, use redux-mock-store to mock the store.

A mock store for testing Redux async action creators and middleware. The mock store will create an array of dispatched actions which serve as an action log for tests.

Use jest.mock() to mock postJobService function used inside the worker saga postJobSaga. So that we will not call the real API service.

When the submit event is triggered, the actions dispatched are obtained via store.getActions(). Assert whether they meet expectations.

Since postJobSaga and postJobService are asynchronous, we need to wait for the execution of the postJobService promise to complete, so a flushPromise method is used to wait for the promise micro task to complete.

It's better to test the behavior of postJob instead of the specific implementation so that our test cases will not be so fragile, because the implementation may change, but the behavior will not. For example, use the functional component of react hooks to refactor class-based component, the implementation has changed, but the behavior remains unchanged.

E.g. (I removed the irrelevant code)

index.tsx:

import React from 'react';
import { useDispatch } from 'react-redux';
import { postJobAction } from './redux/postJobActions';

const PostJobComponent = () => {
  const dispatch = useDispatch();

  const postJob = (event) => {
    event.preventDefault();
    const title = event.target.title.value;
    const location = event.target.location.value;

    dispatch(postJobAction({ title, location }));
  };

  return (
    <div className="post-job">
      <form onSubmit={postJob}></form>
    </div>
  );
};

export default PostJobComponent;

./redux/postJobActions.ts:

export const postJobAction = (payload) => {
  return {
    type: 'POST_JOB_REQUEST',
    payload,
  };
};

./redux/postJobSaga.ts:

import { put, call, takeLatest } from 'redux-saga/effects';
import { postJobService } from '../services/postJobServices';

export function* postJobSaga(payload) {
  try {
    const response = yield call(postJobService, payload);
    yield put({ type: 'POST_JOB_SUCCESS', response });
  } catch (error) {
    yield put({ type: 'POST_JOB_ERROR', error });
  }
}

export function* watchPostJobSaga() {
  yield takeLatest('POST_JOB_REQUEST', postJobSaga);
}

./services/postJobServices.ts:

export const postJobService = (request) => {
  return fetch('http://localhost:3000/api/v1/employer/jobs', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(request.payload),
  })
    .then((response) => {
      return response.json();
    })
    .then((json) => {
      return json;
    })
    .catch((error) => {
      return error;
    });
};

index.test.tsx:

import { mount } from 'enzyme';
import React from 'react';
import { Provider } from 'react-redux';
import createMockStore from 'redux-mock-store';
import createSagaMiddleware from 'redux-saga';
import { mocked } from 'ts-jest/utils';
import PostJobComponent from './';
import { watchPostJobSaga } from './redux/postJobSaga';
import { postJobService } from './services/postJobServices';

const sagaMiddleware = createSagaMiddleware();
const mws = [sagaMiddleware];
const mockStore = createMockStore(mws);

jest.mock('./services/postJobServices');

const mockedPostJobService = mocked(postJobService);

function flushPromises() {
  return new Promise((resolve) => setImmediate(resolve));
}

describe('68233094', () => {
  it('should handle form submit', async () => {
    const store = mockStore({});
    sagaMiddleware.run(watchPostJobSaga);

    mockedPostJobService.mockResolvedValueOnce({ success: true });
    const wrapper = mount(
      <Provider store={store}>
        <PostJobComponent></PostJobComponent>
      </Provider>
    );
    wrapper.find('form').simulate('submit', {
      target: {
        title: { value: 'mocked title' },
        location: { value: 'mocked location' },
      },
    });
    await flushPromises();
    const actions = store.getActions();
    expect(actions).toEqual([
      {
        type: 'POST_JOB_REQUEST',
        payload: { title: 'mocked title', location: 'mocked location' },
      },
      { type: 'POST_JOB_SUCCESS', response: { success: true } },
    ]);
  });
});

test result:

 PASS  examples/68233094/index.test.tsx (12.307 s)
  68233094
    ✓ should handle form submit (43 ms)

---------------------|---------|----------|---------|---------|-------------------
File                 | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
---------------------|---------|----------|---------|---------|-------------------
All files            |   83.33 |      100 |   55.56 |   82.14 |                   
 68233094            |     100 |      100 |     100 |     100 |                   
  index.tsx          |     100 |      100 |     100 |     100 |                   
 68233094/redux      |   91.67 |      100 |     100 |   90.91 |                   
  postJobActions.ts  |     100 |      100 |     100 |     100 |                   
  postJobSaga.ts     |   88.89 |      100 |     100 |   88.89 | 9                 
 68233094/services   |   33.33 |      100 |       0 |      20 |                   
  postJobServices.ts |   33.33 |      100 |       0 |      20 | 2-16              
---------------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        14.333 s

推荐阅读