开始测试React Native App(上篇)

前期技术储备

前言


我是测试小白,小小白,小小小白,最近想在成了一定规模的项目中引入测试,于是找了许些资料学习,现在已经在项目中成功引入。于是想在思路明朗和记忆深刻的时候总结下学习路径以及写测试中遇到的难点、坑点、注意点。给自己的近段学习成果做个总结,同时也希望能帮助到和我一样初入测试的人。

注意注意特别注意!!!


React Native在0.56、0.57版本上测试运行有各种各样的问题,例如:Can't run jest tests with 0.56.00.56 regression: jest.mock only works when defined in jestSetup.js, not in individual Snapshots tests以及笔者还没遇到的问题,笔者亲测:"Can't run jest tests with 0.56.0"这个问题在0.57中已经解决,“0.56 regression: jest.mock only works when defined in jestSetup.js, not in individual Snapshots tests”这个问题在0.57中依然存在。所以文章示例建议在0.55.4版本中运行。

初入测试一定要明白的重要概念


  • 自动化测试
  • 测试金字塔
  • 单元/集成/e2e测试

扩展阅读:如何自动化测试 React Native 项目 (上篇) - 核心思想与E2E自动化了解以上概念。

随着项目越来越大,新增需求对于开发而言或许不算太大工作量,但是对于测试而言,特别是回归测试,压力会徒然增加很多,如果是手工测试或者是放弃一些测试用例,都是不稳定的测试。所以自动化测试的重要性就体现出来了,自动化测试的大体思路即是”测试金字塔“,测试金字塔从上到下分别是E2E测试、集成测试、单元测试。E2E测试是需要真实编译打包在模拟器上或者真机上模拟用户行为走测试流程,测试结果受网络,弹窗,电话等不可控影响较大,因此不能过于信任,因此E2E测试出的Bug最好能到集成测试中重现,集成测试中的出现的Bug最好能在单元测试中重现,若不能重现则应该加入更多的单元/集成测试来重现Bug。集成和单元测试都不需要编译打包运行,因此它们的执行速度非常快,所以项目中测试代码量应该是单元测试大于集成测试,集成测试大于E2E测试,从而形成自动化测试金字塔。

  • Snapshot
  • Mock
  • JavaScript Testing utility:例如Detox、Enzyme
  • JavaScript Test runners and assertion libraries:例如Jest

文章后面会重点解释以上概念。

React Native对于测试的支持


If you're interested in testing a React Native app, check out the React Native Tutorial on the Jest website.

Starting from react-native version 0.38, a Jest setup is included by default when running react-native init.

通过React NativeJest官方描述,可以得到结论:在react-native 0.38及后续版本在react-native init时已经默认植入了Jest测试库,所以我们可以0配置开始尝试编写测试代码。

使用以下方式开始尝试一下吧 (*^^*) 创建iosandroid同级目录下创建__test__文件夹,在__test__文件夹下创建helloworld.test.js文件,并输入以下代码:

it('test',()=>{
  expect(42).toEqual(42)
})
复制代码

在终端执行:npm test查看测试结果。 入门是不是超简单o(* ̄ ̄*)o!

注:不是一定要在iosandroid同级的目录创建__test__文件夹才能写测试代码,项目下的*.test.js都可以执行测试。

Jest必备知识


请阅读 jestjs.io/docs/en/get… 的 Introduction 章节的前5篇文章(到Mock Function为止),Guides章节的第一篇文章。

Jest 是一个运行测试和断言的库(Test Runner and assertion libraries),Jest通过Expect来断言当前结果和预期结果是否相同,这些结果是这里所涉及到的数据类型。Jest使用Mock来模拟一些Function、Module以及Class来方便测试(Mock测试中不需要真实去执行的代码,例如Fetch,Platform.OS等)。

