51Testing软件测试论坛

 找回密码
 (注-册)加入51Testing

QQ登录

只需一步,快速开始

微信登录,快人一步

手机号码,快捷登录

查看: 4999|回复: 2
打印 上一主题 下一主题

[转贴] 编写单元测试,规范很重要

[复制链接]
  • TA的每日心情
    无聊
    4 天前
  • 签到天数: 1050 天

    连续签到: 1 天

    [LV.10]测试总司令

    跳转到指定楼层
    1#
    发表于 2020-9-18 10:21:07 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
     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]和你的组件划分、逻辑复用、状态传递有很大关系, 如果你的编码不规范, 代码耦合度高, 比如这样:

    1. // antd

    2. {

    3.   title: '状态',

    4.   dataIndex: 'optionStatus',

    5.   render: (value: number) => (value: number) => {

    6.     if (value === 0) {

    7.       return <Tag color="red">{PROPOSAL_RULE_ENABLE[0].label}</Tag>

    8.     }

    9.     if (value === 1) {

    10.       return <Tag color="blue">{PROPOSAL_RULE_ENABLE[1].label}</Tag>

    11.     }

    12.     return <Tag color="blue">{PROPOSAL_RULE_ENABLE[2].label}</Tag>

    13.   },

    14.   width: 120,

    15. },
    复制代码
    在编写测试语句的时候if条件分支很容易测不全, 而且逻辑写在render方法里面, 需要在table里mock进去不同的数据, 也增加了编码工作。
      打个比方, 写代码战斗力为80, 写[url=]单元测试[/url]战斗值120+, 首先要明白自己的代码逻辑, 才能写出好的测试语句。
      而且在开发中要有意识地想到后面的测试怎么写, 顾头不顾尾很容易返工改代码, 当然, 你说我测不全无所谓, 一把梭哈干到底??, 这样也可以啦, 后面测试可能会多找你谈谈心。
      这就引出了第二个问题, 如何根据产品原型划分我的组件, 代码结构怎么设计呢?
      2. 组件划分
      React + Typescript + Mobx + hooks
      以上是我所在的部门, 日常开发所采用的[url=]技术[/url]栈, 举个例子:


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

    函数式组件编码开发:

    1. Overview

    2.    Department.tsx //搜索部门组件

    3.    helper.ts //增 删 改 查 变更记录 搜索,方法调用

    4.    index.tsx //项目骨架

    5.    List.tsx //table表格

    6.    SearchForm.tsx //上部搜索组件
    复制代码
    我们在编写代码的时候, 对业务模块进行了充分的拆分, 每个功能放在单独的文件中, 入口文件没有太重的业务代码。
      Department可以放在Component文件夹中, helper文件存放页面的调用方法, 以及和store的交互, 其余组件只是作为页面UI的渲染。
      这里涉及到一个拆分粒度的问题, 确保每个文件解决某一单一问题, 比如数据格式化可以放在tool文件夹里, 页面打散之后, 单个文件涉及到的代码逻辑不会太重。在后续测试中, 把文件当作一个个函数, 只需要输入不同的参数即可测试。
      3. 测试index入口文件
      示例是带有tab切换的首页, 话不多说, 上代码:


    1. import React from 'react'

    2. ...

    3. const PromoRule = observer(() => {

    4.   return (

    5.     <Spin>

    6.       <Tabs activeKey={PromoRule.tabValue.value} onChange={onChange}>

    7.         <TabPane tab="销额提升" key="GMVPromote">

    8.           <GmvPromote />

    9.         </TabPane>

    10.         <TabPane tab="ROI提升" key="ROIPromote">

    11.           <ROIPromote />

    12.         </TabPane>

    13.         <TabPane tab="双提升" key="DoublePromote">

    14.           <DoublePromote />

    15.         </TabPane>

    16.       </Tabs>

    17.     </Spin>

    18.   )

    19. })




    20. export default PromoRule
    复制代码
    测试代码:

    1. import React from 'react'

    2. import { mount } from 'enzyme'

    3. import { Provider } from 'mobx-react'

    4. import { Tabs } from 'antd'




    5. import { PromoRule } from 'page/PromoRules/OptionsCenter/Whitepaper/PromoRule'

    6. import { PromoRule as store } from 'store/PromoRule'

    7. ....




    8. const wrap = () =>

    9.   mount(

    10.     // 1.注入组件所需store

    11.     <Provider store={store}>  

    12.       <PromoRule />

    13.     </Provider>,

    14.   )




    15. describe('page/PromoRules/OptionsCenter/WhitePaper/PromoRule', () => {

    16.   it('测试可正确渲染页面结构', () => {

    17.     const app = wrap()

    18.     // 2.判断组件是否存在于页面中

    19.     expect(app.find(GmvPromote)).toHaveLength(1)

    20.     expect(app.find(ROIPromote)).toHaveLength(1)

    21.     expect(app.find(DoublePromote)).toHaveLength(1)

    22.     expect(app.find(Tabs).prop('onChange')).toBe(onChange)

    23.     // 3.每个语句块执行完毕unmount()卸载下

    24.     app.unmount()

    25.   })




    26.   it('测试tab切换显示正确', () => {

    27.     const app = wrap()

    28.     store.tabValue.set('ROIPromote')

    29.     // 4. update()重新渲染页面,否则不生效

    30.     app.update()

    31.     expect(app.find(ROIPromote)).toHaveLength(1)

    32.     app.unmount()

    33.   })

    34. })
    复制代码
    4. 测试List表格组件
      项目开发利用antd中的table组件, 数据都是从后端返回的, 我们只需要对table中的属性测试即可。
      源代码:


    1. import { ThresholdDetailVO } from 'service/promoRule/definitions'

    2. import { randomRowKey } from 'tool/randomRowKey'

    3. ...




    4. const columns: IColumnProps<ThresholdDetailVO>[] = [

    5.   {

    6.     title: renderColTitle,

    7.     dataIndex: 'thresholdValue',

    8.     align: 'right',

    9.     render: (value, record) => renderColValue(value, record),

    10.   },

    11.   {

    12.     title: '订单数占比',

    13.     dataIndex: 'cumOrdNumRatio',

    14.     align: 'right',

    15.     render: (value: number) => renderRatioValue(value),

    16.   },

    17.   {

    18.     title: '订单金额占比',

    19.     dataIndex: 'cumOrdAmountRatio',

    20.     align: 'right',

    21.     render: (value: number) => renderRatioValue(value),

    22.   },

    23.   {

    24.     title: '订单数',

    25.     dataIndex: 'cumOrdNum',

    26.     align: 'right',

    27.     render: (value: number) => renderNumValue(value),

    28.   },

    29.   {

    30.     title: '订单金额',

    31.     dataIndex: 'cumOrdAmount',

    32.     align: 'right',

    33.     render: (value: number) => renderNumValue(value),

    34.   },

    35. ]




    36. export const List: React.FC = observer(() => {

    37.   const resData = toJS(store.distributionList.value)

    38.   return (

    39.     <Table

    40.       rowKey={randomRowKey}

    41.       columns={columns}

    42.       dataSource={resData.thresholdDetailList}

    43.       loading={store.distributionList.fetching}

    44.       pagination={{ showQuickJumper: true, showSizeChanger: true }}

    45.       bordered

    46.       size="middle"

    47.     />

    48.   )

    49. })
    复制代码
    注意: antd中table组件中的rowKey必须是唯一的, randomRowKey公共方法生成随机数, 否则控制台会报warning
      测试代码:


    1. it('测试可正确请求数据', async () => {

    2.     const {

    3.       unitPriceDistribution: { distributionList },

    4.     } = store

    5.     // 1.mock一些假的参数

    6.     await distributionList.fetch({

    7.       body: {

    8.         deptLevel: 2,

    9.         deptId: '837',

    10.         cidLevel: 12,

    11.         cid: 'test',

    12.         deptName: 'abc',

    13.       },

    14.     })

    15.     // 2.mock后端返回数据

    16.     runInAction(() => {

    17.       distributionList.value.thresholdDetailList = [

    18.         {

    19.           thresholdValue: 12,

    20.           thresholdName: 'nn',

    21.           ordNum: 123,

    22.           ordAmount: 500,

    23.           ordNumRatio: 0.12,

    24.           ordAmountRatio: 0.12,

    25.         },

    26.       ]

    27.     })

    28.     const app = wrap()

    29.     const table = app.find(Table).at(0)

    30.     // 3.测试Table组件的dataSource属性, 与你从后端取到的数据进行一个对比

    31.     expect(table.prop('dataSource')).toEqual(distributionList.value.thresholdDetailList)

    32.     distributionList.restore()

    33.     app.unmount()

    34.   })




    35.   it('测试表格数据处理方法', () => {

    36.     const app = wrap()

    37.     const {

    38.       unitPriceDistribution: { modalType },

    39.     } = store

    40.     modalType.set(3)

    41.     // 4.render方法单独写在外面, 通过传入不同的数据, 测试渲染是否正确

    42.     expect(renderColValue(0, { thresholdName: '5%~10%9-95折' })).toBe('5%~10% 9-95折')

    43.     modalType.restore()

    44.     expect(renderRatioValue(0.123)).toBe('12.3%')

    45.     expect(renderRatioValue(0.77)).toBe('77%')

    46.     expect(renderNumValue(6789.1234)).toBe('6,789')

    47.     app.unmount()

    48.   })
    复制代码
    最后,如果为了更加严谨,也可以测试一下每个单元格中渲染的数据是否和预期中的一致:
      5. 测试SearchForm表单组件
      源代码:


    1. it('测试表格可正确显示数据', () => {

    2.   ....

    3.   app.update()

    4.   const table = app.find(Table)

    5.   const tr = table.find('tr').at(1)

    6.   

    7.   expect(

    8.     tr

    9.     .find('td')

    10.     .at(1)

    11.     .text(),

    12.   ).toBe('满100减20')

    13.   // 5.这里应该用toBe, 比较数值和字符串, 比较对象和数组用toEqual (类比js值类型和引用类型)




    14.   expect(

    15.     tr

    16.     .find('td')

    17.     .at(2)

    18.     .text(),

    19.   ).toBe('高')

    20. })
    复制代码
    Form 表单有一些需要注意的点:
      No.1 怎么样让测试语句也有form属性呢?
      No.2 Form组件的实例是怎么获取的?
      No.3 onSubmit提交时, 异步请求还没回来怎么办?
      接下来, 让我们来一一解答。
      No.1 使用Form.create()修饰器无法获取组件实例。
      你一般会到用@Form.create()装饰器语法, 但是他可能会导致无法正常获取表单实例。


    1. @Form.create()

    2. @inject('store')

    3. @observer

    4. export class SearchForm extends React.Component<IProps, IState> {

    5.   ...

    6.   public render() {

    7.     return (

    8.       <Card>

    9.         <Form onSubmit={this.submit}>

    10.            ...

    11.         </Form>

    12.       </Card>

    13.     )

    14.   }

    15. }




    16. export default SearchForm
    复制代码
    因此,你需要把Form.create()修饰器改成函数式调用的方式:

    1. interface IProps { // 注意这里

    2.   store?: {

    3.     app: AppStore

    4.     department: DepartmentStore

    5.     promoRules: PromoRulesStore

    6.   }

    7.   form: WrappedFormUtils

    8.   wrappedComponentRef?: any

    9. }




    10. @inject('store')

    11. @observer

    12. export class SearchForm extends React.Component<IProps, IState> {

    13.   ...

    14.   public render() {

    15.     return (

    16.       <Card>

    17.         <Form onSubmit={this.submit}>

    18.            ...

    19.         </Form>

    20.       </Card>

    21.     )

    22.   }

    23. }




    24. export default Form.create<IProps>()(SearchForm) // 答案在这里
    复制代码
    如果你现在使用的 TS,还需要把当前组件的props类型定义,传递给Form.create(),否则会抛出类型错误。因为antd要用它在内部进行更进一步的类型定义。
      同理, 测试语句是这样的:


    1. import React from 'react'

    2. import { mount } from 'enzyme'

    3. import { Provider } from 'mobx-react'

    4. import { Form } from 'antd'




    5. const Comp = Form.create()(({ form }) => <SearchForm form={form} />)




    6. const wrap = () =>

    7.   mount(

    8.     <Provider store={store}>

    9.       <Comp />

    10.     </Provider>,

    11.   )
    复制代码
    No.2 现在我们的Form组件已经有了this.props.form属性, 但是组件实例是怎么得到的呢, 哈哈, 聪明的你一定想到了 wrappedComponentRef 这个高阶组件。
      在antd官网上面提到过, 使用 rc-form 提供的 wrappedComponentRef, 可以拿到 ref。
      测试语句:


    1. let formInstance: any




    2. const wrapper = () =>

    3.   mount(

    4.     <Provider store={store}>

    5.       <SearchForm

    6.         // 看这里

    7.         wrappedComponentRef={(formEle: any) => {

    8.           formInstance = formEle

    9.         }}

    10.       />

    11.     </Provider>,

    12.   )
    复制代码
    使用wrappedComponentRef属性,指定一个回调函数,通过它的回调参数即可拿到当前的表单实例。
      注意: 一般表单触发的submit方法都会有一个默认的回调参数对象,它里面包含了很多[url=]浏览器[/url]原生方法和属性(比如event)。
      所以, 如果你的源代码中用到了相关的属性,我们在测试的时候必须对它进行一个模拟,否则源代码肯定会抛异常。


    1. formInstance.submit({

    2.   preventDefault: jest.fn(), // jest.fn表示返回一个空函数

    3. })
    复制代码
    具体测试语句:

    1. it('测试submit方法', done => {

    2.   const spy = jest.spyOn(formInstance, 'fetchData')

    3.   // 这里是不是很react

    4.   formInstance.props.form.setFieldsValue({

    5.     deptId: undefined,

    6.   })

    7.   

    8.   formInstance.submit({

    9.     preventDefault: jest.fn(),

    10.   })




    11.   setTimeout(() => { // setTimeout, 这里我懂了

    12.     expect(spy).not.toHaveBeenCalledTimes(1)

    13.     done() // 这里是什么

    14.   })

    15. })
    复制代码
    No.3 上面的代码正好回答了我们的第三个问题。
      因为antd form对象上的setFieldsValue方法是异步的。所以,这里一般我会加一个setTimeout。否则,[url=]测试用例[/url]可能一直无法测试成功。
      done 方法用于解决异步代码测试问题, 在一个异步语句中, 你的测试将会在调用回调之前完成。所以, 使用一个名为 done 的参数。jest将等待回调完成后进行测试。
      6. 测试helper.ts
      helper文件里存放的都是页面的交互方法, 如果你用的纯函数的化, 只需要传入不同数据, 测试即可。
      源代码:


    1. // 这里需要有个event, 浏览器原生属性

    2. export const filter = (form: WrappedFormUtils) => (e: React.FormEvent<HTMLFormElement>) => {

    3.   e.preventDefault() // 注意这里

    4.   form.validateFields(async (err, values) => {

    5.     if (!err) {

    6.       const thresholdFilter = formatFormValuesToGroup(values)

    7.       const query = {

    8.         deptLevel: values.deptLevel,

    9.         cidLevel: values.cidLevel,

    10.         deptId: values.deptId,

    11.         cid: values.cid,

    12.         deptName: values.deptName,

    13.       }

    14.       if (isEmpty(values.cid)) {

    15.         // 删除对象属性用es6中的Reflect

    16.         Reflect.deleteProperty(query, 'cid')

    17.         Reflect.deleteProperty(query, 'cidLevel')

    18.       }

    19.       await store.thresholdRange.fetch({ body: { ...query } })

    20.       store.sliderScope.set(store.thresholdRange.value.minAndMaxThreshold as number[])

    21.     }

    22.   })

    23. }
    复制代码
    这里着重讲一下方法里面传入form属性时, 该如何写测试语句。
      测试代码:


    1. it('filter,过滤金额门槛', async () => {

    2.   const values = {

    3.     promoDataTimeScope: 'half_year',

    4.     deptLevel: 2,

    5.     deptId: '837',

    6.     cidLevel: 12,

    7.     cid: '',

    8.     deptName: 'abc',

    9.   }

    10.   const form = ({

    11.     // 在ts里面validateFields是必传项, 这里mock一个假函数

    12.     validateFields: jest.fn(cb => {

    13.       // 模拟 没有错误提交

    14.       cb(null, values)

    15.     }),

    16.   } as unknown) as WrappedFormUtils

    17.   // 同理, event也需要mock

    18.   const e = ({ preventDefault: jest.fn() } as unknown) as React.FormEvent<HTMLFormElement>

    19.   // 这里spyOn后端的请求接口

    20.   const spy = jest.spyOn(store.thresholdRange, 'fetch').mockImplementation(() => Promise.resolve() as Promise<any>)

    21.   await filter(form)(e)

    22.   expect(spy).toHaveBeenCalled() //

    23.   expect(store.sliderScope.value).toEqual([])

    24. })
    复制代码
    注意: 上述代码有一个语句前后顺序问题

    本帖子中包含更多资源

    您需要 登录 才可以下载或查看,没有帐号?(注-册)加入51Testing

    x
    分享到:  QQ好友和群QQ好友和群 QQ空间QQ空间 腾讯微博腾讯微博 腾讯朋友腾讯朋友
    收藏收藏
    回复

    使用道具 举报

  • TA的每日心情
    无聊
    4 天前
  • 签到天数: 1050 天

    连续签到: 1 天

    [LV.10]测试总司令

    2#
     楼主| 发表于 2020-9-18 10:24:24 | 只看该作者

    1. const spy = jest.spyOn...

    2. expect(spy).toHaveBeenCalled()
    复制代码
    jest.spyOn 一定要写在前面, 否则测试不通过, 因为 store.thresholdRange 还没有被请求。

    1. // error

    2. expected: >=1

    3. received: 0
    复制代码
    7. 其他知识点
      至此, 常见的组件测试方法都已经讲完了, 下面说下其他需要注意的点:
      上面写的都是函数式组件测试方法, 如果是class组件, 该如何做呢, 其实原理都是一样的, 好多示例代码都有了, 你只需要借(fu)鉴(zhi)下就ok了。
      这里讲下class组件里面的方法是怎么测试的
      源代码:


    1. import { observer } from 'mobx-react'




    2. @observer

    3. export class BatchUpload extends React.Component<IProps> {

    4.   // 注意这里应该用public, private测试无法取到

    5.   public showBatchModal = () => {

    6.     this.setState({

    7.       visible: true,

    8.     })

    9.   }




    10.   public render() {

    11.     const {

    12.       form: { getFieldDecorator },

    13.     } = this.props

    14.     const { spinning } = this.state




    15.     return (

    16.       <>

    17.         <Modal

    18.           title="批量导入"

    19.           onOk={this.handleOk}

    20.           visible={this.state.visible}

    21.           footer={this.renderFooter()}

    22.           destroyOnClose

    23.         >

    24.           <Spin spinning={spinning}>

    25.             <Form layout="horizontal">

    26.               ...

    27.             </Form>

    28.           </Spin>

    29.         </Modal>

    30.       </>

    31.     )

    32.   }

    33. }




    34. export default Form.create<IProps>()(BatchUpload)
    复制代码
    测试代码:

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




    2. it('测试showBatchModal方法', () => {

    3.   const app = wrapper()

    4.   // 看这里

    5.   const instance = app.find(BatchUpload).instance() as BatchUpload

    6.   instance.showBatchModal()




    7.   expect(instance.state.visible).toBeTruthy()

    8.   app.unmount()

    9. })
    复制代码
    看到没, 只需要find找到这个组件,然后 instance 实例化class就可以了, so easy~
      在函数式组件里面, useEffect这个hook是在页面初始化就执行了, 所以在测试的时候, 应该mount下。


    1. const Comp: React.FC = () => {

    2.   React.useEffect(() => {

    3.     store.previewSpec.fetch()

    4.   }, [])

    5.   return <br />

    6. }




    7. it('测试可正确渲染页面结构', () => {

    8.   runInAction(() => {

    9.     router.history.location.pathname = '/preview'

    10.   })

    11.   mount(<Comp />)

    12.   expect(app.find(Descriptions.Item)).toHaveLength(5)

    13. })
    复制代码
    下面这个报错是因为没有jest.fn, 直接请求了实际的url。

    1. // error

    2. only absolute urls are supported
    复制代码
    每写完一个测试文件, 都可以运行--coverage命令, 查看分支或者语句的覆盖率, 也可以定位到某个文件夹, 查看模块的覆盖率。
    1. npx jest page/PromoRules/BatchUpload.test.tsx --coverage
    复制代码
    8. 小结
      以上是自己在开发过程中的一些常见测试方法, 然而实际工作中, 好多人不喜欢写测试, 大部分时间都是点点点, 程序无问题, 上线吧,后面测试出来好多bug, (我以前也是这么想的)。
      后来, 在写jest测试过程中, 确实能够发现自己程序的bug, 而且会反推思考自己的代码逻辑, 这是一个相互促进的过程, 不管是在保证代码质量还是以后面试, 写出覆盖率高、通过率高的单元测试也是一个加分项。
      最后说一下, 测试覆盖率的问题。
      bad:


    good:

    尽量保证代码全都覆盖到(强迫症), 在实际开发中其实很难, 包括日常开发进度, 还有重复编码问题。
      但是, 你不觉得一片绿油油的很爽嘛~

    本帖子中包含更多资源

    您需要 登录 才可以下载或查看,没有帐号?(注-册)加入51Testing

    x
    回复 支持 反对

    使用道具 举报

    本版积分规则

    关闭

    站长推荐上一条 /1 下一条

    小黑屋|手机版|Archiver|51Testing软件测试网 ( 沪ICP备05003035号 关于我们

    GMT+8, 2024-11-25 08:06 , Processed in 0.071804 second(s), 24 queries .

    Powered by Discuz! X3.2

    © 2001-2024 Comsenz Inc.

    快速回复 返回顶部 返回列表