发布于 2026-01-06 3 阅读
0

React/Redux 连接表单单元测试和集成测试完整指南

React/Redux 连接表单单元测试和集成测试完整指南

在我发表了关于如何使用 Jest 和 Enzyme 进行测试的最新文章后,收到了许多精彩的反馈和请求,因此我很乐意分享一些其他的测试用例。今天,我们将讨论如何测试与 Redux 连接的 React 表单,包括单元测试和集成测试。希望以下内容对您有所帮助。

单元测试与集成测试

在深入探讨这个主题之前,让我们先确保大家都了解一些基础知识。应用程序测试有很多不同的类型,但2018年的一项调查显示,自动化单元测试和集成测试位居榜首。

测试类型
为了更好地进行比较,我只选取两种主要的自动化测试方法。让我们来看看单元测试和集成测试的定义和特点:

自动化测试方法

考试准备:表格复习

在开始任何工作之前,你都想了解它的方方面面。你不想出现任何意外,也希望取得最佳结果。测试工作也是如此。因此,最好事先获取所有关于待测表单及其相关条件的信息。当然,还要确保你清楚具体需要测试哪些内容。

为了向您演示其工作原理,我选择了一个包含房产评估信息的表格。客户填写此表格是为了描述他们想要购买的房产。它非常简单——没有任何复杂的逻辑或必填字段,只有几个需要填写的字段。

请看下图:

moneypark-form

图片中看不到的唯一逻辑是,不同的字段会根据Property type字段中的选择而设置不同的值。例如:

  • 如果顾客选择“公寓”,他们会看到“楼层”、“停车条件”等选项。
  • 如果客户选择“房屋”,他们会看到“建筑面积”、“建筑标准”等选项。

接下来,我们深入了解表单的代码。表单的实现分为两部分:

测试与 Redux 连接的表单

testingforms-connected-with-redux

根据测试类型的不同,我会使用不同的流程来测试与 Redux 连接的表单。

单元测试,我使用浅渲染(而非深树渲染)和 Redux-mock-store 库。集成测试中,我使用挂载渲染(深树渲染)和一个实际的 Redux store。

对与 Redux 连接的表单进行单元测试

正如我上面提到的,单元测试我使用浅渲染。浅渲染是一种单层渲染,不会考虑被测组件内部的子组件。此外,被测组件也不会间接影响其子组件的行为。

Redux-mock-store是一个用于测试 action 逻辑的库,它提供了一个模拟的 Redux store。它易于启动和使用,并且不会影响 Redux store 本身。

开始测试之前,请务必配置表单。

以下是我导入的内容:

  • 渲染方法:Enzyme 的浅渲染器
  • 包含表单渲染所需的模拟数据。在下面的示例中,它是一个名为 djangoParamsChoices 的 JSON 文件,其中包含下拉选项的模拟数据。此数据在后端传递给上下文,并在前端通过自定义函数获取getDjangoParam
  • 包含表单视图本身
  • 导入商店模拟所需的其他工具
  • 导入测试所需的其他库(主要在编写特殊测试用例时需要)
import { shallow } from 'enzyme';
import djangoParamsChoices from 'tests/__mocks__/djangoParamsChoices';
import PropertySelfOwnedForm from '../PropertySelfOwnedForm';
import configureStore from 'redux-mock-store';
const snapshotDiff = require('snapshot-diff');
  • 使用空状态初始化 mockstore:
const initialState = {};
  • 设置默认属性(与测试表单的要求有所不同):

表单视图取决于属性类型;这就是我添加默认属性的原因。

const defaultProps = {
    propertyType: 1
};
  • 每次测试前模拟存储并渲染表单:

首先,借助 redux-mock-store 库配置模拟 store。

const mockStore = configureStore();
  • 使用“beforeEach”方法配置在每次测试运行之前执行的函数。
let store, PropertySelfOwnedFormComponentWrapper, PropertySelfOwnedFormComponent, receivedNamesList;

beforeEach(() => {
    store = mockStore(initialState);
    PropertySelfOwnedFormComponentWrapper = (props) => (
        <PropertySelfOwnedForm {...defaultProps} {...props} store={store} />
    );
    PropertySelfOwnedFormComponent = shallow(<PropertySelfOwnedFormComponentWrapper />).dive();
});

在函数内部,别忘了:

  • 每次测试后重置 store:store = mockStore(initialState)返回已配置的模拟 store 的实例。
  • 制作 Wrapper HOC,使其能够传递 store、defaultProps 和自定义 props,以用于特殊测试用例。

  • 使用该方法进行表单渲染,.dive()以接收更深一层的渲染表单结构。

如果没有 dive() 方法,ShallowWrapper 的代码如下:

