React/Redux 连接表单单元测试和集成测试完整指南
在我发表了关于如何使用 Jest 和 Enzyme 进行测试的最新文章后,收到了许多精彩的反馈和请求,因此我很乐意分享一些其他的测试用例。今天,我们将讨论如何测试与 Redux 连接的 React 表单,包括单元测试和集成测试。希望以下内容对您有所帮助。
单元测试与集成测试
在深入探讨这个主题之前,让我们先确保大家都了解一些基础知识。应用程序测试有很多不同的类型,但2018年的一项调查显示,自动化单元测试和集成测试位居榜首。

为了更好地进行比较,我只选取两种主要的自动化测试方法。让我们来看看单元测试和集成测试的定义和特点:
考试准备:表格复习
在开始任何工作之前,你都想了解它的方方面面。你不想出现任何意外,也希望取得最佳结果。测试工作也是如此。因此,最好事先获取所有关于待测表单及其相关条件的信息。当然,还要确保你清楚具体需要测试哪些内容。
为了向您演示其工作原理,我选择了一个包含房产评估信息的表格。客户填写此表格是为了描述他们想要购买的房产。它非常简单——没有任何复杂的逻辑或必填字段,只有几个需要填写的字段。
请看下图:
图片中看不到的唯一逻辑是,不同的字段会根据Property type字段中的选择而设置不同的值。例如:
- 如果顾客选择“公寓”,他们会看到“楼层”、“停车条件”等选项。
- 如果客户选择“房屋”,他们会看到“建筑面积”、“建筑标准”等选项。
接下来,我们深入了解表单的代码。表单的实现分为两部分:
- 模板文件 - 所有字段的列表;我们也可以称之为“视图”( GitHub 上的PropertySelfOwnedForm.js代码清单)
- 容器文件 - 表单逻辑,集中存储在一个地方(GitHub 上的PropertySelfOwnedFormContainer.js代码清单)
测试与 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 的实例。store = mockStore(initialState) -
制作 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 博客。


