React全家桶與前端單元測試藝術|洞見

2021-02-20 ThoughtWorks洞見

TL;DR——什麼是好的單元測試?

其實我是個標題黨,單元測試根本沒有「藝術」可言。

好的單元測試來自於好的代碼,如果說有藝術,那也是代碼的藝術。

註:以下「測試」一詞,如非特指均為單元測試。

單元測試的好壞在於「單元」而不在「測試」。如果一個系統毫無單元可言,那就沒法進行單元測試,幾乎只能用Selenium做大量的E2E測試,其成本和穩定性可想而知。科學的單元劃分可以讓你擺脫mock,減少依賴,提高並行度,不依賴實現/易重構,提高測試對業務的覆蓋率,以及易學易用,大幅減少測試代碼。

最好的單元是返回簡單數據結構的函數:函數是最基本的抽象,可大可小,不需要mock,只依靠傳參。簡單數據結構可以判等。 

最好的測試工具是Assert.Equal這種的:只是判等。判等容易,判斷發生了什麼很難。你可以看到後面對於DOM和異步操作這些和副作用相關的例子都靠判等測試。把作用冪等於數據,拿到數據就一定發生作用,然後再測數據,是一個基本思路。

以上是你以前學習測試第一天就會的內容,所以不存在門檻。

為什麼不談TDD?

首先,TDD肯定是有價值的(價值大小不論)。反對TDD的原因一般比較明顯,對於TDD是否帶來正收益不確定(動機不足)。 某些項目質量要求很高,預算寬綽,TDD勢在必行。某些項目比較緊急,或者並非關鍵或無長期維護計劃,TDD理由就不充分。

為什麼談測試?

因為測試難。

第一難學,第二難寫。寫測試是個挺困難的活,要在測試裡正確重演業務要費好大勁,只能靠反覆練習。雖然這些測試在某些項目中是值得的,但是可能並不適合其他某些項目的基本情況。 

測試難,就代表訓練成本高,生產成本也高,收益就下降。要提高採用TDD的動機,與其說服別人,不如從簡化測試開始。