<PropertySelfOwnedForm
     propertyType={1}
     onSubmit={[Function: mockConstructor]}
     onSubmitAndNavigate={[Function: mockConstructor]}
     onNavigate={[Function: mockConstructor]}
     store={{...}}
/>

以下是使用 dive() 方法后的效果:ShallowWrapperWithDiveMethod.js

编写单元测试用例

现在,你可以开始编写测试题了。请按照我的步骤操作,看看应该如何进行。

检查正在渲染的表单组件:

it('render connected form component', () => {
    expect(PropertySelfOwnedFormComponent.length).toEqual(1);
});

检查“房屋”房产类型下正确渲染的字段列表:

it('PropertySelfOwnedForm renders correctly with PropertyTypeHouse', () => {
    receivedNamesList = PropertySelfOwnedFormComponent.find('form').find('Col').children().map(node => node.props().name);
    const expectedNamesList = [
         'building_volume',
         'site_area',
         'building_volume_standard',
         'number_of_garages_house',
         'number_of_garages_separate_building',
         'number_of_parking_spots_covered',
         'number_of_parking_spots_uncovered'
    ];
    expect(receivedNamesList).toEqual(expect.arrayContaining(expectedNamesList));
});

创建快照以检查“房屋”类型房产的用户界面:

it('create snapshot for PropertySelfOwnedForm with PropertyTypeHouse fields', () => {
    expect(PropertySelfOwnedFormComponent).toMatchSnapshot();
});

此时,你肯定会问自己:“为什么同一种属性类型需要两个测试,既要测试快照,又要测试字段是否存在?” 原因如下:这两个测试有助于我们检查逻辑和用户界面。

  • 根据逻辑,我们应该收到一个预期的字段列表。
  • 根据用户界面,我们应该获得具有其自身设计的、具有明确顺序的字段。

这是我们从这两个测试中得到的结果:

  • 字段列表/用户界面无变化 -> 两项测试均通过
  • 字段列表无变化/用户界面发生变化 -> 快照测试失败,即用户界面已更改。
  • 字段列表更改/用户界面更改 -> 两项测试均失败,即逻辑失败(或逻辑和用户界面均失败),因为字段列表与预期不同。

经过两次测试,我清楚地看到了问题所在,也知道应该从哪里查找失败原因。我使用另一种房产类型——“公寓”及其预期的字段数组重复了上述过程。我遵循相同的步骤:
检查房产类型“公寓”正确渲染的字段列表:

