理解前端自動化測試TDD + BDD

前言

在平常的開發中,成天趕需求的咱們好像沒有時間顧及自動化測試,尤爲是在敏捷開發的時候。但其實自動化測試能夠幫助咱們提升代碼和功能的健壯程度,大幅減小可能出現的bug。javascript

尤爲是在複雜系統中,自動化測試的做用不容忽視。本篇文章是我本身的學習記錄,使用測試框架jest和前端框架React來簡單梳理的自動化測試。css

平常開發一般涉及到業務代碼的開發以及函數、組件庫的開發。針對這兩方面的自動化測試,在模式和流程上也有各自的要求與側重。這就衍生出了單元測試和集成測試兩種測試方法,以及TDD與BDD的測試開發流程。前端

單元測試

單元測試,見名知意,能夠理解爲對系統的某個單元進行測試,而這個單元,能夠是某個函數,某個組件,對於這種測試形式來講,咱們只關注這個獨立的單元的功能是否正常。測試用例以當前單元內的功能做爲對象。java

集成測試

將多個單元集成到一塊兒,進行測試,重點關注各個單元串聯起來以後的系統總體功能是否正常。此時的測試用例以多個單元組成的某個獨立的系統爲對象。node

以上是兩種測試方法,但有時測試的細化程度與系統複雜的操做流程難以平衡,這就須要作出取捨,針對不一樣的開發主體以及業務場景採用不一樣的測試+開發的流程。react

TDD: 測試驅動開發(Test-Driven Development)

這種模式中,先編寫測試用例,在測試用例的指導下去完善功能,當測試用例編寫完而且都經過測試以後,相應的功能也就作完了。TDD的模式適合於對系統代碼質量和測試覆蓋率有要求的開發主體,好比函數和組件庫。但一般在代碼發生變化的時候,測試用例也要進行相應的調整。npm

BDD: 行爲驅動開發(Behavior Driven Development)

測試用例模擬用戶的操做行爲,一般在完成業務代碼開發以後,以用戶的操做爲指導編寫測試代碼。當測試用例跑通以後,就能夠認爲系統的總體流程已經流暢。BDD的模式適用於平時的業務代碼開發,由於業務的需求有可能變動頻繁,但操做流程有可能不會變化,當業務代碼發生變化的時候,可使用原來的測試用例繼續跑代碼,節省了開發時間。json

我認爲在平時的項目中,一般使用TDD和BDD相結合來進行測試,TDD負責方法類、獨立組件的測試。BDD則負責總體業務模塊的測試。前端工程化

從Demo入手來理解自動化測試

讓咱們用一個demo來理解一下前端自動化測試,先從搭建環境開始,認識一下和jest以及React有關的配套工具和配置項。api

搭建測試環境

若是是用create-react-app 建立的項目,內部會集成好一個jest的測試環境。npm run eject將配置項暴露出來後,在package.json的jest字段內能夠看到jest的配置項,也能夠將這些配置項複製出來,粘貼到新建的jest.config.js中。

create-react-app生成的jest配置項內容

* 是匹配任意文件夾,是匹配任意文件名

module.exports = {
    // 測試哪些目錄下的文件
    "roots": [
      "<rootDir>/src"
    ],

    // 生成測試覆蓋率報告的時候,統計哪些目錄下以哪些後綴爲結尾的文件,前邊加!是不參與統計的意思,.d.ts是ts中的類型聲明文件,因此不用參與統計
    "collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}",
      "!src/**/*.d.ts"
    ],

    // 使用react-app-polyfill/jsdom 解決js兼容性的一些問題
    "setupFiles": [
      "react-app-polyfill/jsdom"
    ],

    // 測試環境創建好之後,會執行裏面的文件,在當前這個場景下,setupTests.js裏作的事情就是引入了一些jsdom擴展的matchers。
    "setupFilesAfterEnv": [
      "<rootDir>/src/setupTests.js"
    ],

    // 當測試運行時,要執行一些測試文件,這個配置項內就是用正則匹配要被執行的文件。
    "testMatch": [
      "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
      "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
    ],

    // 由於測試環境是在node中執行的,沒有dom或者window的api,因此這個配置項的值會模擬window或者dom的一些api
    "testEnvironment": "jest-environment-jsdom-fourteen",
    
    // 當引入的文件符合transform這個配置項的key的正則的時候,用value去解析轉換該文件
    "transform": {
      "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
      "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
      "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
    },

    // 與上邊的transform是對應的,當引入的文件符合這個配置項的key的正則的時候,就忽略不作處理
    "transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
      "^.+\\.module\\.(css|sass|scss)$"
    ],

    // 當引入一個模塊在node_modules內找不到時,須要在自定義的路徑下去找,能夠將路徑寫在這裏
    "modulePaths": [],

    // 針對css-module,使用identity-obj-proxy將樣式從 .selector: { width: 20px }轉換爲 { .selector: '.selector' } 這樣的形式,
    // 目的是在測試中,忽略樣式,因此簡化處理
    "moduleNameMapper": {
      "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
    },

    // 在測試文件中引入文件的時候,若是引入的文件名沒有寫後綴,會依據下邊的後綴去找這個文件
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx",
      "json",
      "jsx",
      "node"
    ],

    // npm run test命令的時候,進入jest會進入監聽文件變更的模式。這些是監聽的插件,也能夠直接使用jest自帶的監聽模式
    "watchPlugins": [
      "jest-watch-typeahead/filename",
      "jest-watch-typeahead/testname"
    ]
  }