(圖片來自:http://t.cn/Rpw9WKg)

為什麼談前端測試?

一般項目都是後端測試覆蓋率高,同時後端套路也比較固定。測RESTful API粒度足夠大,可以很好地避開實現並且覆蓋業務。同時RESTful API一般也正好對應Web框架的Action handler,在這裡同時它粒度也足夠小,剛好可以直接調用而不啟動真的Web server,使得測試最大程度並行化。所以這樣測試收益總是最高的,爭議很小。 

前端不說套路不固定,測不測都有待商榷。因為前端流派不統一,資源不規則,邊界也不清晰,有渲染又有點業務,有導航有請求,很多團隊不測試/測Model/測Component/測E2E,五花八門。 但得益於JavaScript本身,前端測試其實是可以非常高效的。

下面你可以看到各種極簡極快的測試工具和測試方式,並且它們完全可以貫穿開發始終,而非僅給Hello World體量項目準備的,你可以在很大的全家桶項目中完全機械地套用這些方法。(機械也是極限的一部分,你不應該在使用工具過程中面臨太多抉擇,而應當專注於將業務翻譯成測試)。

為什麼談React全家桶?

前端從每周刷新一個框架,穩定到了Angular, React, Vue3個主流框架並存的階段。網絡中爭論這三個框架蓋的樓已經可以繞太陽系了。根據蓋的各種大樓看來,現在哪個更優秀還沒個定論。不過具體到單元測試方面,得益於Virtual DOM本身和模塊化設計(不然全家桶白叫了),React全家桶明顯更優秀些。

測試工具

我們本篇中的測試有三個目標:學得快,寫得快,跑得快。

(圖片來自:http://t.cn/RpwCke3)

平臺上Selenium, Phantom, Chrome, 包括Karma都比較重,最好的測試框架就是直接跑在node上的。本著極限編程的原則,我們將測試本身和測試環境儘可能簡化,以達到加快測試速度,最終反饋到開發速度的目的。

我們使用AVA進行測試,它非常簡潔,速度非常快,和mocha不同,它默認會啟動多線程並發測試。因此我們的測試必須減少共享狀態來提高並發能力,不然就會出現意想不到的錯誤。安裝和運行:

yarn add avaava --watch

這樣可以運行並watch測試。改變代碼測試結果會立刻改變,你也可以看到友善的錯誤信息,以及expected和actual之間的diff。寫下第一段測試:

import test from 'ava'test(t => {  t.is(1 + 1, 2)})

除了is方法以外,我們還會用到deepEqual和true方法。好,你現在已經完全會用AVA了。其他的功能我們完全不關心。

Redux測試 (Model測試)

Redux就是用一堆Reducer函數來reduce所有事件用來做全局Store的狀態機(FSM)。用源碼本身介紹它甚至比用上一小段文字介紹還快:

const createStore = reducer => {  let state, listeners = []  const dispatch = action => {    state = reducer(state, action)    listeners.forEach(listeners => listeners())  }  return {    getState() { return state },    subscribe(listener) {      listeners.push(listener)      return () => { listeners = listeners.filter(l => l !== listener)}    },    dispatch,  }}

這是一個簡化版的代碼,去掉了拋錯等等細節,但功能是完整的。把你自己寫的reducer扔進去,然後可以發事件來使其更新,你還可以訂閱它來拿狀態。有點像Event Sourcing,以消息而非調用來處理邏輯,更新和訂閱的邏輯不在一起(事件是寫模型,各種view就是多個讀模型)。

reducer幾乎包括了我們所有前端業務的核心,測好它就測了大半。它們全都是(State, Action) => nextState形式的純函數,無異步操作,用swtich case來模擬模式匹配來處理事件。比如用喜聞樂見的簡陋版的棧停車場舉例:

export const parkingLot = (state = [], action) => {  switch (action.type) {    case 'parkingLot/PARK':      return [action.car, ...state]    case 'parkingLot/PICK':      const [_, ...rest] = state      return rest    default: return state  }}

Reducer是這麼用的:

const store = createStore(parkingLot)store.subscribe(() => renderMyView(store.getState()))store.dispatch({ type: 'parkingLot/PARK' })

好,現在你又理解了Redux。那我們可以看看怎麼測試上面的parkingLot reducer了:

test('parking lot', t => {  const initial = parkingLot(undefined, {})  t.deepEqual(initial, [], 'should be empty when init')  const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })  t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')  const picked = parkingLot(parked, { type: 'parkingLot/PICK' })  t.deepEqual(picked, [], 'should remove the car')})

它就是你第一天學測試就會寫的那種測試。這些測試不受任何上下文影響,是冪等的。試著把那幾個const聲明的state挪到任何地方,你都可以發現測試還是正確的,這和我們平常小心翼翼分離各個測試case,並用beforeEach和afterEach重置截然不同。

(圖片來自:http://t.cn/RpwS3AK)

測試Reducer是非常機械的,你不需要問自己「我到底應該測哪些東西」,只需要機械地測試初始state和每個switch case就好了。(小秘密:redux-devtools寫完實現,在瀏覽器裡打開,反過來還可以自動生成各種框架的測試代碼,粘貼回來就行了。推薦不寫測試的項目嘗試下,反正白送的測試……而且跟你寫的沒兩樣)

隨著業務變得複雜,當state樹變大時,我們可以將reducer結構繼續往下抽,並繼續傳遞事件,函數沒有this,重構起來比普通OO要簡單得多,就不贅述了。這時候測試還是完全一樣的,這種樹形結構保證了我們能最大限度地覆蓋一個bounded context—也就是root reducer。

另外更好的方式是用t.is(斷言引用相同)而非t.deepEqual。但是JavaScript對象本身是可變的,引入immutable.js可以讓你只用t.is測試,不過immutable的API有點彆扭,不展開了。

組件測試 (View測試)

React是一個View library,它幹的活就是DOM domain裡的兩個事:渲染和捕獲事件。我們在這裡依然從簡,只用stateless component這個子集,雖然在用到生命周期方法的時候需要用一下class,但絕大多數時候應該只用stateless component。

它以Virtual DOM的形式封裝了噁心的瀏覽器基礎設施,讓我們以函數和數據結構來描述組件,所以和大部分框架不同,我們的測試依然可以在node上並行運行。如果用Karma + Chrome真正地渲染測試,你會發現共享一個瀏覽器實例的測試非常慢,幾乎無法watch測試,因此我們的TDD cycle就會變得不那麼流暢了。

最基本的就是state => UI這種純函數組件:

const Greeter = ({ name }) => <p>Greetings {name}!</p>

使用的時候就像HTML一樣傳遞attribute就可以了。

render(<Greeter name="React"/>, document.body)

最簡單的測試還是判等,我們用一個叫jsx-test-helpers的庫來幫我們渲染:

import { renderJSX, JSX } from 'jsx-test-helpers'const Paragraph = ({ children }) => <p>{children}</p>const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>test('Greeter', t => {  t.is(renderJSX(<Greeter name="React"/>),       JSX(<Paragraph>Greetings React!</Paragraph>),       'should render greeting text with name')})

這裡我多加了一層叫做Paragraph的組件,它的作用僅僅是傳遞給p標籤,children這個prop表示XML標籤傳進來的子元素。多加這層Paragraph是為了展示renderJSX只向下渲染了一層,而非最終需要渲染的p標籤。這樣我們在View上的測試粒度就會變得更小,成本更低,速度更快。

(圖片來自:http://t.cn/RpwYskG)

View不像業務本身那麼穩定,細粒度低成本的快速測試更划算些,這也是為什麼我們的View都只是接受參數渲染,這樣你只用測很少的case就能保證View可以正確渲染。假如你的FSM Model有M種可能性,View顯示的邏輯有N種,如果將兩個集成在一起測試可能就需要M×N種Path,如果分開測就有M+N種。View和Model的邊界清晰時,你的Model測試不容易被更困難的View測試幹擾,View測試也減少了混沌程度,需要測試的情形就減少了。

我們的組件不應該只有渲染,還有事件,比如我們封裝個TextField組件:

const TextField = ({ label, onChange }) => <label>  {label}  <input type="text" onChange={onChange} /></label>

當然我們還可以判等,只要onChange函數引用相同就好了。

test('TextField', t => {  const onChange = () => {}  const actual = renderJSX(<TextField label="Email" onChange={onChange} />)  const expected = JSX(<label>    Email    <input type="text" onChange={onChange}/>  </label>)  t.is(actual, expected)})

當然有時候你的組件更複雜些,測試時並不關心組件是不是完全按你想要的樣子渲染,可能你想像jQuery一樣選擇什麼,觸發什麼。這樣可以用更主流的enzyme來測試:

import {shallow} from 'enzyme'import sinon from 'sinon'test('TextField with enzyme', t => {  const onChange = sinon.spy()  const wrapper = shallow(<TextField label="Email" onChange={onChange} />)  t.true(wrapper.contains(<label>Email</label>), 'should render label')  const event = { target: { value: 'foo@bar.com' } }  wrapper.find('input').simulate('change', event)  t.true(onChange.calledWith(event))})

這裡用的shallow顧名思義,也是向下渲染一層。此外我們還用了spy,這樣測試就變得有點複雜了,丟掉了我們之前聲明式的優雅,所以組件還是小一點、一下測完比較好。

還不夠快?Facebook就覺得不夠快,他們覺得View測試成本比較浪費,乾脆搞了個Snapshot測試——意思就是照個像,只斷言它不變。下次誰改了別的地方不小心影響到這裡,就會掛掉,如果無意的就修好,如果有意的話和git一樣commit一下就修好了:

import render from 'react-test-renderer'test('Greeter', t => {  const tree = render.create(<Greeter name="React"/>).toJSON()  t.snapshot(tree, 'should not change')})

當你修改Greeter的時候,測試就會掛掉,這時候運行:

ava --update-snapshots

就好了。Facebook自家的Jest對snapshot的支持更好,當snapshot不匹配時按個y/n就完事了,夠快了吧。要有更快的可能就是不測了……

小結

這節裡我們展示了3種測試View的不同方式,它們都比傳統框架更簡單更快速。我們的思路還是以判等為主,但不同於Model,粒度越大越好。View測試粒度越小越好,足夠小、足夠冪等之後,其實不用測試你也可以發現組件總是按照預期工作。相比之下MVVM天然有一種讓View和Model粒度擬合的傾向,很容易讓測試變得既難測又缺乏價值。

異步Effect測試

這算個續集……異步操作不複雜的項目可以無視這段,可以選擇性不測。

React先解決了噁心的DOM問題,把Model的問題留下了。然後Redux把同步邏輯解決了,其實前端還留下異步操作的大問題沒有解決。這種類似Unix「只做一件事」的哲學是React全家桶的根基。我們用一個叫做Redux-saga的庫來展現全家桶的異步測試怎麼寫,Redux模仿的目標是Elm architecture,但是簡化掉了Elm的作用模型,只保留了同步模型,Redux-saga其實就是把Elm的作用模型又拿回來了。

Saga是一種worker模式,很早之前在Java社區就存在了。Redux-saga抽象出來多種通用的作用比如call / takeEvery等等,然後有了這些作用,我們又可以愉快地判等了。比如:

import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'function *account() {  yield call(takeEvery, 'login/REQUESTED', login)}function *login({ name, password }) {  try {    const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })    yield put({ type: 'login/SUCCEEDED', token })  }  catch (error) {    yield put ({ type: 'login/FAILED', error })  }}

這段代碼乍看起來很醜,這是因為它把程序裡所有異步操作全都集中在自己身上了。其他部分都可以開心地發同步事件了,此外有了Saga之後Redux終於有了「用事件觸發事件」的機制了,只用redux,應用複雜到一定程度你一定會想這個問題的。

這是個最普通的API處理saga,一個account worker看到每個'login/REQUESTED'就會forward給login worker(takeEvery),讓它繼續管下面的事。然後login worker拿到消息就會去發請求(call),之後傻傻地等著回復,或者是出錯。最後它會發出和結果相關的事件。用這個方式你可以輕鬆解決瘋狂難度的異步問題。

test('account saga', t => {  const gen = account()  t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))})test('login saga', t => {  const gen = login({ name: 'John', password: 'super-secret-123'})  const request = gen.next().value  t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))  const response = gen.next({ token: 'non-human-readable-token' }).value  t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))  const failure = gen.throw('You code just exploded!').value  t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))})