Snapshot翻译成中文是快照的意思,以前的UI测试是执行测试脚本并在停留的页面上截图,当再次执行相同的测试脚本时会拿前后的截图做对比,如果像素相同则测试通过,像素不相同则测试不通过。在Jest中对React的UI测试可以通过Snapshot生成序列化结构树(文本形式),对比前后生成的结构树即可。Snapshot不仅仅可以用来测试UI,它可以用来测试任何可以序列化的结构,例如Action、Store等,在文章后面会有所提及。

前期技术储备好了我们就可以开始着手写测试了^_^

单元测试

Redux 逻辑测试


官方推荐阅读:Testing React Native with the new Jest — Part II

Redux中的Reducer测试

Reducer是纯函数,也就是说在有相同的输入值时,就一定是相同的输出,因此是很容易测试的。

it('start upload action will combine upload\'s watting queue and failed queue then update upload\'s uploading state', () => {
    let currentState = Map({
        'uploadTestKey': new Upload({
            name: 'uploadTestKey',
            wattingQueue: List([
                new UploadItem({
                    name: 'fileTwo',
                    filepath: 'fileTwoPath'
                })
            ]),
            uploadedQueue: List([
                new UploadItem({
                    name: 'fileThree',
                    filepath: 'fileThreePath'
                }),
            ]),
            failedQueue: List([
                new UploadItem({
                    name: 'fileOne',
                    filepath: 'fileOnePath'
                }),
            ]),
        })
    })
    currentState = UploadReducer(currentState, UPloadActions.startUpload({upload: 'uploadTestKey'}))
    expect(currentState).toMatchSnapshot()
})
复制代码

上面的代码示例是测试UploadReducer对固定输入currentStateUPloadActions.startUpload({upload: 'uploadTestKey'})的输出是否正确,这里需注意以下两点:

1、要确保第一次运行npm run test后产生的__snapshots__/<测试文件名称>.snap里面内容的正确性。因为expect(currentState).toMatchSnapshot()expect(value).toEqual(someValue)的写法不同,后一种可以在写测试用例时直接给出期望值,前一种是测试用例运行完自动将期望值写入到了__snapshots__/<测试文件名称>.snap文件中,因此在第一次运行完测试用例我们需要确认生成的snapshot的正确性。toMatchSnapshot()的好处是不需要copy代码在测试用例中,如果不使用toMatchSnapshot(),我们的测试用例将写成以下形式:

it('start upload action will combine upload\'s watting queue and failed queue then update upload\'s uploading state', () => {
    let currentState = Map({
        'uploadTestKey': new Upload({
            name: 'uploadTestKey',
            wattingQueue: List([
                new UploadItem({
                    name: 'fileTwo',
                    filepath: 'fileTwoPath'
                })
            ]),
            uploadedQueue: List([
                new UploadItem({
                    name: 'fileThree',
                    filepath: 'fileThreePath'
                }),
            ]),
            failedQueue: List([
                new UploadItem({
                    name: 'fileOne',
                    filepath: 'fileOnePath'
                }),
            ]),
        })
    })
    currentState = UploadReducer(currentState, UPloadActions.startUpload({upload: 'uploadTestKey'}))
    expect(currentState.is(
        Map({
        'uploadTestKey': new Upload({
            name: 'uploadTestKey',
            wattingQueue: List([
                new UploadItem({
                    name: 'fileTwo',
                    filepath: 'fileTwoPath'
                }),
                new UploadItem({
                    name: 'fileOne',
                    filepath: 'fileOnePath'
                }),
            ]),
            uploadedQueue: List([
                new UploadItem({
                    name: 'fileThree',
                    filepath: 'fileThreePath'
                }),
            ]),
            failedQueue: List([]),
        })
    })
    )).toBe(true)
})
复制代码

这样就造成了代码冗余,这时snapshot的重要性就提现出来了。

2、既然是单元测试,那我们写的每个测试用例的职责都要单一,不要在单元测试中写出集成测试出来,这是刚学测试经常难以区分的。测试的语法并不难,难得是写出什么样的测试用例。例如以上的测试用例是测试一个上传队列组件,它的reducer可以处理多个action,例如pushdeleteupload等,那我们应该怎样为这个reducer写单元测试呢?笔者一开始就跑偏了,写出了这样的测试用例,各位看官可以看看:

