開始測試React Native App(上篇)

lyxia_iOS發表於2018-09-25

前期技術儲備

前言


我是測試小白,小小白,小小小白,最近想在成了一定規模的專案中引入測試,於是找了許些資料學習,現在已經在專案中成功引入。於是想在思路明朗和記憶深刻的時候總結下學習路徑以及寫測試中遇到的難點、坑點、注意點。給自己的近段學習成果做個總結,同時也希望能幫助到和我一樣初入測試的人。

注意注意特別注意!!!


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… 文章同步更新^_^

相關文章