若是是徹底本身配置的項目,能夠在項目內安裝jest,而後npx jest --init,初始化一個jest.config.js文件來配置測試環境,固然徹底能夠參考create-react-app生成的jest配置項。

以上是配置了一個基本的jest測試環境,對於React項目的測試仍是徹底不夠的。

使用Enzyme測試React組件

React中組件是一個重要的概念,因此,方便靈活地對組件進行測試也很是重要。

測試組件,涉及到組件的props,state,內部方法。針對這種場景,可使用enzyme來對組件進行測試。

enzyme是Airbnb公司推出的一款針對React測試的工具,組件能夠經過enzyme提供的方法在測試環境中被渲染出來,再經過其他的API能夠獲取或者驗證組件的狀態、行爲。

以一個簡單的組件爲例:

import React from 'react';

function App() {
  return (
    <div className="App" data-test='container'>
      hello world
    </div>
  );
}

export default App;

若是對這個組件進行測試,須要首先安裝enzyme。安裝enzyme的同時,也須要安裝enzyme針對react的一個適配器enzyme-adapter-react-16
適配器最後的數字須要與你當前項目中的react版本一致。

npm i --save-dev enzyme enzyme-adapter-react-16

安裝好以後,在測試用例的文件中引入並配置enzyme。

import React from 'react';
import App from './App';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

test('驗證App組件是否被正確掛載', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]').length).toBe(1)
});

這段測試代碼驗證data-test="container"這個容器是否存在。爲了和業務代碼解耦,測試用例的選擇器(find)不該該使用與業務相關的標記,這裏在須要測試的容器上加上了一個屬性: data-test='container'。

測試用例的意思是用shallow將組件渲染出來,被渲染以後的組件就能夠調用一些enzyme提供的方法,這裏的find就是找到data-test="container"的集合,集合的長度若是爲1,那就說明該容器存在,測試經過。

固然,不可能寫一個測試文件就引入一次enzyme。能夠將enzyme的引入和配置工做放到測試環境準備好的時候,也就是jest.config.js中setupFilesAfterEnv配置項配置的文件中,在該文件中引入。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

shallow 和 mount

在測試用例中,咱們也是須要將組件渲染出來的,只不過是這樣寫的:

const wrapper = shallow(<App />)

這裏的shallow,是enzyme提供的方法,能夠理解爲淺渲染,也就是若是被shallow包裹的組件有嵌套其餘組件的話,嵌套的組件會用一個標記來替代。因此只會渲染出組件的第一層,這樣作的目的是爲了在對組件作單元測試的時候,只關注當前組件,同時能夠大幅度提高性能。

與之對應的還有一個mount方法,這個方法會將全部嵌套的組件都渲染出來,再也不對組件進行淺渲染,至關於關注多個組件結合在一塊兒的運行狀況。

擴展matchers

在上面的測試用例中,調用的是jest提供的原生的matcher,其實可使用jest-enzyme提供的一些針對React組件的matchers,更方便地進行測試。

首先,安裝jest-enzyme:

npm install jest-enzyme --save-dev

而後,須要在jest.config.js中,setupFilesAfterEnv中加上jest-enzyme主體文件的路徑,目的是在測試環境準備好以後,初始化jest-enzyme。