it('PropertySelfOwnedForm renders correctly with PropertyTypeApartment', () => {
    const props = {
            propertyType: 10
        },
        PropertySelfOwnedFormComponent = shallow(<PropertySelfOwnedFormComponentWrapper {...props} />).dive();
    const receivedNamesList = PropertySelfOwnedFormComponent.find('form').find('Col').children().map(node => node.props().name);
    const expectedNamesList = [
        'number_of_apartments',
        'floor_number',
        'balcony_terrace_place',
        'apartments_number_of_outdoor_parking_spaces',
        'apartments_number_of_garages',
        'apartments_number_of_garages_individual'
    ];
    expect(receivedNamesList).toEqual(expect.arrayContaining(expectedNamesList));

创建快照以检查“公寓”房产类型的字段:

it('create snapshot for PropertySelfOwnedForm with PropertyTypeApartment fields', () => {
    const props = {
            propertyType: 10
        },
        PropertySelfOwnedFormComponent = shallow(<PropertySelfOwnedFormComponentWrapper {...props} />).dive();
    expect(PropertySelfOwnedFormComponent).toMatchSnapshot();
});

下一个测试是实验性的。我决定研究一下最近一篇文章的一位读者推荐的Jest 差异快照工具。

首先,我们来看看它是如何工作的。它接收两个具有不同状态或属性的已渲染组件,并将它们的差异以字符串形式输出。在下面的示例中,我创建了一个快照,显示了具有不同属性类型('House' 和 'Apartment')的表单之间的差异。

it('snapshot difference between 2 React forms state', () => {
    const props = {
            propertyType: 10
        },
        PropertySelfOwnedFormComponentApartment = shallow(<PropertySelfOwnedFormComponentWrapper {...props} />).dive();
    expect(
        snapshotDiff(
            PropertySelfOwnedFormComponent,
            PropertySelfOwnedFormComponentApartment
        )
    ).toMatchSnapshot();
});

这种测试方法有其优势。如上所示,它涵盖了两个快照,并最大限度地减少了代码量——因此,您无需创建两个快照,只需创建一个快照来显示差异即可;同样地,也只需编写一个测试用例,而不是两个。它非常易于使用,并且允许您使用一个测试用例来覆盖不同的状态。但是,就我的情况而言,我得到了一个包含 2841 行的快照,如GitHub所示。面对如此庞大的代码量,很难看出测试失败的原因和位置。

这只能证明一件事:无论你使用什么工具和库,都要明智地使用,并且只在真正需要的地方使用。这个工具可能有助于测试无状态组件之间的差异,从而发现 UI 不一致之处,以及定义包含最少逻辑条件的简单功能组件之间的差异。但对于测试大型 UI 组件来说,它似乎并不合适。

在结束关于 Redux 表单单元测试的部分之前,还有一件事需要说明。我没有包含事件测试是有原因的。让我们来看一下包含ButtonsToolbar.js组件的表单结构 PropertySelfOwnedForm.js。

import ButtonsToolbar from 'shared/widgets/buttons/ButtonsToolbar';
<ButtonsToolbar {...restProps} />

这个组件包含三个按钮:“保存”、“保存并下一步”和“下一步”,并被用于多种表单中。浅渲染不包含子组件,而且目前我也不关心它们的功能。渲染后的ButtonsToolbar组件如下所示:

<ButtonsToolbar
      onNavigate={[MockFunction]}
      onSubmit={[MockFunction]}
      onSubmitAndNavigate={[MockFunction]}
      store={
        Object {
          "clearActions": [Function],
          "dispatch": [Function],
          "getActions": [Function],
          "getState": [Function],
          "replaceReducer": [Function],
          "subscribe": [Function],
        }
      }
    />

事实上,我不需要将其作为单元测试的一部分进行测试。我会在ButtonsToolbar.test.js中单独测试按钮事件。您可以在这里找到完整的测试清单PropertySelfOwnedFormUnit.test.js

对与 Redux 连接的表单进行集成测试

对于集成测试(即在工作环境中测试组件),我使用挂载渲染。挂载渲染是一种深层渲染,它通过将所有子组件挂载到 DOM 中来包含它们。

这种渲染方式实际上与真实的 DOM 树非常相似,因为其组件的行为是相互关联的。而集成测试的目标正是检验这种关联性。因此,在这种情况下,使用 Redux store 是一个不错的选择。

真正的 Redux store 是借助redux库创建的。在这种情况下,无需进行任何模拟,因为您可以像在应用程序中一样使用真实的 store。

接下来,我要配置表单以进行测试。

以下是进口清单:

  • 渲染方法:Enzyme 的 mount 渲染器
  • Redux 中用于创建 store 和将 reducers 合并成单个根 reducer 的方法
  • 来自 react-redux 库的提供程序,用于使 store 可供包装在 connect() 函数中的嵌套组件使用。
  • 来自 react-router-dom 的路由,用于提供 React Router 导航
  • Redux-form 用于更好地管理表单的 Redux 状态
  • propertyDetailsResource 是一个具有命名空间和端点的对象。
  • 包含名为 djangoParamsChoices 的 json 文件,其中包含从后端传递的模拟数据。
  • 包含表单视图本身
import { mount } from 'enzyme';
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { reduxForm, reducer as formReducer } from 'redux-form';

import propertyDetailsResource from 'store/propertyDetailsResource';
import djangoParamsChoices from 'tests/__mocks__/djangoParamsChoices';
import PropertySelfOwnedForm from '../PropertySelfOwnedForm';

然后,我准备用于测试的数据。为此,需要牢记以下几点:

  • 单元测试和集成测试的默认属性配置有所不同:

  • 通过集成测试,会将具有实际端点的资源添加到 defaultProps 中。

  • 模拟函数 handleSubmit 由 'redux-form' 提供,因为 Redux-Form 会使用 handleSubmit 属性装饰组件。

  • 三个用于自定义按钮提交事件的模拟函数。

  • 商店的创建方式与应用程序中的方式相同。

  • 导入的表单使用 reduxForm 进行装饰。

  • 装饰后的表单由路由器和提供程序包裹。

如果这样对您来说更容易理解,那么集成测试的数据准备顺序与 Redux 表单集成期间的操作顺序相同。

global.getDjangoParam = () => djangoParamsChoices;

let PropertySelfOwnedFormComponent;
const history = {
        push: jest.fn(),
        location: {
            pathname: '/en/data-collection/property-valuation/'
        },
        listen: () => {}
    },
    defaultProps = {
        propertyType: 1,
        resource: propertyDetailsResource,
        handleSubmit: (fn) => fn,
        onSubmit: jest.fn(),
        onSubmitAndNavigate: jest.fn(),
        onNavigate: jest.fn()
    },
    store = createStore(combineReducers({ form: formReducer })),
    Decorated = reduxForm({
        form: 'property-details-form'
    })(PropertySelfOwnedForm),
    PropertySelfOwnedFormComponentWrapper = (props) => (
        <Provider store={store}>
            <Router history={history}>
                <Decorated {...defaultProps} {...props} />
            </Router>
        </Provider>
    );

每次测试前渲染表单:

beforeEach(() => {
    PropertySelfOwnedFormComponent = mount(
        <PropertySelfOwnedFormComponentWrapper />
    );
});

编写集成测试的测试用例

现在,我们开始实际编写代码。第一步是创建两种房产类型的快照。这意味着,首先,您需要创建一个快照来检查“房屋”房产类型的字段:

it('PropertySelfOwnedForm renders correctly with PropertyTypeHouse', () => {
    expect(PropertySelfOwnedFormComponent).toMatchSnapshot();
});

接下来,创建一个快照来检查“公寓”房产类型的字段:

it('PropertySelfOwnedForm renders correctly with PropertyTypeApartment', () => {
    const props = {
            propertyType: 10
        },
        PropertyTypeApartmentWrapper = mount(<PropertySelfOwnedFormComponentWrapper {...props} />);
    expect(PropertyTypeApartmentWrapper).toMatchSnapshot();
});

如果表单处于初始状态或正在提交状态,则表单按钮将被禁用。以下测试检查“保存”按钮是否会响应表单更改并在失去初始状态后变为可用状态:

it('check if `Save` button react to form changes', () => {
    expect(PropertySelfOwnedFormComponent.find('button.button--accent').props().disabled).toEqual(true);
    const streetNumberField = PropertySelfOwnedFormComponent.find('input[name="street_number"]');
    streetNumberField.simulate('change', { target: {value: '10'} });
    expect(PropertySelfOwnedFormComponent.find('button.button--accent').props().disabled).toEqual(false);
});

最后三个测试检查通过单击 onSubmit、onSubmitAndNavigate 或 onNavigate 按钮调用的事件。

检查是否调用了 onSubmit 事件:

it('check event on `Save` button', () => {
    const streetNumberField = PropertySelfOwnedFormComponent.find('input[name="street_number"]');
    streetNumberField.simulate('change', { target: {value: '10'} });

    const propertySelfOwnedFormButton = PropertySelfOwnedFormComponent.find('button.button--accent');
    propertySelfOwnedFormButton.simulate('click');
    expect(defaultProps.onSubmit).toHaveBeenCalled();
});

检查是否调用了 onSubmitAndNavigate 事件:

it('check event on `Save & continue` button', () => {
    const streetNumberField = PropertySelfOwnedFormComponent.find('input[name="street_number"]');
    streetNumberField.simulate('change', { target: {value: '10'} });

    const propertySelfOwnedFormButton = PropertySelfOwnedFormComponent.find('button.button--secondary').at(0);
    propertySelfOwnedFormButton.simulate('click');
    expect(defaultProps.onSubmitAndNavigate).toHaveBeenCalled();
});

检查是否调用了 onNavigate 事件:

it('check event on `Next` button', () => {
    const propertySelfOwnedFormButton = PropertySelfOwnedFormComponent.find('button.button--secondary').at(1);
    propertySelfOwnedFormButton.simulate('click');
    expect(defaultProps.onNavigate).toHaveBeenCalled();
});

完整测试列表PropertySelfOwnedFormIntegration.test.js

现在表单已完成全面测试,包括内部组件的渲染。

总之,我想说单元测试和集成测试同等重要。每种测试都有其自身的作用和目的。忽视其中任何一种都可能导致日后大量的故障排除工作。

单元测试主要覆盖用户界面,而集成测试则深入挖掘功能。有些人认为两者都做是多余的,但我认为,如果你希望产品外观美观、易于使用且运行流畅,两者都必不可少。单靠单元测试永远无法覆盖产品最重要的部分——组件之间的交互。此外,防患于未然总是好的。

在测试方面,表单需要特别关注,因为表单是许多项目的重要组成部分,也是与客户沟通的重要途径。因此,做好充分准备并仔细完成所有步骤至关重要——导入、模拟数据准备、创建 store、使用 Redux 进行表单装饰以及创建正确的包装器。但测试本身并不复杂。在大多数情况下,它们遵循表单逻辑,反映字段更改和按钮模拟(在集成测试中)。

感谢您抽出时间。我们期待您的反馈!

这篇关于React/Redux 表单单元测试和集成测试的教程由Django Stars的前端开发人员 Alyona Pysarenko 撰写,最初
发表于Django Stars 博客

文章来源:https://dev.to/django_stars/complete-guide-on-unit-and-integration-testing-of-redux-react-connected-forms-3324