【W(wǎng)eb技術(shù)】1320- 一篇文章搞定前端單元測試框架 Jest
前言
雖然有很多前端團隊壓根現(xiàn)在甚至未來都不太可能使用單元測試,包括我自己的團隊,原因無非是耽誤時間,開發(fā)任務(wù)本身就比較重等等理由。
但是我覺得一味的圖快,永遠(yuǎn)是飲鴆止渴,陷入惡性循環(huán),項目快 \--> 代碼爛 \--> 修改和加功能花費更多的時間和精力 \--> 來不及做優(yōu)化必須更快 \--> 項目快 \--> 代碼爛 \--> ... 無限循環(huán)。
這就是做單元測試我認(rèn)為最重要的原因就是,重構(gòu)代碼時,確認(rèn)功能沒有問題,不怕人員流動,功能遷移,最主要的是跟產(chǎn)品撕b,測試用例就是最好的證據(jù)??。
業(yè)務(wù)項目用不到的話,如果你寫庫,不寫單測,可能用的同學(xué)都會有所顧忌,所以會寫單測是對高級以上前端必備的技能。
單元測試框架基本原理
例如如下的一個測試用例,感受一下基本的樣子長啥,我們后面會把其中用到的方法自己實現(xiàn)一個簡單版本
//?意思是字符串hello是否包含ll
test('測試字符串中是否包含?ll'),?()?=>?{
????expect(findStr('hello')).toMatch('ll')
})
function?findStr(str){
????return?`${str}?world`
}
復(fù)制代碼
我們可以簡單的實現(xiàn)一下上面測試用例用到的方法,test、expect、toMatch,這樣就算掌握了基本的測試框架原理
test
function?test(desc,?fn){
????try{
????????fn();
????????console.log(`???通過測試用例`)
????}catch{
????????console.log(`??沒有通過測試用例`)
????}
}
復(fù)制代碼
expect、toMatch
function?expect(ret){
????return?{
????????toMatch(expRet){
????????????if(typeof?ret?===?'string'){?throw?Error('')?}
????????????if(!ret.includes(expRet)){?throw?Error('')?}
????????}
????}
}
復(fù)制代碼
jest基本配置
必備工具:
$?npm?i?-D?jest?babel-jest?ts-jest?@types/jest
復(fù)制代碼
參考配置jest.config.js,測試文件均放在tests目錄中:下面的testRegex表示匹配的tests文件夾下的以test或者spec結(jié)尾的jsx或者tsx文件
module.exports?=?{
??transform:?{
????'^.+\\.tsx?$':?'ts-jest',
??},
??testRegex:?'(/tests/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
??moduleFileExtensions:?['tsx',?'ts',?'js',?'jsx',?'json',?'node'],
};
復(fù)制代碼
最后在package.json的scripts中加入
{
????test:?"jest"
????//?如果要測試覆蓋率,后面加上--coverage
????//?如果要監(jiān)聽所有測試文件?--watchAll
}
復(fù)制代碼
匹配器
匹配器(Matchers)是Jest中非常重要的一個概念,它可以提供很多種方式來讓你去驗證你所測試的返回值。舉個例子就明白什么是匹配器了。
這里的匹配器掃一眼即可,大概知道有那么回事,用的時候查你想要的匹配器就行,不用刻意去記憶。
相等匹配,這是我們最常用的匹配規(guī)則
test('two?plus?two?is?four',?()?=>?{
??expect(2?+?2).toBe(4);
});
復(fù)制代碼
在這段代碼中 expact(2 + 2) 將返回我們期望的結(jié)果,通常情況下我們只需要調(diào)用expect就可以,括號中的可以是一個具有返回值的函數(shù),也可以是表達(dá)式。后面的toBe 就是一匹配器。
下面列舉一些常用的匹配器:
普通匹配器
toBe:object.is 相當(dāng)于 ===
test('測試加法?3?+?7',?()?=>?{
??//?toBe?匹配器?matchers?object.is?相當(dāng)于?===
??expect(10).toBe(10)
})
復(fù)制代碼
toEqual:內(nèi)容相等,匹配內(nèi)容,不匹配引用
test('toEqual?匹配器',?()?=>?{
??//?toEqual?匹配器?只會匹配內(nèi)容,不會匹配引用
??const?a?=?{?one:?1?}
??expect(a).toEqual({?one:?1?})
})
復(fù)制代碼
與真假有關(guān)的匹配器
真假 toBeNull:只匹配 Null
test('toBeNull?匹配器',?()?=>?{
??//?toBeNull
??const?a?=?null
??expect(a).toBeNull()
})
復(fù)制代碼
toBeUndefined:只匹配 undefined
test('toBeUndefined?匹配器',?()?=>?{
??const?a?=?undefined
??expect(a).toBeUndefined()
})
復(fù)制代碼
toBeDefined:與 toBeUndefined 相反,這里匹配 null 是通過的
test('toBeDefined?匹配器',?()?=>?{
??const?a?=?null
??expect(a).toBeDefined()
})
復(fù)制代碼
toBeTruthy:匹配任何 if 語句為 true
test('toBeTruthy?匹配器',?()?=>?{
??const?a?=?1
??expect(a).toBeTruthy()
})
復(fù)制代碼
toBeFalsy:匹配任何 if 語句為 false
test('toBeFalsy?匹配器',?()?=>?{
??const?a?=?0
??expect(a).toBeFalsy()
})
復(fù)制代碼
not:取反
test('not?匹配器',?()?=>?{
??const?a?=?1
??//?以下兩個匹配器是一樣的
??expect(a).not.toBeFalsy()
??expect(a).toBeTruthy()
})
復(fù)制代碼
數(shù)字
toBeGreaterThan:大于
test('toBeGreaterThan',?()?=>?{
??const?count?=?10
??expect(count).toBeGreaterThan(9)
})
復(fù)制代碼
toBeLessThan:小于
test('toBeLessThan',?()?=>?{
??const?count?=?10
??expect(count).toBeLessThan(12)
})
復(fù)制代碼
toBeGreaterThanOrEqual:大于等于
test('toBeGreaterThanOrEqual',?()?=>?{
??const?count?=?10
??expect(count).toBeGreaterThanOrEqual(10)?//?大于等于?10
})
復(fù)制代碼
toBeLessThanOrEqual:小于等于
test('toBeLessThanOrEqual',?()?=>?{
??const?count?=?10
??expect(count).toBeLessThanOrEqual(10)?//?小于等于?10
})
復(fù)制代碼
toBeCloseTo:計算浮點數(shù)
test('toBeCloseTo',?()?=>?{
??const?firstNumber?=?0.1
??const?secondNumber?=?0.2
??expect(firstNumber?+?secondNumber).toBeCloseTo(0.3)?//?計算浮點數(shù)
})
復(fù)制代碼
字符串
toMatch:匹配某個特定項字符串,支持正則
test('toMatch',?()?=>?{
??const?str?=?'http://www.zsh.com'
??expect(str).toMatch('zsh')
??expect(str).toMatch(/zsh/)
})
復(fù)制代碼
數(shù)組
toContain:匹配是否包含某個特定項
test('toContain',?()?=>?{
??const?arr?=?['z',?'s',?'h']
??const?data?=?new?Set(arr)
??expect(data).toContain('z')
})
復(fù)制代碼
異常
toThrow
const?throwNewErrorFunc?=?()?=>?{
??throw?new?Error('this?is?a?new?error')
}
test('toThrow',?()?=>?{
??//?拋出的異常也要一樣才可以通過,也可以寫正則表達(dá)式
??expect(throwNewErrorFunc).toThrow('this?is?a?new?error')
})
復(fù)制代碼
測試異步代碼
假設(shè)請求函數(shù)如下
const fethUserInfo = fetch('http://xxxx')
復(fù)制代碼
測試異步代碼有好幾種方式,我就推薦一種我認(rèn)為比較常用的方式
//?fetchData.test.js
//?測試promise成功需要加.resolves方法
test('the?data?is?peanut?butter',?async?()?=>?{
????await?expect(fethUserInfo()).resolves.toBe('peanut?butter');
});
//?測試promise成功需要加.rejects方法
test('the?fetch?fails?with?an?error',?async?()?=>?{
????await?expect(fethUserInfo()).rejects.toMatch('error');
});
復(fù)制代碼
作用域
jest提供一個describle函數(shù)來分離各個test測試用例,就是把相關(guān)的代碼放到一類分組中,這么簡單,看個例子就懂了。
//?分組一
describe('Test?xxFunction',?()?=>?{
??test('Test?default?return?zero',?()?=>?{
??????expect(xxFunction()).toBe(0)
??})
??//?...其它test
})
//?分組二
describe('Test?xxFunction2',?()?=>?{
??test('Pass?3?can?return?9',?()?=>?{
??????expect(xxFunction2(3)).toBe(9)
??})
??//?...其它test
})
復(fù)制代碼
鉤子函數(shù)
jest中有4個鉤子函數(shù)
beforeAll:所有測試之前執(zhí)行 afterAll:所有測試執(zhí)行完之后 beforeEach:每個測試實例之前執(zhí)行 afterEach:每個測試實例完成之后執(zhí)行
我們舉例來說明為什么需要他們。
在 index.js 中寫入一些待測試方法
export?default?class?compute?{
??constructor()?{
????this.number?=?0
??}
??addOne()?{
????this.number?+=?1
??}
??addTwo()?{
????this.number?+=?2
??}
??minusOne()?{
????this.number?-=?1
??}
??minusTwo()?{
????this.number?-=?2
??}
}
復(fù)制代碼
假如我們要 在 index.test.js 中寫測試實例
import?compute?from?'./index'
const?Compute?=?new?compute()
test('測試?addOne',?()?=>?{
??Compute.addOne()
??expect(Compute.number).toBe(1)
})
test('測試?minusOne',?()?=>?{
??Compute.minusOne()
??expect(Compute.number).toBe(0)
})
復(fù)制代碼
這里兩個測試實例相互之間影響了,共用了一個computet實例,我們可以將
const Compute = new compute()放在beforEach里面就可以解決了,每次測試實例之前先重新new compute同理,你想在每個test測試完畢后單獨運行什么可以放入到
afterEach中
我們接著看一下什么情況下使用beforeAll,假如我們測試數(shù)據(jù)庫數(shù)據(jù)是否保存正確
我們在測試最開始,也就是? beforeAll生命周期里,?新增1條數(shù)據(jù)到數(shù)據(jù)庫里測試完后,也就是? afterAll周期里,?刪除之前添加的數(shù)據(jù)最后利用全局作用域? afterAll?確認(rèn)數(shù)據(jù)庫是否還原成初始狀態(tài)
這里說到
//?模擬數(shù)據(jù)庫
const?userDB?=?[
??{?id:?1,?name:?'小明'?},
??{?id:?2,?name:?'小花'?},
]
//?新增數(shù)據(jù)
const?insertTestData?=?data?=>?{
??//?userDB,push數(shù)據(jù)
}
//?刪除數(shù)據(jù)
const?deleteTestData?=?id?=>?{
??//?userDB,delete數(shù)據(jù)
}
//?全部測試完
afterAll(()?=>?{
??console.log(userDB)
})
describe('Test?about?user?data',?()?=>?{
??beforeAll(()?=>?{
??????insertTestData({?id:?99,?name:?'CS'?})
??})
??afterAll(()?=>?{
??????deleteTestData(99)
??})
})
復(fù)制代碼
jest里的Mock
為什么要使用Mock函數(shù)?
在項目中,經(jīng)常會碰見A模塊掉B模塊的方法。并且,在單元測試中,我們可能并不需要關(guān)心內(nèi)部調(diào)用的方法的執(zhí)行過程和結(jié)果,只想知道它是否被正確調(diào)用即可,甚至?xí)付ㄔ摵瘮?shù)的返回值。此時,就需要mock函數(shù)了。
Mock函數(shù)提供的以下三種特性,在我們寫測試代碼時十分有用:
捕獲函數(shù)調(diào)用情況 設(shè)置函數(shù)返回值 改變函數(shù)的內(nèi)部實現(xiàn)
jest.fn()
jest.fn()是創(chuàng)建Mock函數(shù)最常用的方式。
test('測試jest.fn()',?()?=>?{
??let?mockFn?=?jest.fn();
??let?result?=?mockFn(1);
??//?斷言mockFn被調(diào)用
??expect(mockFn).toBeCalled();
??//?斷言mockFn被調(diào)用了一次
??expect(mockFn).toBeCalledTimes(1);
??//?斷言mockFn傳入的參數(shù)為1
??expect(mockFn).toHaveBeenCalledWith(1);
})
復(fù)制代碼
jest.fn()所創(chuàng)建的Mock函數(shù)還可以設(shè)置返回值,定義內(nèi)部實現(xiàn)或返回Promise對象。
test('測試jest.fn()返回固定值',?()?=>?{
??let?mockFn?=?jest.fn().mockReturnValue('default');
??//?斷言mockFn執(zhí)行后返回值為default
??expect(mockFn()).toBe('default');
})
test('測試jest.fn()內(nèi)部實現(xiàn)',?()?=>?{
??let?mockFn?=?jest.fn((num1,?num2)?=>?{
????return?num1?*?num2;
??})
??//?斷言mockFn執(zhí)行后返回100
??expect(mockFn(10,?10)).toBe(100);
})
test('測試jest.fn()返回Promise',?async?()?=>?{
??let?mockFn?=?jest.fn().mockResolvedValue('default');
??let?result?=?await?mockFn();
??//?斷言mockFn通過await關(guān)鍵字執(zhí)行后返回值為default
??expect(result).toBe('default');
??//?斷言mockFn調(diào)用后返回的是Promise對象
??expect(Object.prototype.toString.call(mockFn())).toBe("[object?Promise]");
})
復(fù)制代碼
2. jest.mock()
fetch.js文件夾中封裝的請求方法可能我們在其他模塊被調(diào)用的時候,并不需要進行實際的請求(請求方法已經(jīng)通過單測或需要該方法返回非真實數(shù)據(jù))。此時,使用jest.mock()去mock整個模塊是十分有必要的。
下面我們在src/fetch.js的同級目錄下創(chuàng)建一個src/events.js。
import?fetch?from?'./fetch';
export?default?{
??async?getPostList()?{
????return?fetch.fetchPostsList(data?=>?{
??????console.log('fetchPostsList?be?called!');
??????//?do?something
????});
??}
}
復(fù)制代碼
import?events?from?'../src/events';
import?fetch?from?'../src/fetch';
jest.mock('../src/fetch.js');
test('mock?整個?fetch.js模塊',?async?()?=>?{
??expect.assertions(2);
??await?events.getPostList();
??expect(fetch.fetchPostsList).toHaveBeenCalled();
??expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});
復(fù)制代碼
在測試代碼中我們使用了jest.mock('../src/fetch.js')去mock整個fetch.js模塊。如果注釋掉這行代碼,執(zhí)行測試腳本時會出現(xiàn)以下報錯信息
從這個報錯中,我們可以總結(jié)出一個重要的結(jié)論:
在jest中如果想捕獲函數(shù)的調(diào)用情況,則該函數(shù)必須被mock或者spy!
3. jest.spyOn()
jest.spyOn()方法同樣創(chuàng)建一個mock函數(shù),但是該mock函數(shù)不僅能夠捕獲函數(shù)的調(diào)用情況,還可以正常的執(zhí)行被spy的函數(shù)。實際上,jest.spyOn()是jest.fn()的語法糖,它創(chuàng)建了一個和被spy的函數(shù)具有相同內(nèi)部代碼的mock函數(shù)。
上圖是之前jest.mock()的示例代碼中的正確執(zhí)行結(jié)果的截圖,從shell腳本中可以看到console.log('fetchPostsList be called!');這行代碼并沒有在shell中被打印,這是因為通過jest.mock()后,模塊內(nèi)的方法是不會被jest所實際執(zhí)行的。這時我們就需要使用jest.spyOn()。
//?functions.test.js
import?events?from?'../src/events';
import?fetch?from?'../src/fetch';
test('使用jest.spyOn()監(jiān)控fetch.fetchPostsList被正常調(diào)用',?async()?=>?{
??expect.assertions(2);
??const?spyFn?=?jest.spyOn(fetch,?'fetchPostsList');
??await?events.getPostList();
??expect(spyFn).toHaveBeenCalled();
??expect(spyFn).toHaveBeenCalledTimes(1);
})
復(fù)制代碼
執(zhí)行npm run test后,可以看到shell中的打印信息,說明通過jest.spyOn(),fetchPostsList被正常的執(zhí)行了。
快照
快照就是對你對比的數(shù)據(jù)會存一份副本,啥意思呢,我們舉個例子:
這是index.js
export?const?data2?=?()?=>?{
??return?{
????name:?'zhangsan',
????age:?26,
????time:?new?Date()
??}
}
復(fù)制代碼
在 index.test.js 中寫入一些測試實例
import?{?data2?}?from?"./index"
it('測試快照?data2',?()?=>?{
??expect(data2()).toMatchSnapshot({
????name:?'zhangsan',
????age:?26,
????time:?expect.any(Date)?//用于聲明是個時間類型,否則時間會一直改變,快照不通過
??})
})
復(fù)制代碼
toMatchSnapshot會將參數(shù)將快照進行匹配expect.any(Date)用于匹配一個時間類型
執(zhí)行npm run test會生成一個__snapshots__文件夾,里面是生成的快照,當(dāng)你修改一下測試代碼時,會提示你,快照不匹配。如果你確定你需要修改,按 u 鍵,即可更新快照。這用于UI組件的測試非常有用。
React的BDD單測
接下來我們看下react代碼如何進行測試,用一個很小的例子來說明。
案例中引入了enzyme。_Enzyme_?來自 airbnb 公司,是一個用于 React 的 JavaScript 測試工具,方便你判斷、操縱和歷遍 React Components 輸出。
我們達(dá)成的目的是檢測:
用戶進入首頁,看到兩個按鈕,分別是counter1和counter2 點擊counter1,就能看到兩個按鈕的文字部分分別是"counter1"和"counter2"
react代碼如下
import?React?from?'react';
function?Counter(){
????return?(
????????<ul>
????????????<li>
????????????????<button?id='counter1'?className='button1'>counter1button>
????????????li>
????????????<li>
????????????????<button?id='counter2'?className='button2'>counter2button>
????????????li>
????????ul>
????)
}
復(fù)制代碼
單測的文件:
import?Counter?from?xx;
import?{?mount?}?from?'enzyme';
describle('測試APP',()?=>?{
????test('用戶進入首頁,看到兩個按鈕,分別是counter1和counter2,并且按鈕文字也是counter1和counter2',()=>{
????????const?wrapper?=?mount(<Counter?/>);
????????const?button?=?wrapper.find('button');
????????except(button).toHaveLength(2);
????????except(button.at(0).text()).toBe('counter1');
????????except(button.at(1).text()).toBe('counter2');
????})
})
復(fù)制代碼
Jest | 測試設(shè)置分類(describe)及作用域[1]
jest入門單元測試[2]
關(guān)于本文
作者:孟祥_成都
https://juejin.cn/post/7092188990471667749