"setupFilesAfterEnv": ["./node_modules/jest-enzyme/lib/index.js"'"]

使用了jest-enzyme以後,咱們的測試用例的代碼能夠改爲

test('驗證App組件是否被正確掛載', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]')).toExist()
});

toExist方法就是jest-enzyme提供的matcher,完成的matchers列表在這裏,隨查隨用。

Demo實戰

環境準備好以後,分別使用TDD與BDD結合單元測試與集成測試開發一個簡單的demo來理解這兩種流程下的自動化測試。

功能點有三個

  • 輸入文字,回車,列表添加一條記錄
  • 回車的同時輸入框內容清空
  • 點擊刪除會刪除該條記錄

代碼結構:Input組件負責輸入內容,List組件負責展現數據並提供刪除的功能。兩個組件嵌套在一個父組件(App)以內。

<div className="App">
      <Input
        onAddData={onAddData}
      />
      <List
        list={list}
        onDelete={onDelete}
      />
    </div>

TDD + 單元測試

TDD須要在測試的指導下寫代碼,關注點稍微偏重於測試。使用單元測試結合測試驅動開發的流程,應該逐一梳理功能,編寫的測試用例應聚焦在某個單元上。

回到demo上,針對上述的三個功能點和組件各自的職責,先寫測試代碼,而後寫業務代碼,讓業務最後經過測試,完成開發。同時採用單元測試的方式,要保證所編寫的測試用例,只針對組件自己的功能。

先從Input組件入手,梳理組件的功能。

  • 輸入內容後回車,傳入的onAddData方法應該被調用,而且接收到的參數就是最終輸入的內容
  • 輸入內容後回車,輸入框的內容應清空

從第一條開始,編寫測試代碼:

test('輸入內容,點擊回車,Input組件的onAddData應該被調用而且接收到正確的參數', () => {
  const fn = jest.fn()
  const wrapper = shallow(<Input
    onAddData={fn}
  />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(fn).toHaveBeenCalledWith('hello')
})

測試代碼驗證輸入內容回車後,傳入Input組件的函數會不會被調用,而且驗證是否能夠接收到正確的值。

這裏用到了jest的Mock Functions功能。使用enzyme提供的shallow將組件渲染出來後,找到input並模擬keyup事件,在接下來的流程中驗證fn是否被調用並接收到了正確的值。

如今由於尚未寫業務代碼,測試是不會經過的。接下來看一下Input組件此時的實現:

const Input = (props) => {
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

App.js補充onAddData的函數

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
    </div>
  );
}

再繼續,當回車後,輸入框的內容應該被清空,針對這個點編寫測試代碼

test('點擊回車,Input組件的輸入框內容應該清空', () => {
  const wrapper = shallow(<Input onAddData={() => {}} />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(input.text()).toBe('')
})

而後,在Input組件中將這個邏輯補上

const Input = (props) => {
  const [ value, setValue ] = useState('') // 針對測試用例新加的代碼
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        // 針對測試用例新加的代碼
        setValue('')
      }
    }}
  />
}

跑一下測試,兩個測試用例都經過了,就說明Input組件已經基本開發完了,下面分析一下List組件:

  • 接收到列表數據,能夠正確的渲染出來
  • 點擊刪除按鈕,onDelete應該被調用,而且接收到當前列表項的索引

從第一條開始編寫測試用例

import React from 'react'
import { shallow } from 'enzyme'
import List from './List'
test('列表組件接收到列表數據,應該渲染出對應數量的列表項', () => {
  const list = ['hello', 'world']
  const wrapper = shallow(<List
    list={list}
  />)
  const items = wrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(2)
  expect(items.at(0).text()).toBe('hello')
  expect(items.at(1).text()).toBe('world')
})

向List組件傳入了一個數組,以後找到應該渲染出來的元素,判斷其長度和各自的內容。接下來實現它

const List = (props) => {
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item}>
          <span data-test="list-item">{item}</span>
          <button>刪除</button>
        </p>
      })
    }
  </div>

}

App.js中將list數據傳入List組件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
      <List list={list}/>
    </div>
  );
}

再看第二條:點擊刪除按鈕,onDelete應該被調用,而且接收到當前列表項的索引。測試代碼與Input組件的第一個測試用例大同小異:

