Jest+Enzyme测试React组件(编码规范=>测试实例)

2,962

1. React函数式组件

fb团队推荐使用函数式组件进行开发, 但是函数是无状态的, 用class组件不香嘛, 自带state状态, 为什么要换写法😧

某乎上这个问题回答的很好

为什么 React 现在要推行函数式组件,用 class 不好吗?

1. hooks是比HOC和render props更优雅的逻辑复用方式

state是一种外部数据

useState得到的状态,对于组件来说是一种外部传入的数据,和props、context没有本质的区别。useState声明的状态,实际由React内核进行维护,传递给函数式组件。hooks时代的函数式组件依然是【外部数据=>view】的纯函数。

Umi Hooks 阿里团队开源的hooks方法集(现已升级为ahooks), 可以说是hook界的lodash, 足以满足日常开发, 复杂业务也可自定义hook

ahooks

2. 函数式组件的心智模型更加“声明式”

现在hooks直接给你一个声明副作用的API, 使得生命周期变成了一个"底层概念", 无需开发考虑, 开发者工作在更高的抽象层次上了

3. 纯函数组件对于开启"并发模式"是必备的条件

React渲染过程本质上是在:根据数据模型(应用状态)来计算出视图内容。

组件纯化以后,开发者编写的组件树其实就是 (应用状态)=>DOM结构 的纯函数。又因为应用状态实际由React内核进行维护,所以React内核可以维护多份数据模型,并发渲染多个版本的组件树。React开发者只需要编写纯函数,不需要关心如何应对这些并发渲染。

自从看完《JavaScript函数式编程指南》 感觉自己学到了屠龙术, 如果所有开发全部是函数式, 这个世界就会很美好😀, but, 直到遇见一个业务问题, 发现还是class组件实现更好一些😏, 少年, too naive

等等, 不是要说Jest测试嘛, 讲了这么多函数式编程是什么鬼😵

因为编写单元测试和你的组件划分、逻辑复用、状态传递有很大关系, 如果你的编码不规范, 代码耦合度高, 比如这样

// 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, 写单元测试战斗值120+, 首先要明白自己的代码逻辑, 才能写出好的测试语句。

而且在开发中要有意识地想到后面的测试怎么写, 顾头不顾尾很容易返工改代码, 当然, 你说我测不全无所谓, 一把梭哈干到底🙉, 这样也可以啦, 只是后面测试可能会多找你谈谈心😂

这就引出了第二个问题, 如何根据产品原型划分我的组件, 代码结构怎么设计呢🤔

2. 组件划分

React + Typescript + Mobx + hooks

以上是我所在的部门, 日常开发所采用的技术栈, 举个例子🌰

Markdown

按照以上产品原型, 拆分功能模块如下:

Markdown

函数式组件编码开发:

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()
  })
})

如果你觉得每次都要wrap()unmount()好麻烦,可以这样写

import * as React from 'react'
import { mount } from 'enzyme'
import { Provider } from 'mobx-react'
...

describe('page/PromoRules/Overview/AmountModal', () => {
  const wrap = () =>
    mount(
      <Provider store={store}>
        <AmountModal />
      </Provider>,
    )
  // 1. 类型定义  
  let app: ReturnType<typeof mount>
  
  beforeEach(() => {
  	// 2. 注意下面语句的先后顺序
    promoOverview.amountModal.show()
    app = wrap()
  })
  // 3. beforeEach & afterEach
  afterEach(() => {
    app.unmount()
    // 4. 模态框modal
    promoOverview.amountModal.hide()
  })

  it('测试包含必要的子组件', () => {
    expect(app.find(ExportData)).toHaveLength(1)
    ...
  })
})

beforeEachafterEach是在每条it语句前后执行,用于多次重复测试;与此相对应的还有beforeAllafterAll,是在所有语句前后执行。

作用域:当beforeafter的语句在describe块内部的时候,则只适用于该describe块内的测试。

注意: 在模态框的测试中,一定要先show以下,否则是测不到的😂

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"
    />
  )
})

注意: antdtable组件中的rowKey必须是唯一的, 一般可以用后端返回的id, 也可以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()
  })

最后,如果为了更加严谨,也可以测试一下每个单元格中渲染的数据是否和预期中的一致:

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('高')
})

5. 测试SearchForm表单组件

源代码:

import { Form } from 'antd'
import { WrappedFormUtils } from 'antd/lib/form/Form'
import * as React from 'react'

import { fetchList } from './RuleWhitepaper/helper'
import { DepartmentTree } from 'component/DepartmentTree'
...

interface IProps {
  form: WrappedFormUtils
}

