React全家桶與前端單元測試藝術(shù)
TL;DR——什么是好的單元測試?
其實(shí)我是個標(biāo)題黨,單元測試根本沒有“藝術(shù)”可言。
好的測試來自于好的代碼,如果說有藝術(shù),那也是代碼的藝術(shù)。
注:以下“測試”一詞,如非特指均為單元測試。
單元測試的好壞在于“單元”而不在“測試”。如果一個系統(tǒng)毫無單元可言,那就沒法進(jìn)行單元測試,幾乎只能用 Selenium 做大量的E2E測試,其成本和穩(wěn)定性可想而知。科學(xué)的單元劃分可以讓你擺脫mock,減少依賴,提高并行度,不依賴實(shí)現(xiàn)/易重構(gòu),提高測試對業(yè)務(wù)的覆蓋率,以及易學(xué)易用,大幅減少測試代碼。
最好的單元是返回簡單數(shù)據(jù)結(jié)構(gòu)的函數(shù):函數(shù)是最基本的抽象,可大可小,不需要mock,只依靠傳參。簡單數(shù)據(jù)結(jié)構(gòu)可以判等。 最好的測試工具是Assert.Equal這種的:只是判等。判等容易,判斷發(fā)生了什么很難。你可以看到后面對于DOM和異步操作這些和副作用相關(guān)的例子都靠判等測試。把作用冪等于數(shù)據(jù),拿到數(shù)據(jù)就一定發(fā)生作用,然后再測數(shù)據(jù),是一個基本思路。
以上是你以前學(xué)習(xí)測試第一天就會的內(nèi)容,所以不存在門檻。
為什么不談TDD?
首先, TDD 肯定是有價(jià)值的(價(jià)值大小不論)。反對TDD的原因一般比較明顯,對于TDD是否帶來正收益不確定(動機(jī)不足)。 某些項(xiàng)目質(zhì)量要求很高,預(yù)算寬綽,TDD勢在必行。某些項(xiàng)目比較緊急,或者并非關(guān)鍵或無長期維護(hù)計(jì)劃,TDD理由就不充分。
為什么談測試?
因?yàn)闇y試難。
第一難學(xué),第二難寫。寫測試是個挺困難的活,要在測試?yán)镎_重演業(yè)務(wù)要費(fèi)好大勁,只能靠反復(fù)練習(xí)。雖然這些測試在某些項(xiàng)目中是值得的,但是可能并不適合其他某些項(xiàng)目的基本情況。
測試難,就代表訓(xùn)練成本高,生產(chǎn)成本也高,收益就下降。要提高采用TDD的動機(jī),與其說服別人,不如從簡化測試開始。
為什么談前端測試?
一般項(xiàng)目都是后端測試覆蓋率高,同時后端套路也比較固定。測RESTful API粒度足夠大,可以很好地避開實(shí)現(xiàn)并且覆蓋業(yè)務(wù)。同時RESTful API一般也正好對應(yīng)Web框架的Action handler,在這里同時它粒度也足夠小,剛好可以直接調(diào)用而不啟動真的Web server,使得測試最大程度并行化。所以這樣測試收益總是最高的,爭議很小。
前端不說套路不固定,測不測都有待商榷。因?yàn)榍岸肆髋刹唤y(tǒng)一,資源不規(guī)則,邊界也不清晰,有渲染又有點(diǎn)業(yè)務(wù),有導(dǎo)航有請求,很多團(tuán)隊(duì)不測試/測Model/測Component/測E2E,五花八門。 但得益于JavaScript本身,前端測試其實(shí)是可以非常高效的。
下面你可以看到各種極簡極快的測試工具和測試方式,并且它們完全可以 貫穿開發(fā)始終,而非僅給Hello World體量項(xiàng)目準(zhǔn)備的 ,你可以在很大的全家桶項(xiàng)目中完全機(jī)械地套用這些方法。(機(jī)械也是極限的一部分,你不應(yīng)該在使用工具過程中面臨太多抉擇,而應(yīng)當(dāng)專注于將業(yè)務(wù)翻譯成測試)。
為什么談React全家桶?
前端從每周刷新一個框架,穩(wěn)定到了 Angular , React , Vue 3個主流框架并存的階段。網(wǎng)絡(luò)中爭論這三個框架蓋的樓已經(jīng)可以繞太陽系了。根據(jù)蓋的各種大樓看來,現(xiàn)在哪個更優(yōu)秀還沒個定論。不過具體到單元測試方面,得益于Virtual DOM本身和模塊化設(shè)計(jì)(不然全家桶白叫了),React全家桶明顯更優(yōu)秀些。
測試工具
我們本篇中的測試有三個目標(biāo): 學(xué)得快,寫得快,跑得快 。
平臺上 Selenium , Phantom , Chrome , 包括 Karma 都比較重,最好的測試框架就是直接跑在 node 上的。本著極限編程的原則,我們將測試本身和測試環(huán)境盡可能簡化,以達(dá)到加快測試速度,最終反饋到開發(fā)速度的目的。
我們使用 AVA 進(jìn)行測試,它非常簡潔,速度非常快,和 mocha 不同,它默認(rèn)會啟動多線程并發(fā)測試。因此我們的測試必須減少共享狀態(tài)來提高并發(fā)能力,不然就會出現(xiàn)意想不到的錯誤。安裝和運(yùn)行:
- yarn add ava
- ava --watch
這樣可以運(yùn)行并watch測試。改變代碼測試結(jié)果會立刻改變,你也可以看到友善的錯誤信息,以及expected和actual之間的diff。寫下第一段測試:
- import test from 'ava'
- test(t => {
- t.is(1 + 1, 2)
- })
除了is方法以外,我們還會用到deepEqual和true方法。好,你現(xiàn)在已經(jīng)完全會用AVA了。其他的功能我們完全不關(guān)心。
Redux測試 (Model測試)
Redux 就是用一堆Reducer函數(shù)來reduce所有事件用來做全局Store的狀態(tài)機(jī)( 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,
- }
- }
這是一個簡化版的代碼,去掉了拋錯等等細(xì)節(jié),但功能是完整的。把你自己寫的reducer扔進(jìn)去,然后可以發(fā)事件來使其更新,你還可以訂閱它來拿狀態(tài)。有點(diǎn)像 Event Sourcing ,以消息而非調(diào)用來處理邏輯,更新和訂閱的邏輯不在一起(事件是寫模型,各種view就是多個讀模型)。
reducer幾乎包括了我們所有前端業(yè)務(wù)的核心,測好它就測了大半。它們?nèi)际?(State, Action) => nextState 形式的純函數(shù),無異步操作,用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' })
好,現(xiàn)在你又理解了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')
- })
它就是你第一天學(xué)測試就會寫的那種測試。這些測試不受任何上下文影響,是冪等的。試著把那幾個const聲明的state挪到任何地方,你都可以發(fā)現(xiàn)測試還是正確的,這和我們平常小心翼翼分離各個測試case,并用beforeEach和afterEach重置截然不同。
測試Reducer是非常機(jī)械的,你不需要問自己“我到底應(yīng)該測哪些東西”,只需要機(jī)械地測試初始state和每個switch case就好了。(小秘密:redux-devtools寫完實(shí)現(xiàn),在瀏覽器里打開,反過來還可以自動生成各種框架的測試代碼,粘貼回來就行了。推薦不寫測試的項(xiàng)目嘗試下,反正白送的測試……而且跟你寫的沒兩樣)
隨著業(yè)務(wù)變得復(fù)雜,當(dāng)state樹變大時,我們可以將reducer結(jié)構(gòu)繼續(xù)往下抽,并繼續(xù)傳遞事件,函數(shù)沒有this,重構(gòu)起來比普通OO要簡單得多,就不贅述了。這時候測試還是完全一樣的,這種樹形結(jié)構(gòu)保證了我們能最大限度地覆蓋一個bounded context—也就是root reducer。
另外更好的方式是用t.is(斷言引用相同)而非t.deepEqual。但是JavaScript對象本身是可變的,引入 immutable.js 可以讓你只用t.is測試,不過immutable的API有點(diǎn)別扭,不展開了。
組件測試 (View測試)
React是一個View library,它干的活就是DOM domain里的兩個事:渲染和捕獲事件。我們在這里依然從簡,只用stateless component這個子集,雖然在用到生命周期方法的時候需要用一下class,但絕大多數(shù)時候應(yīng)該只用stateless component。
它以Virtual DOM的形式封裝了惡心的瀏覽器基礎(chǔ)設(shè)施,讓我們以函數(shù)和數(shù)據(jù)結(jié)構(gòu)來描述組件,所以和大部分框架不同,我們的測試依然可以在node上并行運(yùn)行。如果用Karma + Chrome真正地渲染測試,你會發(fā)現(xiàn)共享一個瀏覽器實(shí)例的測試非常慢,幾乎無法watch測試,因此我們的TDD cycle就會變得不那么流暢了。
最基本的就是 state => UI 這種純函數(shù)組件:
- 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標(biāo)簽,children這個prop表示XML標(biāo)簽傳進(jìn)來的子元素。多加這層Paragraph是為了展示renderJSX只向下渲染了一層,而非最終需要渲染的p標(biāo)簽。這樣我們在View上的測試粒度就會變得更小,成本更低,速度更快。
View不像業(yè)務(wù)本身那么穩(wěn)定,細(xì)粒度低成本的快速測試更劃算些,這也是為什么我們的View都只是接受參數(shù)渲染,這樣你只用測很少的case就能保證View可以正確渲染。假如你的FSM Model有M種可能性,View顯示的邏輯有N種,如果將兩個集成在一起測試可能就需要M×N種Path,如果分開測就有M+N種。View和Model的邊界清晰時,你的Model測試不容易被更困難的View測試干擾,View測試也減少了混沌程度,需要測試的情形就減少了。
我們的組件不應(yīng)該只有渲染,還有事件,比如我們封裝個TextField組件:
- const TextField = ({ label, onChange }) => <label>
- {label}
- <input type="text" onChange={onChange} />
- </label>
當(dāng)然我們還可以判等,只要onChange函數(shù)引用相同就好了。
- test('TextField', t => {
- const onChange = () => {}
- const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
- const expected = JSX(<label>
- <input type="text" onChange={onChange}/>
- </label>)
- t.is(actual, expected)
- })
當(dāng)然有時候你的組件更復(fù)雜些,測試時并不關(guān)心組件是不是完全按你想要的樣子渲染,可能你想像 jQuery 一樣選擇什么,觸發(fā)什么。這樣可以用更主流的 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,這樣測試就變得有點(diǎn)復(fù)雜了,丟掉了我們之前聲明式的優(yōu)雅,所以組件還是小一點(diǎn)、一下測完比較好。
還不夠快?Facebook就覺得不夠快,他們覺得View測試成本比較浪費(fèi),干脆搞了個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')
- })
當(dāng)你修改Greeter的時候,測試就會掛掉,這時候運(yùn)行:
- ava --update-snapshots
就好了。Facebook自家的 Jest 對snapshot的支持更好,當(dāng)snapshot不匹配時按個y/n就完事了,夠快了吧。要有更快的可能就是不測了……
小結(jié)
這節(jié)里我們展示了3種測試View的不同方式,它們都比傳統(tǒng)框架更簡單更快速。我們的思路還是以判等為主,但不同于Model,粒度越大越好。View測試粒度越小越好,足夠小、足夠冪等之后,其實(shí)不用測試你也可以發(fā)現(xiàn)組件總是按照預(yù)期工作。相比之下MVVM天然有一種讓View和Model粒度擬合的傾向,很容易讓測試變得既難測又缺乏價(jià)值。
異步Effect測試
這算個續(xù)集……異步操作不復(fù)雜的項(xiàng)目可以無視這段,可以選擇性不測。
React先解決了惡心的DOM問題,把Model的問題留下了。然后Redux把同步邏輯解決了,其實(shí)前端還留下異步操作的大問題沒有解決。這種類似“Unix只做一件事”的哲學(xué)是React全家桶的根基。我們用一個叫做 Redux-saga 的庫來展現(xiàn)全家桶的異步測試怎么寫,Redux模仿的目標(biāo)是 Elm architecture,但是簡化掉了Elm的作用模型,只保留了同步模型,Redux-saga其實(shí)就是把Elm的作用模型又拿回來了。
Saga是一種worker模式,很早之前在Java社區(qū)就存在了。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 })
- }
- }
這段代碼乍看起來很丑,這是因?yàn)樗殉绦蚶锼挟惒讲僮魅技性谧约荷砩狭恕F渌糠侄伎梢蚤_心地發(fā)同步事件了,此外有了Saga之后Redux終于有了“用事件觸發(fā)事件”的機(jī)制了,只用redux,應(yīng)用復(fù)雜到一定程度你一定會想這個問題的。
這是個最普通的API處理saga,一個account worker看到每個’login/REQUESTED’就會forward給login worker(takeEvery),讓它繼續(xù)管下面的事。然后login worker拿到消息就會去發(fā)請求(call),之后傻傻地等著回復(fù),或者是出錯。最后它會發(fā)出和結(jié)果相關(guān)的事件。用這個方式你可以輕松解決瘋狂難度的異步問題。
- 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就是以某些參數(shù)調(diào)用某個函數(shù),put就是發(fā)事件。
可以試著把fetch覆蓋成空函數(shù),你可以發(fā)現(xiàn)實(shí)際上副作用根本沒發(fā)生,“fetch到底是個啥”對測試一點(diǎn)影響都沒有。你可能發(fā)現(xiàn)了,其實(shí)saga就是用數(shù)據(jù)結(jié)構(gòu)表示作用,而不著急執(zhí)行,在這里又走回冪等的老路了。這和React Virtual DOM的思路異曲同工。
結(jié)語
首先是文章開頭提到的TL;DR的內(nèi)容。函數(shù)是個好東西,測函數(shù)不等同“測1+1=2”這種沒營養(yǎng)的單元,函數(shù)是可以包含很大上下文的。這種輸入輸出的模型既簡單又有效。
我們消滅了mock,減少了依賴,并發(fā)了測試,加快了速度,降低了門檻,減少了測試路徑等等。如果你的React項(xiàng)目原來在TDD的邊緣搖擺不定,現(xiàn)在是時候入一發(fā)這種唯快不破了。
全家桶讓Model/View/Async這三者之間的邊界變得清晰,任由業(yè)務(wù)變更,它們之間的職責(zé)是不會互相替代的,這樣你測它們的時候才更容易。后端之所以測試穩(wěn)定是因?yàn)橛蠥PI。所以想讓前端好測也是一樣的思路。
文中好多次提到“冪等”這個概念,冪等可以讓你減少測試的case,寫代碼更有底氣。拋開測試不談,代碼冪等的地方越多,程序越可控可預(yù)期。其實(shí)仔細(xì)思考一下我們的實(shí)際項(xiàng)目,大部分業(yè)務(wù)都是非常確定的,并沒有什么隨機(jī)因素。為什么最后還是會出現(xiàn)很多隨機(jī)現(xiàn)象呢?
聲明優(yōu)于命令,描述發(fā)生什么、想要什么比親自指導(dǎo)具體步驟好。
消息機(jī)制優(yōu)于調(diào)用機(jī)制。Smalltalk > Simula。其實(shí)RESTful API一定程度上也是消息。簡單的對象直接互相作用是完全沒問題的,人作為復(fù)雜對象主要通過語言媒介來交流,聽到內(nèi)容思考其中的含義,而不是靠肢體接觸,或者像連體嬰兒那樣共享器官。所以才有一句俗語叫“你的對象都想成長為Actor”。
從View的幾種測試?yán)镂覀円部梢钥吹剑瑴y試并不是只有測或者不測這兩種選擇,我們老提測試金字塔,意思是測試可多可少,不同層級的測試保持正金字塔形狀比較健康,像今天我們說的就可以大幅加寬你測試金字塔的底座。所以你的項(xiàng)目有可能測試過少,也可能測試過度,所以時間可以動態(tài)調(diào)整。
沒用全家桶的項(xiàng)目可以把“大Model小View”的思想拿走,這樣更容易于專注價(jià)值。盡量抽出Model層,不要把邏輯寫在VM里,看那樣似省事,行數(shù)在測試?yán)锒歼€回來了。