describe("upload component reducer test", () => {
    describe("one file upload", () => {
        let currentState = Map({})
        beforeAll(() => {
            currentState = UploadReducer(currentState, UPloadActions.registerUpload({upload: 'uploadTestKey'}))
            expect(currentState).toMatchSnapshot()
        })
    
        afterAll(() => {
            currentState = UploadReducer(currentState, UPloadActions.destroyUpload({upload: 'uploadTestKey'}))
            expect(currentState).toMatchSnapshot()
        })
        ...
        test("handle upload success", () => {
            let state = UploadReducer(currentState, UPloadActions.pushUploadItem({upload: 'uploadTestKey', name: 'fileOne', filePath: 'fileOnePath'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.startUpload({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.startuploadItem({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.uploadItemSuccess({upload: 'uploadTestKey', id: '12345'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.uploadComplete({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
        })

        test("handler upload failed", () => {
          ...
        })

        test("handler reupload success", () => {
            let state = UploadReducer(currentState, UPloadActions.pushUploadItem({upload: 'uploadTestKey', name: 'fileOne', filePath: 'fileOnePath'}))
            state = UploadReducer(state, UPloadActions.startUpload({upload: 'uploadTestKey'}))
            state = UploadReducer(state, UPloadActions.startuploadItem({upload: 'uploadTestKey'}))
            state = UploadReducer(state, UPloadActions.uploadItemFailed({upload: 'uploadTestKey'}))
            state = UploadReducer(state, UPloadActions.uploadComplete({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.startUpload({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
            state = UploadReducer(state, UPloadActions.startuploadItem({upload: 'uploadTestKey'}))
            state = UploadReducer(state, UPloadActions.uploadItemSuccess({upload: 'uploadTestKey', id: '12345'}))
            state = UploadReducer(state, UPloadActions.uploadComplete({upload: 'uploadTestKey'}))
            expect(state).toMatchSnapshot()
        })
    })
    describe("mult file upload", () => {
        let currentState = Map({})
        beforeAll(() => {
            ...
        })

        afterAll(() => {
            ...
        })
        ...
        test("handle upload successed", () => {
            ...
        })

        test("handle upload failed", () => {
            ...
        })

        test("hanlde reupload successed", () => {
            ...
        })
    })
})
复制代码

可以看上以上单元测试的问题吗?在这里引入这篇文章所举的例子:

image.png
笔者就是犯了以上错误,测试语法学会后,不知道如何写测试用例,傻傻的在单元测试里写入集成测试,就会出现如果 reducer增加了新的 action处理,那测试文件中应该添加多少个测试用例呢? 于是笔者改成了以下写法:

describe("upload component reducer test", () => {
    it('register upload action will register a upload queue to state', () => {
        let currentState = Map({})
        currentState = UploadReducer(currentState, UPloadActions.registerUpload({upload: 'uploadTestKey'}))
        expect(currentState).toMatchSnapshot()
    })

    it('destroy upload action will remove upload queue from state', () => {
        let currentState = Map({
            'uploadTestKey': new Upload({
                name: 'uploadTestKey'
            })
        })
        currentState = UploadReducer(currentState, UPloadActions.destroyUpload({upload: 'uploadTestKey'}))
        expect(currentState).toMatchSnapshot()
    })

    it('push upload item action will add an uploadItem into upload\'s wattingQueue', () => {
        ...
    })

    it('delete upload item action will remove an uploadItem from upload\'s all queue', () => {
       ...
    })
    ...
})
复制代码

reducer能处理多少个action就有多少个测试用例,是不是明了多了? 示例代码

Redux中的Action Creator测试

Reducer同样的道理,也是要注意两点,一个是测试用例的职责要对,一定要记住它是“单元测试”,我们只需要保证单个Action creator有特定的输入就有特定的输出,而且要对第一次运行测试用例的输出snapshot进行检查,保证期望值的正确性。 示例代码

如何测试异步Action

通常的Action是一个Object对象,带有type属性即可,但是异步Action它返回的不是一个Object而是一个特殊的Function,需要类似于redux-thunk的中间件来处理。因此我们在测异步Action时需要Mock两个模块,一个是网络异步所需要的fetch,另一个就是可以派发Async ActionStore

请先阅读Jest官方的Mock相关文档:Mock Functionsmanual-mocks

Mock fetch可以使用库:jest-fetch-mock Mock store可以使用库:redux-mock-store 具体配置查看官方README,这是配置好的项目。 Object类型的Action测试写法:

it('register upload action' , () => {
  store.dispatch(UploadActions.registerUpload({upload: 'uploadKey'}))
  expect(store.getActions()).toMatchSnapshot()
})
复制代码

异步Action测试写法:

it('upload one file fail action test', () => {
  fetch.mockResponseOnce(JSON.stringify({ error: new Error('fail') }))

  return store.dispatch(UploadActions.upload('uploadKey', config))
          .then(() => {
            expect(store.getActions()).toMatchSnapshot()
          })
})
复制代码

异步测试有多种写法,分别用来处理callBackPromiseasync/await,具体请查阅官方文档

Component测试


上面详细讲述了关于Redux的单元测试,下面来看看Component如何做单元测试。

请先阅读Testing React Native with the new Jest — Part I

需要注意的是,网上有许多文章在写组件测试的时候都使用了react-native-mock,用来mock RN的库,但是在RN0.37版本开始,内置于react-native的Jest设置自带一些应用于react-native库的mock。可以在setup.js中查阅,因此不需要再引入react-native-mock。

Component测试的核心点:

  • 给不同的props会有不同的Dom输出。
  • 使用主动执行实例方法来模拟State的变化输出不同的Dom
  • 测试使用connect(component)包裹的组件时,mockconnect组件连接的props直接测试被connect包裹的组件
  • 测试使用HOC的组件时,分别测试ComponentWrapComponent

注意上面列表加粗的文字,这些文字就是我们写Component测试的着手点。

UI Render测试,我们测试的是不同的props有不同的Dom

it('render login screen with init state', () => {
    const loginWrap = shallow(
        <LoginScreen
            handleSubmit={handleSubmit}
            valid={false}
            submitting={false}
        />
    )
    expect(toJson(loginWrap)).toMatchSnapshot()
})
复制代码

在上段的代码中,我们可以改变valid这些属性值,然后使用toMatchSnapshot来保留snap。这里涉及的库有:enzyme,enzyme-to-json,知识点有:shallow

enzyme是使用javascript语言为react写的测试工具,可以用来快速的获取Component的输出(Dom),操控Dom,以及对Dom写各种断言。类似的有React Test Utilitiesreact-testing-library,React Test Utilities是React官方出的测试工具,也可以输出Dom,但是它不能操作Dom,没有提供Selector。react-testing-library与enzyme的功能很接近,但是不支持react-native,支持react

enzyme-to-json可以将shallow的结果json化输出,一般配合JesttoMatchSnapshot使用。 Shallow的render方式是浅渲染,只生成Dom树的一层,例如:

//ComponentA.js
import React from 'react'
import {
    Text,
    View,
} from 'react-native'

class ComponentA extends React.Component {
    render() {
        return (
            <View><ComponentB /></View>
        )
    }
}
class ComponentB extends React.Component {
    render() {
        return (
            <Text>Hello world</Text>
        )
    }
}

export default ComponentA
复制代码
//ComponentA.test.js
import ComponentA from './ComponentA'
import React from 'react'
import { shallow } from 'enzyme'
import toJson from 'enzyme-to-json'

it('shallow ComponentA', () => {
    const wrap = shallow(<ComponentA/>)
    expect(toJson(wrap)).toMatchSnapshot()
})
复制代码
//ComponentA.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`shallow ComponentA 1`] = `
<Component>
  <ComponentB />
</Component>
`;
复制代码

使用Shallow的渲染结果就是<View><ComponentB/></View>,它不会再把ComponentB展开获得<View><Text>Hello world</Text></View>这种结果。这样我们就不用关心子组件的行为,我们之要专心测ComponentA即可。

enzymeenzyme-to-json的安装,参考官网:airbnb.io/enzyme/

UI交互测试,我们需要主动调用实例方法来触发state的更改:

//Foo.js
import React from 'react'
import {
    Switch
} from 'react-native'

export default class extends React.Component {
    constructor() {
        super(...arguments)

        this.state = {
            value: false
        }
    }

    _onChange = (value) => {
        this.setState({value: value})
    }

    render() {
        return (
            <Switch onValueChange={this._onChange} value={this.state.value}/>
        )
    }
}
复制代码
//Foo.test.js
import Foo from './Foo'

import React from 'react'
import { shallow } from 'enzyme'
import toJson from 'enzyme-to-json'

it('Foo change state', () => {
    const wrap = shallow(<Foo/>)
    expect(wrap.state(['value'])).toEqual(false)
    expect(toJson(wrap)).toMatchSnapshot()

    const firstWrap = wrap.first()
    firstWrap.props().onValueChange(true)
    expect(wrap.state(['value'])).toEqual(true)
    expect(toJson(wrap)).toMatchSnapshot()
})
复制代码
//Foo.test.js.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Foo change state 1`] = `
<Switch
  disabled={false}
  onValueChange={[Function]}
  value={false}
/>
`;

exports[`Foo change state 2`] = `
<Switch
  disabled={false}
  onValueChange={[Function]}
  value={true}
/>
`;
复制代码

在这个例子中,在firstWrap.props().onValueChange(true)前分别打印了snap,并且断言state.value的值,来测试onValueChange引起的state的更改。firstWrap.props().onValueChange(true)就是主动调用实例方法的行为。

HOC测试:

在以上的两个例子中,可以掌握常规组件的单元测试,那么Hoc组件如何测试呢?其实实现方式也很简单,我们把HOC拆开来看,可以分别测Higher OrderComponentComponent的测试和上两个例子一样,需要注意的是,要分别导出Higher OrderComponent以及HOC:

//Hoc.js
import React from 'react'
import {
    View
} from 'react-native'

export function fetchAble(WrappedComponent) {
    return class extends React.Component{
        _fetchData = () => {
            console.log('start fetch')
        }

        render() {
            return (
                <WrappedComponent fetchData={this._fetchData}/>
            )
        }
    }
}

export class Com extends React.Component {
    render() {
        return (<ComponentA/>)
    }
}

export default fetchAble(View)
复制代码
//Hoc.test.js
import {fetchAble} from './Hoc'
it('Hoc test', () => {
    const A = (props) => <View/>
    const B = fetchAble(A)
    const fetchWarp = shallow(<B/>)

    const wrapA = fetchWarp.find(A)
    expect(wrapA).not.toBeUndefined()
    expect(wrapA.props().fetchData).not.toBeUndefined()
    wrapA.props().fetchData()
    expect(console.log.mock.calls.length).toEqual(1)
    expect(console.log.mock.calls[0][0]).toEqual('start fetch')
})
复制代码

setupJest中配置了mockconsole

Redux Connect与HOC是同样的道理

组件测试的参考文章(搭梯子):

Sharing and Testing Code in React with Higher Order Components

Testing React Component’s State

Unit Testing Redux Connected Components

这一篇主要是围绕组件和Redux写单元测试,下一篇将开始写集成以及e2e测试

欢迎关注我的简书主页:www.jianshu.com/u/b92ab7b3a… 文章同步更新^_^

猜你喜欢

转载自juejin.im/post/5baa56b86fb9a05d0d287339