你看我們的測試連異步操作都還可以無恥地判等。call就是以某些參數調用某個函數,put就是發事件。

可以試著把fetch覆蓋成空函數,你可以發現實際上副作用根本沒發生,「fetch到底是個啥」對測試一點影響都沒有。你可能發現了,其實saga就是用數據結構表示作用,而不著急執行,在這裡又走回冪等的老路了。這和React Virtual DOM的思路異曲同工。

結語

首先是文章開頭提到的TL;DR的內容。函數是個好東西,測函數不等同「測1+1=2」這種沒營養的單元,函數是可以包含很大上下文的。這種輸入輸出的模型既簡單又有效。

我們消滅了mock,減少了依賴,並發了測試,加快了速度,降低了門檻,減少了測試路徑等等。如果你的React項目原來在TDD的邊緣搖擺不定,現在是時候入一發這種唯快不破了。

全家桶讓Model/View/Async這三者之間的邊界變得清晰,任由業務變更,它們之間的職責是不會互相替代的,這樣你測它們的時候才更容易。後端之所以測試穩定是因為有API。所以想讓前端好測也是一樣的思路。

文中好多次提到「冪等」這個概念,冪等可以讓你減少測試的case,寫代碼更有底氣。拋開測試不談,代碼冪等的地方越多,程序越可控可預期。其實仔細思考一下我們的實際項目,大部分業務都是非常確定的,並沒有什麼隨機因素。為什麼最後還是會出現很多隨機現象呢?