test('點擊刪除按鈕,List組件的onDelete方法應該被調用,而且接收到正確的參數', () => {
  const list = ['hello', 'world']
  const fn = jest.fn()
  const wrapper = shallow(<List
    list={list}
    onDelete={fn}
  />)
  const deleteBtn = wrapper.find('[data-test="delete-btn"]')
  deleteBtn.at(1).simulate('click')
  expect(fn).toHaveBeenCalledWith(1)
})

而後補齊這個功能的代碼

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
            onClick={() => onDelete(index)}
            data-test='delete-btn'
          >刪除</button>
        </p>
      })
    }
  </div>
}

App.js中添加刪除的邏輯

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

到此,這個demo就使用TDD+單元測試的模式開發完畢了。TDD因爲是先寫測試用例再進行開發,因此會保證每一個功能的代碼都是通過測試的,bug天然就少了不少。同時在編寫測試代碼的時候,很天然地要去思考這個功能的代碼如何組織,也在必定程度上提升了代碼的可維護性。

單元測試會保證測試覆蓋率很是高,但在業務開發的場景下,帶來了幾個問題:

  • 代碼量增多,demo中爲了測試功能編寫了不少的測試用例,有時單元測試代碼甚至會比業務代碼多。
  • 業務耦合度高,測試用例中使用了業務中一些模擬的數據,當業務代碼變動的時候,要去從新組織測試用例。
  • 關注點過於獨立,因爲單元測試只關注這一個單元的健康情況,沒法保證多個單元組成的總體是否正常。

這幾個問題說明用單元測試來進行業務測試或許不是一個明智的作法,下面就介紹一種適合業務場景的測試方法。

BDD + 集成測試

BDD其實是模擬用戶的行爲,在業務代碼完成後,用測試用例模擬用戶的操做行爲,因爲關注點上升到了整個系統的層面,因此使用集成測試,應該忽略組件個體的行爲,保證系統行爲的流暢。

因爲是先完成業務代碼,再作測試,因此看一下最終的代碼:

App組件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

Input組件

const Input = (props) => {
  const [ value, setValue ] = useState('')
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

List組件

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
             onClick={() => onDelete(index)}
             data-test='delete-btn' 
          >刪除</button>
        </p>
      })
    }
  </div>
}

如今梳理demo的功能,有兩點:

  • 輸入內容回車以後,列表應該展現輸入的內容
  • 點擊列表項的刪除按鈕,應該把這一項刪除

針對兩個功能來編寫各自的測試用例。與單元測試不一樣的是,咱們的測試對象是Input、List、App這三個組件組成的系統,App組件內包含了全部邏輯,要在在測試用例中將App組件以及內部的嵌套組件都渲染出來,因此再也不使用enzyme的shallow方法,轉而使用mount方法作深度渲染。

下面寫出這兩個功能的測試代碼:

import React from 'react'
import App from './App'
import { mount } from 'enzyme'

test('Input組件輸入內容後回車,List組件應該將內容展現出來', () => {
  const appWrapper = mount(<App />)
  const input = appWrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const items = appWrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(1)
  expect(items.at(0).text()).toBe('hello')
})

test('點擊列表項的刪除按鈕,List組件內相應的記錄應被刪除', () => {
  const appWrapper = mount(<App />)
  // 先添加一條數據,便於刪除
  const input = appWrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const deleteBtn = appWrapper.find('[data-test="delete-btn"]')
  deleteBtn.at(0).simulate('click')
  const items = appWrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(0)
})

第一個測試用例將App渲染出來後,找到輸入框,模擬回車事件,傳入相應的內容。以後找到列表項,若是列表的長度爲1而且內容是hello,則測試經過。

第二個測試用例要先加1條數據,再找到刪除按鈕,模擬點擊事件,若是此時列表項長度爲0,則測試經過。

經過上面這個demo能夠明白集成測試相對於單元測試,更多側重多組件的協同,假如一個組件自己沒有問題,但與其餘組件配合的時候出問題了,那整個流程是不會經過測試的。再結合BDD,使開發時更加關注業務代碼,沒必要先寫繁瑣的測試用例。並且只要操做流程不會變,那測試用例也基本不用動,更加適合平時業務的開發。

總結

自動化測試確實會在必定程度上增長開發的工做量,但通過測試的系統,穩定性的提高會讓咱們更有信心。文中介紹的兩種開發+自動化測試的組合模式能夠應對不一樣的開發場景,但願你們能夠針對本身的場景,選擇合適的方式來引入自動化測試,不管是對提高系統健壯程度仍是深化前端工程化,都很是有幫助。

相關文章
相關標籤/搜索