TA的每日心情 | 无聊 昨天 09:05 |
---|
签到天数: 1050 天 连续签到: 1 天 [LV.10]测试总司令
|
1. React函数式组件
fb团队推荐使用函数式组件进行开发, 但是函数是无状态的, 用class组件不香嘛, 自带state状态, 为什么要换写法??
某乎上这个问题回答的很好
为什么 React 现在要推行函数式组件,用 class 不好吗?
1)hooks是比HOC和render props更优雅的逻辑复用方式
state是一种外部数据
useState得到的状态,对于组件来说是一种外部传入的数据,和props、context没有本质的区别。useState声明的状态,实际由React内核进行维护,传递给函数式组件。hooks时代的函数式组件依然是【外部数据=>view】的纯函数。
Umi Hooks 阿里团队开源的hooks方法集, 可以说是hook界的lodash, 足以满足日常开发, 复杂业务也可自定义hook。
2)函数式组件的心智模型更加“声明式”
现在hooks直接给你一个声明副作用的API, 使得生命周期变成了一个"底层概念", 无需开发考虑, 开发者工作在更高的抽象层次上了。
3)纯函数组件对于开启"并发模式"是必备的条件
React渲染过程本质上是在:根据数据模型(应用状态)来计算出视图内容。
组件纯化以后,开发者编写的组件树其实就是 (应用状态)=>DOM结构 的纯函数。又因为应用状态实际由React内核进行维护,所以React内核可以维护多份数据模型,并发渲染多个版本的组件树。React开发者只需要编写纯函数,不需要关心如何应对这些并发渲染。
等等, 不是要说Jest测试嘛, 讲了这么多函数式编程是什么鬼?
因为编写单元[url=]测试[/url]和你的组件划分、逻辑复用、状态传递有很大关系, 如果你的编码不规范, 代码耦合度高, 比如这样:
- // antd
- {
- title: '状态',
- dataIndex: 'optionStatus',
- render: (value: number) => (value: number) => {
- if (value === 0) {
- return <Tag color="red">{PROPOSAL_RULE_ENABLE[0].label}</Tag>
- }
- if (value === 1) {
- return <Tag color="blue">{PROPOSAL_RULE_ENABLE[1].label}</Tag>
- }
- return <Tag color="blue">{PROPOSAL_RULE_ENABLE[2].label}</Tag>
- },
- width: 120,
- },
复制代码 在编写测试语句的时候if条件分支很容易测不全, 而且逻辑写在render方法里面, 需要在table里mock进去不同的数据, 也增加了编码工作。
打个比方, 写代码战斗力为80, 写[url=]单元测试[/url]战斗值120+, 首先要明白自己的代码逻辑, 才能写出好的测试语句。
而且在开发中要有意识地想到后面的测试怎么写, 顾头不顾尾很容易返工改代码, 当然, 你说我测不全无所谓, 一把梭哈干到底??, 这样也可以啦, 后面测试可能会多找你谈谈心。
这就引出了第二个问题, 如何根据产品原型划分我的组件, 代码结构怎么设计呢?
2. 组件划分
React + Typescript + Mobx + hooks
以上是我所在的部门, 日常开发所采用的[url=]技术[/url]栈, 举个例子:
按照以上产品原型, 拆分功能模块如下:
函数式组件编码开发:
- Overview
- Department.tsx //搜索部门组件
- helper.ts //增 删 改 查 变更记录 搜索,方法调用
- index.tsx //项目骨架
- List.tsx //table表格
- SearchForm.tsx //上部搜索组件
复制代码 我们在编写代码的时候, 对业务模块进行了充分的拆分, 每个功能放在单独的文件中, 入口文件没有太重的业务代码。
Department可以放在Component文件夹中, helper文件存放页面的调用方法, 以及和store的交互, 其余组件只是作为页面UI的渲染。
这里涉及到一个拆分粒度的问题, 确保每个文件解决某一单一问题, 比如数据格式化可以放在tool文件夹里, 页面打散之后, 单个文件涉及到的代码逻辑不会太重。在后续测试中, 把文件当作一个个函数, 只需要输入不同的参数即可测试。
3. 测试index入口文件
示例是带有tab切换的首页, 话不多说, 上代码:
- import React from 'react'
- ...
- const PromoRule = observer(() => {
- return (
- <Spin>
- <Tabs activeKey={PromoRule.tabValue.value} onChange={onChange}>
- <TabPane tab="销额提升" key="GMVPromote">
- <GmvPromote />
- </TabPane>
- <TabPane tab="ROI提升" key="ROIPromote">
- <ROIPromote />
- </TabPane>
- <TabPane tab="双提升" key="DoublePromote">
- <DoublePromote />
- </TabPane>
- </Tabs>
- </Spin>
- )
- })
- export default PromoRule
复制代码 测试代码:
- import React from 'react'
- import { mount } from 'enzyme'
- import { Provider } from 'mobx-react'
- import { Tabs } from 'antd'
- import { PromoRule } from 'page/PromoRules/OptionsCenter/Whitepaper/PromoRule'
- import { PromoRule as store } from 'store/PromoRule'
- ....
- const wrap = () =>
- mount(
- // 1.注入组件所需store
- <Provider store={store}>
- <PromoRule />
- </Provider>,
- )
- describe('page/PromoRules/OptionsCenter/WhitePaper/PromoRule', () => {
- it('测试可正确渲染页面结构', () => {
- const app = wrap()
- // 2.判断组件是否存在于页面中
- expect(app.find(GmvPromote)).toHaveLength(1)
- expect(app.find(ROIPromote)).toHaveLength(1)
- expect(app.find(DoublePromote)).toHaveLength(1)
- expect(app.find(Tabs).prop('onChange')).toBe(onChange)
- // 3.每个语句块执行完毕unmount()卸载下
- app.unmount()
- })
- it('测试tab切换显示正确', () => {
- const app = wrap()
- store.tabValue.set('ROIPromote')
- // 4. update()重新渲染页面,否则不生效
- app.update()
- expect(app.find(ROIPromote)).toHaveLength(1)
- app.unmount()
- })
- })
复制代码 4. 测试List表格组件
项目开发利用antd中的table组件, 数据都是从后端返回的, 我们只需要对table中的属性测试即可。
源代码:
- import { ThresholdDetailVO } from 'service/promoRule/definitions'
- import { randomRowKey } from 'tool/randomRowKey'
- ...
- const columns: IColumnProps<ThresholdDetailVO>[] = [
- {
- title: renderColTitle,
- dataIndex: 'thresholdValue',
- align: 'right',
- render: (value, record) => renderColValue(value, record),
- },
- {
- title: '订单数占比',
- dataIndex: 'cumOrdNumRatio',
- align: 'right',
- render: (value: number) => renderRatioValue(value),
- },
- {
- title: '订单金额占比',
- dataIndex: 'cumOrdAmountRatio',
- align: 'right',
- render: (value: number) => renderRatioValue(value),
- },
- {
- title: '订单数',
- dataIndex: 'cumOrdNum',
- align: 'right',
- render: (value: number) => renderNumValue(value),
- },
- {
- title: '订单金额',
- dataIndex: 'cumOrdAmount',
- align: 'right',
- render: (value: number) => renderNumValue(value),
- },
- ]
- export const List: React.FC = observer(() => {
- const resData = toJS(store.distributionList.value)
- return (
- <Table
- rowKey={randomRowKey}
- columns={columns}
- dataSource={resData.thresholdDetailList}
- loading={store.distributionList.fetching}
- pagination={{ showQuickJumper: true, showSizeChanger: true }}
- bordered
- size="middle"
- />
- )
- })
复制代码 注意: antd中table组件中的rowKey必须是唯一的, randomRowKey公共方法生成随机数, 否则控制台会报warning
测试代码:
- it('测试可正确请求数据', async () => {
- const {
- unitPriceDistribution: { distributionList },
- } = store
- // 1.mock一些假的参数
- await distributionList.fetch({
- body: {
- deptLevel: 2,
- deptId: '837',
- cidLevel: 12,
- cid: 'test',
- deptName: 'abc',
- },
- })
- // 2.mock后端返回数据
- runInAction(() => {
- distributionList.value.thresholdDetailList = [
- {
- thresholdValue: 12,
- thresholdName: 'nn',
- ordNum: 123,
- ordAmount: 500,
- ordNumRatio: 0.12,
- ordAmountRatio: 0.12,
- },
- ]
- })
- const app = wrap()
- const table = app.find(Table).at(0)
- // 3.测试Table组件的dataSource属性, 与你从后端取到的数据进行一个对比
- expect(table.prop('dataSource')).toEqual(distributionList.value.thresholdDetailList)
- distributionList.restore()
- app.unmount()
- })
- it('测试表格数据处理方法', () => {
- const app = wrap()
- const {
- unitPriceDistribution: { modalType },
- } = store
- modalType.set(3)
- // 4.render方法单独写在外面, 通过传入不同的数据, 测试渲染是否正确
- expect(renderColValue(0, { thresholdName: '5%~10%9-95折' })).toBe('5%~10% 9-95折')
- modalType.restore()
- expect(renderRatioValue(0.123)).toBe('12.3%')
- expect(renderRatioValue(0.77)).toBe('77%')
- expect(renderNumValue(6789.1234)).toBe('6,789')
- app.unmount()
- })
复制代码 最后,如果为了更加严谨,也可以测试一下每个单元格中渲染的数据是否和预期中的一致:
5. 测试SearchForm表单组件
源代码:
- it('测试表格可正确显示数据', () => {
- ....
- app.update()
- const table = app.find(Table)
- const tr = table.find('tr').at(1)
-
- expect(
- tr
- .find('td')
- .at(1)
- .text(),
- ).toBe('满100减20')
- // 5.这里应该用toBe, 比较数值和字符串, 比较对象和数组用toEqual (类比js值类型和引用类型)
- expect(
- tr
- .find('td')
- .at(2)
- .text(),
- ).toBe('高')
- })
复制代码 Form 表单有一些需要注意的点:
No.1 怎么样让测试语句也有form属性呢?
No.2 Form组件的实例是怎么获取的?
No.3 onSubmit提交时, 异步请求还没回来怎么办?
接下来, 让我们来一一解答。
No.1 使用Form.create()修饰器无法获取组件实例。
你一般会到用@Form.create()装饰器语法, 但是他可能会导致无法正常获取表单实例。
- @Form.create()
- @inject('store')
- @observer
- export class SearchForm extends React.Component<IProps, IState> {
- ...
- public render() {
- return (
- <Card>
- <Form onSubmit={this.submit}>
- ...
- </Form>
- </Card>
- )
- }
- }
- export default SearchForm
复制代码 因此,你需要把Form.create()修饰器改成函数式调用的方式:
- interface IProps { // 注意这里
- store?: {
- app: AppStore
- department: DepartmentStore
- promoRules: PromoRulesStore
- }
- form: WrappedFormUtils
- wrappedComponentRef?: any
- }
- @inject('store')
- @observer
- export class SearchForm extends React.Component<IProps, IState> {
- ...
- public render() {
- return (
- <Card>
- <Form onSubmit={this.submit}>
- ...
- </Form>
- </Card>
- )
- }
- }
- export default Form.create<IProps>()(SearchForm) // 答案在这里
复制代码 如果你现在使用的 TS,还需要把当前组件的props类型定义,传递给Form.create(),否则会抛出类型错误。因为antd要用它在内部进行更进一步的类型定义。
同理, 测试语句是这样的:
- import React from 'react'
- import { mount } from 'enzyme'
- import { Provider } from 'mobx-react'
- import { Form } from 'antd'
- const Comp = Form.create()(({ form }) => <SearchForm form={form} />)
- const wrap = () =>
- mount(
- <Provider store={store}>
- <Comp />
- </Provider>,
- )
复制代码 No.2 现在我们的Form组件已经有了this.props.form属性, 但是组件实例是怎么得到的呢, 哈哈, 聪明的你一定想到了 wrappedComponentRef 这个高阶组件。
在antd官网上面提到过, 使用 rc-form 提供的 wrappedComponentRef, 可以拿到 ref。
测试语句:
- let formInstance: any
- const wrapper = () =>
- mount(
- <Provider store={store}>
- <SearchForm
- // 看这里
- wrappedComponentRef={(formEle: any) => {
- formInstance = formEle
- }}
- />
- </Provider>,
- )
复制代码 使用wrappedComponentRef属性,指定一个回调函数,通过它的回调参数即可拿到当前的表单实例。
注意: 一般表单触发的submit方法都会有一个默认的回调参数对象,它里面包含了很多[url=]浏览器[/url]原生方法和属性(比如event)。
所以, 如果你的源代码中用到了相关的属性,我们在测试的时候必须对它进行一个模拟,否则源代码肯定会抛异常。
- formInstance.submit({
- preventDefault: jest.fn(), // jest.fn表示返回一个空函数
- })
复制代码 具体测试语句:
- it('测试submit方法', done => {
- const spy = jest.spyOn(formInstance, 'fetchData')
- // 这里是不是很react
- formInstance.props.form.setFieldsValue({
- deptId: undefined,
- })
-
- formInstance.submit({
- preventDefault: jest.fn(),
- })
- setTimeout(() => { // setTimeout, 这里我懂了
- expect(spy).not.toHaveBeenCalledTimes(1)
- done() // 这里是什么
- })
- })
复制代码 No.3 上面的代码正好回答了我们的第三个问题。
因为antd form对象上的setFieldsValue方法是异步的。所以,这里一般我会加一个setTimeout。否则,[url=]测试用例[/url]可能一直无法测试成功。
done 方法用于解决异步代码测试问题, 在一个异步语句中, 你的测试将会在调用回调之前完成。所以, 使用一个名为 done 的参数。jest将等待回调完成后进行测试。
6. 测试helper.ts
helper文件里存放的都是页面的交互方法, 如果你用的纯函数的化, 只需要传入不同数据, 测试即可。
源代码:
- // 这里需要有个event, 浏览器原生属性
- export const filter = (form: WrappedFormUtils) => (e: React.FormEvent<HTMLFormElement>) => {
- e.preventDefault() // 注意这里
- form.validateFields(async (err, values) => {
- if (!err) {
- const thresholdFilter = formatFormValuesToGroup(values)
- const query = {
- deptLevel: values.deptLevel,
- cidLevel: values.cidLevel,
- deptId: values.deptId,
- cid: values.cid,
- deptName: values.deptName,
- }
- if (isEmpty(values.cid)) {
- // 删除对象属性用es6中的Reflect
- Reflect.deleteProperty(query, 'cid')
- Reflect.deleteProperty(query, 'cidLevel')
- }
- await store.thresholdRange.fetch({ body: { ...query } })
- store.sliderScope.set(store.thresholdRange.value.minAndMaxThreshold as number[])
- }
- })
- }
复制代码 这里着重讲一下方法里面传入form属性时, 该如何写测试语句。
测试代码:
- it('filter,过滤金额门槛', async () => {
- const values = {
- promoDataTimeScope: 'half_year',
- deptLevel: 2,
- deptId: '837',
- cidLevel: 12,
- cid: '',
- deptName: 'abc',
- }
- const form = ({
- // 在ts里面validateFields是必传项, 这里mock一个假函数
- validateFields: jest.fn(cb => {
- // 模拟 没有错误提交
- cb(null, values)
- }),
- } as unknown) as WrappedFormUtils
- // 同理, event也需要mock
- const e = ({ preventDefault: jest.fn() } as unknown) as React.FormEvent<HTMLFormElement>
- // 这里spyOn后端的请求接口
- const spy = jest.spyOn(store.thresholdRange, 'fetch').mockImplementation(() => Promise.resolve() as Promise<any>)
- await filter(form)(e)
- expect(spy).toHaveBeenCalled() //
- expect(store.sliderScope.value).toEqual([])
- })
复制代码 注意: 上述代码有一个语句前后顺序问题
|
|