測試的動機
測試用例的書寫是一個風險驅動的行為, 每當收到 Bug 報告時, 先寫一個單元測試來暴露這個 Bug, 在日后的代碼提交中, 若該測試用例是通過的, 開發者就能更為自信地確保程序不會再次出現此 bug。
測試的動機是有效地提高開發者的自信心。
前端現代化測試模型
前端測試中有兩種模型, 金字塔模型
與獎杯模型
。
金字塔模型摘自 Martin Fowler's blog, 模型示意圖如下:
金字塔模型自下而上分為單元測試、集成測試、UI 測試, 之所以是金字塔結構是因為單元測試的成本最低, 與之相對, UI 測試的成本最高。所以單元測試寫的數量最多, UI 測試寫的數量最少。同時需注意的是越是上層的測試, 其通過率給開發者帶來的信心是越大的。
獎杯模型摘自 Kent C. Dots 提出的 The Testing Trophy, 該模型是筆者比較認可的前端現代化測試模型, 模型示意圖如下:
獎杯模型中自下而上分為靜態測試、單元測試、集成測試、e2e 測試, 它們的職責大致如下:
靜態測試
: 在編寫代碼邏輯階段時進行報錯提示。(代表庫: eslint、flow、TypeScript)單元測試
: 在獎杯模型中, 單元測試的職責是對一些邊界情況或者特定的算法進行測試。(代表庫: jest、mocha)集成測試
: 模擬用戶的行為進行測試, 對網絡請求、獲取數據庫的數據等依賴第三方環境的行為進行 mock。(代表庫: jest、react-testing-library)e2e 測試
: 模擬用戶在真實環境上操作行為(包括網絡請求、獲取數據庫數據等)的測試。(代表庫: cypress)
越是上層的測試給開發者帶來的自信是越大的, 與此同時, 越是下層的測試測試的效率是越高的。獎杯模型綜合考慮了這兩點因素, 可以看到其在集成測試中的占比是最高的。
基於用戶行為去測試
書寫測試用例是為了提高開發者對程序的自信心的, 但是很多時候書寫測試用例給開發者帶來了覺得在做無用功的沮喪。導致沮喪的感覺出現往往是因為開發者對組件的具體實現細節進行了測試, 如果換個角度站在用戶的行為上進行測試則能極大提高測試效率。
測試組件的具體細節會帶來的兩個問題:
- 測試用例對代碼
錯誤否定
; - 測試用例對代碼
錯誤肯定
;
以輪播圖組件
為例, 依次來看上述問題。輪播圖組件偽代碼如下:
class Carousel extends React.Component {
state = {
index: 0
}
/* 跳轉到指定的頁數 */
jump = (to: number) => {
this.setState({
index: to
})
}
render() {
const { index } = this.state
return <>
<Swipe currentPage={index} />
<button onClick={() => this.jump(index + 1)}>下一頁</button>
<span>`當前位於第${index}頁`</span>
</>
}
}
如下是基於 enzyme
的 api 寫的測試用例:
import { mount } from 'enzyme'
describe('Carousel Test', () => {
it('test jump', () => {
const wrapper = mount(<Carousel>
<div>第一頁</div>
<div>第二頁</div>
<div>第三頁</div>
</Carousel>)
expect(wrapper.state('index')).toBe(0)
wrapper.instance().jump(2)
expect((wrapper.state('index')).toBe(2)
})
})
恭喜, 測試通過✅。某一天開發者覺得 index
的命名不妥, 對其重構將 index
更名為 currentPage
, 此時代碼如下:
class Carousel extends React.Component {
state = {
currentPage: 0
}
/* 跳轉到指定的頁數 */
jump = (to: number) => {
this.setState({
currentPage: to
})
}
render() {
const { currentPage } = this.state
return <>
<Swipe currentPage={currentPage} />
<button onClick={() => this.jump(currentPage + 1)}>下一頁</button>
<span>`當前位於第${currentPage}頁`</span>
</>
}
}
再次跑測試用例, 此時在 expect(wrapper.state('index')).toBe(0)
的地方拋出了錯誤❌, 這就是所謂的測試用例對代碼進行了錯誤否定
。因為這段代碼對於使用方來說是不存在問題的, 但是測試用例卻拋出錯誤, 此時開發者不得不做'無用功'來調整測試用例適配新代碼。調整后的測試用例如下:
describe('Carousel Test', () => {
it('test jump', () => {
...
- expect(wrapper.state('index')).toBe(0)
+ expect(wrapper.state('currentPage')).toBe(0)
wrapper.instance().jump(2)
- expect((wrapper.state('index')).toBe(2)
+ expect((wrapper.state('currentPage')).toBe(2)
})
})
然后在某一天粗心的小明同學對代碼做了以下改動:
class Carousel extends React.Component {
state = {
currentPage: 0
}
/* 跳轉到指定的頁數 */
jump = (to: number) => {
this.setState({
currentPage: to
})
}
render() {
const { currentPage } = this.state
return <>
<Swipe currentPage={currentPage} />
- <button onClick={() => this.jump(currentPage + 1)}>下一頁</button>
+ <button onClick={this.jump(currentPage + 1)}>下一頁</button>
<span>`當前位於第${index}頁`</span>
</>
}
}
小明同學跑了上述單測, 測試通過✅, 於是開心地提交了代碼。結果上線后線上出現了問題! 這就是所謂測試用例對代碼進行了錯誤肯定
。因為測試用例測試了組件內部細節(此處為 jump
函數), 讓小明誤以為已經覆蓋了全部場景。
測試用例錯誤否定
以及錯誤肯定
都給開發者帶來了挫敗感與困擾, 究其原因是測試了組件內部的具體細節所至。而一個穩定可靠的測試用例應該脫離組件內部的實現細節, 越接近用戶行為的測試用例能給開發者帶來越充足的自信。相較於 enzyme, react-testing-library 所提供的 api 更加貼近用戶的使用行為, 使用其對上述測試用例進行重構:
import { render, fireEvent } from '@testing-library/react'
describe('Carousel Test', () => {
it('test jump', () => {
const { getByText } = render(<Carousel>
<div>第一頁</div>
<div>第二頁</div>
<div>第三頁</div>
</Carousel>)
expect(getByText(/當前位於第一頁/)).toBeInTheDocument()
fireEvent.click(getByText(/下一頁/))
expect(getByText(/當前位於第一頁/)).not.toBeInTheDocument()
expect(getByText(/當前位於第二頁/)).toBeInTheDocument()
})
})
關於 react-testing-Library
的用法總結將在下一章節 Jest 與 react-testing-Library 具體介紹。如果對 React 技術棧感興趣, 歡迎關注個人博客。