聲明優於命令,描述發生什麼、想要什麼比親自指導具體步驟好。

消息機制優於調用機制。Smalltalk > Simula。其實RESTful API一定程度上也是消息。簡單的對象直接互相作用是完全沒問題的,人作為複雜對象主要通過語言媒介來交流,聽到內容思考其中的含義,而不是靠肢體接觸,或者像連體嬰兒那樣共享器官。所以才有一句俗語叫「你的對象都想成長為Actor」。

從View的幾種測試裡我們也可以看到,測試並不是只有測或者不測這兩種選擇,我們老提測試金字塔,意思是測試可多可少,不同層級的測試保持正金字塔形狀比較健康,像今天我們說的就可以大幅加寬你測試金字塔的底座。所以你的項目有可能測試過少,也可能測試過度,所以時間可以動態調整。

沒用全家桶的項目可以把「大Model小View」的思想拿走,這樣更容易於專注價值。儘量抽出Model層,不要把邏輯寫在VM裡,看那樣似省事,行數在測試裡都還回來了。

- 相關閱讀 -

解讀GraphQL|洞見

使用Enzyme測試React(Native)組件|洞見

點擊【閱讀原文】可至洞見網站查看原文&綠色字體部分的相關連結。

本文版權屬ThoughtWorks公司所有,如需轉載請在後臺留言聯繫。