export const SearchForm = (props: IProps) => {
  const { form } = props

  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    form.validateFields((error, values) => {
      fetchList(values)
      ...
    })
  }

  return (
    <Form layout="horizontal" onSubmit={onSubmit} {...FORM_ITEM_LAYOUT}>
      <ColWrapper>
        <DepartmentTree form={form} disallowSelectTopLevel multiple={false} selectableByChecked />
      </ColWrapper>
      <SubmitButton form={form} />
    </Form>
  )
}

export default Form.create()(SearchForm)

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

antd Form

测试语句:

let formInstance: any

const wrapper = () =>
  mount(
    <Provider store={store}>
      <SearchForm
        // 看这里
        wrappedComponentRef={(formEle: any) => { 
          formInstance = formEle
        }}
      />
    </Provider>,
  )

使用wrappedComponentRef属性,指定一个回调函数,通过它的回调参数即可拿到当前的表单实例。

注意: 一般表单触发的submit方法都会有一个默认的回调参数对象,它里面包含了很多浏览器原生方法和属性。(比如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。否则,测试用例可能一直无法测试成功。

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[])
    }
  })
}

React的合成事件系统只是原生DOM事件系统的一个子集, 它仅仅实现了DOM level3的事件接口, 并且统一了浏览器间的兼容性问题。

有些事件React并没有实现, 或者受某些限制没办法去实现, 比如window的resize事件。

《深入React技术栈》

这里着重讲一下方法里面传入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([])
})

注意: 上述代码有一个语句前后顺序问题

const spy = jest.spyOn...
expect(spy).toHaveBeenCalled()

jest.spyOn 一定要写在前面, 否则测试不通过, 因为 store.thresholdRange 还没有被请求。

// error
expected: >=1
received: 0

7. 其他知识点

至此, 常见的组件测试方法都已经讲完了, 下面说下其他需要注意的点:

  • 上面写的都是函数式组件测试方法, 如果是class组件, 该如何做呢, 其实原理都是一样的, 好多示例代码都有了, 你只需要借(fu)鉴(zhi)下就ok了😏

这里讲下class组件里面的方法是怎么测试的

源代码:

import { observer } from 'mobx-react'

@observer
export class BatchUpload extends React.Component<IProps> {
  // 注意这里应该用public, private测试无法取到
  public showBatchModal = () => { 
    this.setState({
      visible: true,
    })
  }

  public render() {
    const {
      form: { getFieldDecorator },
    } = this.props
    const { spinning } = this.state

    return (
      <>
        <Modal
          title="批量导入"
          onOk={this.handleOk}
          visible={this.state.visible}
          footer={this.renderFooter()}
          destroyOnClose
        >
          <Spin spinning={spinning}>
            <Form layout="horizontal">
              ...
            </Form>
          </Spin>
        </Modal>
      </>
    )
  }
}

export default Form.create<IProps>()(BatchUpload)

测试代码:

import { BatchUpload } from 'page/PromoRules/BatchUpload'

it('测试showBatchModal方法', () => {
  const app = wrapper()
  // 看这里
  const instance = app.find(BatchUpload).instance() as BatchUpload 
  instance.showBatchModal()

  expect(instance.state.visible).toBeTruthy()
  app.unmount()
})

看到没, 只需要find找到这个组件,然后 instance 实例化class就可以了, so easy

  • 在函数式组件里面, useEffect这个hook是在页面初始化就执行了, 所以在测试的时候, 应该mount
const Comp: React.FC = () => {
  React.useEffect(() => {
    store.previewSpec.fetch()
  }, [])
  return <br />
}

it('测试可正确渲染页面结构', () => {
  runInAction(() => {
    router.history.location.pathname = '/preview'
  })
  mount(<Comp />)
  expect(app.find(Descriptions.Item)).toHaveLength(5)
})
  • 下面这个报错是因为没有jest.fn, 直接请求了实际的url
// error
only absolute urls are supported
  • 每写完一个测试文件, 都可以运行--coverage命令, 查看分支或者语句的覆盖率, 也可以定位到某个文件夹, 查看模块的覆盖率。
npx jest page/PromoRules/BatchUpload.test.tsx --coverage

8. 小结

以上是自己在开发过程中的一些常见测试方法, 然而实际工作中, 好多人不喜欢写测试, 大部分时间都是点点点, 程序无问题, 上线吧😀 后面测试出来好多bug, (我以前也是这么想的😂)

后来, 在写jest测试过程中, 确实能够发现自己程序的bug, 而且会反推思考自己的代码逻辑, 这是一个相互促进的过程, 不管是在保证代码质量还是以后面试, 写出覆盖率高、通过率高的单元测试也是一个加分项。

最后说一下, 测试覆盖率的问题

bad:

Markdown

good:

Markdown

尽量保证代码全都覆盖到(强迫症😆), 在实际开发中其实很难, 包括日常开发进度, 还有重复编码问题。

但是, 你不觉得一片绿油油的很爽嘛😎

PS: 如果觉得这篇文章帮到了你, 请手动点赞, 感谢~