相關焦點

  • React + Redux + React-router 全家桶
    web端開發需要搭配React-dom使用import React from 'react';import ReactDOM from 'react-dom';const App = () => (  <div>      <
  • Web 前端單元測試到底要怎麼寫?看這一篇就夠了
    隨著 Web 應用的複雜程度越來越高,很多公司越來越重視前端單元測試。我們看到的大多數教程都會講單元測試的重要性、一些有代表性的測試框架 api 怎麼使用,但在實際項目中單元測試要怎麼下手?測試用例應該包含哪些具體內容呢?  本文從一個真實的應用場景出發,從設計模式、代碼結構來分析單元測試應該包含哪些內容,具體測試用例怎麼寫,希望看到的童鞋都能有所收穫。
  • Vue、React、Angular之三國殺,web前端入坑第六篇(上)
    vue、react、angular對比和選擇 這個話題我在vue1.x 時代 2016年 就想寫了,可時光如梭,懶癌侵身,一個擱淺便是這麼多天。vue都2.5了,angular 都變成了另外一種框架了,不敢想,不敢想, JavaScript 開發框架發展的是如此之快。如果有不知道mvvm概念的同學,請先回顧我 入坑第五篇: 秒懂前端框架歷史和MVVM框架原理!
  • 【前端技術】react渲染 - 流程概述
    作者:winkchen  騰訊IEG前端開發工程師|導語 web前端技術中
  • 推薦一些 GitHub 上值得前端學習的開源實戰項目,進階必看!
    地址:https://github.com/PanJiaChen/vue-element-admin2. blog-vue-typescript此項目是基於 Vue 全家桶 + TypeScript + Element-UI 技術棧的簡潔時尚博客網站。
  • 單元測試的藝術
    最近讀了《單元測試的藝術》一書,對單元測試、單元測試的好處及自動化測試過程有了更深的了解。1. 工作單元(Unit of Work):從調用系統的一個公共方法到產生一個測試可見的最終結果期間這個系統發生的行為的總稱。2.
  • 前端諸神大戰,Vue、React 依舊笑傲江湖
    整理 | 彎月 責編 | 張文頭圖 | CSDN 下載自視覺中國自 2010 年 AngularJS 第一版發布以來,前端框架的發展經歷了十個年頭,前端框架大戰也幾乎告一段落。毫無疑問,現在基本上是 React、Angular(包括 AngularJS 和後來的 Angular)和 Vue.js 三分天下。
  • 軟體測試反模式——杯型蛋糕簡介 TW洞見
    已經本網協議授權的媒體、網站,在使用時必須註明"內容來源:ThoughtWorks洞見",並指定原文連結,違者本網將依法追究責任。要想幫助團隊制定測試策略,編寫出可靠可伸縮的測試,測試金字塔是最好的方式之一。 根據我多次的使用經驗來看,它真的非常有用。同時,我也經常會看到有的團隊在嘗試實踐測試策略時掉進各種陷阱裡。
  • react的核心api-前端進階
    資源 1. react 2. create-r eact-app 起步 1. 安裝官方腳手架: npm install -g create-react-app 2.創建項目: create-react-app react-study 3.
  • React 測試入門教程
    $ git clone https://github.com/ruanyf/react-testing-demo.git$ cd react-testing-demo然後,打開 http://127.0.0.1:8080/,你會看到一個 Todo 應用。
  • ​if 我是前端團隊 Leader,怎麼制定前端協作規範?
    狹義的集成可以簡單認為是『集成測試』吧. 集成測試可以對代碼靜態測試、單元測試、通過單元測試後可以進行集成測試,在應用組成一個整體後在模擬環境中跑E2E測試等等。也就是說,在這裡進行一系列的自動化測試來驗證軟體系統。廣義的持續集成服務,不僅僅是測試,它還衍生出很多概念,例如持續交付、持續部署,如下圖
  • 中國全家桶和美國全家桶有啥區別?來對比一下
    這不,有一位網友在網上發了幾張中國全家桶VS美國全家桶的照片,差距一眼便知,中國網友:果然是「後媽」生的。中國全家桶:說到中國的全家桶有些朋友可能就來氣,甚至有些人可能還忍不住謾罵。在很多人心中,中國的全家桶可能就是一款名不副實的產品,說好的全家一起吃,可能就只夠2個人吃,或許還不能吃飽。咋的,難道全家…全家只允許家裡有2個人嗎?
  • 中國全家桶VS美國全家桶,網友:肯德基,給我一個說法!
    肯德基在中國生意那是非常的好的,經常是滿座、排隊的狀況,主要是裡面的東西味道的確是不錯的,而且東西上得也快,在裡面還可以辦公等等,也是非常的方便,很多的年輕白領都是非常喜歡這種地方的,全家桶我想大家都吃過吧
  • 2018尚矽谷Vue技術全家桶視頻教程發布
    Vue是一套用於構建用戶界面的漸進式框架,作為目前極為流行的前端框架,Vue相關技術逐漸成為一種前端工程師必備技能,基於此需要,尚矽谷教育的專業老師細緻全面的總結了Vue框架相關技術,深入源碼講解,力爭讓學員學習的更透徹
  • 前端技術:React&Vue對比
    React和vue的業務邏輯是差不多,vue在react上封裝了更簡潔的方法,使用起來更加的便捷,如:提供了便捷的指令(v-for,v-if,v-model),還提供了更多的屬性(computed,watch),我還是比較喜歡用react的,更接近js原生,更容易於理解它。
  • 麥當勞開賣「全家桶」?
    在肯德基「全家桶」縱橫馳騁了這麼多年後,全球另一大快餐巨頭麥當勞終於按耐不住了。據外媒消息,麥當勞近期將開始在美國的堪薩斯城進行試點,銷售其自己的「全家桶」盒裝快餐。據悉,這款「麥式全家桶」的「標準配置」是:兩個芝士大漢堡,兩份中包薯條,十塊麥樂雞配蘸醬。「麥式全家桶」的售價為14.99美元,目前只在堪薩斯城的門店中進行試點推廣。
  • 組裝桌上型電腦:能運行Adobe全家桶,3200元以內
    根據網友的需求來看這臺電腦也就是用來運行Adobe全家桶的電腦了,恰巧筆者自己也是一名UI設計師以及前端工程師,並且對於計算機的硬體也有一定的了解,對於Adboe全家桶的尿性也非常的熟悉,也算是經驗豐富了。
  • 2021年最新整理web前端學習路線(內附學習教程視頻連結)
    三、前端高級進階1. React技術全家桶:本視頻將從React項目中帶領著大家從0開始,創建一個完整的web項目。讓大家從基礎到精通的了解react的企業級別開發流程。完整的webApp項目:知識點包含react基礎、高級的知識以及狀態管理和路由(react全家桶)等等。
  • kpc v0.7.8 發布,同時支持 Vue/React/Intact 的前端組件庫
    眾所周知, 前端單頁應用的開發無非基於3大框架:React/Vue/Angular。如果不同框架維護一套自己的組件庫,一方面 維護成本非常高,存在大量重複勞動力;另一方面,即使大家都按統一的互動設計稿開發組件庫,也很難保證 各個組件庫交互和設計的完全統一。