大家好,我是若川。最近組織了源碼共讀活動,感興趣的可以加我微信?ruochuan12?參與,每周大家一起學習200行左右的源碼,共同進步。已進行四個月了,很多小伙伴表示收獲頗豐。
想學源碼,極力推薦訂閱我寫的《學習源碼整體架構系列》?包含20余篇源碼文章。同時推薦參與源碼共讀活動。
前言
對于現在的前端工程,一個標準完整的項目,通常情況單元測試是非常必要的。但很多時候我們只是完成了項目而忽略了項目測試。我認為其中一個很大的原因是很多人對單元測試認知不夠,因此我寫了這邊文章,一方面期望通過這篇文章讓你對單元測試有一個初步認識。另一個方面希望通過代碼示例,讓你掌握寫單元測試實踐能力。
前端為什么需要單元測試?
必要性:JavaScript 缺少類型檢查,編譯期間無法定位到錯誤,單元測試可以幫助你測試多種異常情況。
正確性:測試可以驗證代碼的正確性,在上線前做到心里有底。
自動化:通過 console 雖然可以打印出內部信息,但是這是一次性的事情,下次測試還需要從頭來過,效率不能得到保證。通過編寫測試用例,可以做到一次編寫,多次運行。
保證重構:互聯網行業產品迭代速度很快,迭代后必然存在代碼重構的過程,那怎么才能保證重構后代碼的質量呢?有測試用例做后盾,就可以大膽的進行重構。
現狀
下面是一份抽樣調查片段,抽樣依據如下:
向 200 名相關者發出在線問卷調查,其中 70 人回答了問卷中的問題,前端人數占 81.16%,如果你有興趣的話,也可以幫我填一下調查問卷 (https://www.wjx.cn/vm/Ombu9q1.aspx)
數據收集日期:2021.09.21—2021.10.08
目標群體:所有開發人員
組織規模:不到 50 人,50 到 100人, 100人以上
你執行過 JavaScript 單元測試嗎?

調查中的另一個有趣的見解是,在大型組織中單元測試更受歡迎。其中一個原因可能是,由于大型組織需要處理大規模的產品,以及頻繁的功能迭代吧。這種持續的迭代方式,迫使他們進行自動化測試的投入。更具體地說,單元測試有助于增強產品的整體質量。

另外,報告顯示超 80% 人認為單元測試可以有效的提高質量,超 60% 人使用過 Jest 去編寫前端單元測試,超 40% 的人認為單元測試覆蓋率是重要的且覆蓋率應該大于 80%。
常見單元測試工具
目前用的最多的前端單元測試框架主要有 Mocha (https://mochajs.cn/)、Jest (https://www.jestjs.cn/),但我推薦你使用 Jest,因為 Jest 和 Mocha 相比,無論從 github starts & issues 量,npm下載量相比,都有明顯優勢。
github stars 以及 npm 下載量的實時數據,參見:jest vs mocha (https://www.npmtrends.com/jest-vs-mocha) 截圖日期為 2021.11.25
Github stars & issues

npm 下載量
Jest 的下載量較大,一部分原因是因為 create-react-app 腳手架默認內置了 Jest, 而大部分 react 項目都是用它生成的。

從 github starts & issues 以及 npm 下載量角度來看,Jest 的關注度更高,社區也更活躍
框架對比
框架 | 斷言 | 異步 | 代碼覆蓋率 |
---|---|---|---|
Mocha | 不支持(需要其他庫支持) | 友好 | 不支持(需要其他庫支持) |
Jest | 默認支持 | 友好 | 支持 |
Mocha 生態好,但是需要較多的配置來實現高擴展性
Jest 開箱即用
比如對 sum 函數寫用例
./sum.js
function?sum(a,?b)?{return?a?+?b;
}module.exports?=?sum;
Mocha + Chai 方式
Mocha 需要引入 chai 或則其他斷言庫去斷言, 如果你需要查看覆蓋率報告你還需要安裝 nyc 或者其他覆蓋率工具
./test/sum.test.js
const?{?expect,?assert?}?=?require('chai');
const?sum?=?require('../sum');describe('sum',?function()?{it('adds?1?+?2?to?equal?3',?()?=>?{assert(sum(1,?2)?===?3);});
});
Jest 方式
Jest 默認支持斷言,同時默認支持覆蓋率測試
./test/sum.test.js
const?sum?=?require('./sum');describe('sum?function?test',?()?=>?{it('sum(1,?2)?===?3',?()?=>?{expect(sum(1,?2)).toBe(3);});//?這里?test?和?it?沒有明顯區別,it?是指:?it?should?xxx,?test?是指?test?xxxtest('sum(1,?2)?===?3',?()?=>?{expect(sum(1,?2)).toBe(3);});
})
可見無論是受歡迎度和寫法上,Jest 都有很大的優勢,因此推薦你使用開箱即用的 Jest
如何開始?
1.安裝依賴
npm?install?--save-dev?jest
2.簡單的例子
首先,創建一個 sum.js 文件
./sum.js
function?sum(a,?b)?{return?a?+?b;
}module.exports?=?sum;
創建一個名為 sum.test.js 的文件,這個文件包含了實際測試內容:
./test/sum.test.js
const?sum?=?require('../sum');test('adds?1?+?2?to?equal?3',?()?=>?{expect(sum(1,?2)).toBe(3);
});
將下面的配置部分添加到你的 package.json 里面
{"scripts":?{"test":?"jest"},
}
運行 npm run test ,jest 將打印下面這個消息

3.不支持部分 ES6 語法
nodejs 采用的是 CommonJS 的模塊化規范,使用 require 引入模塊;而 import 是 ES6 的模塊化規范關鍵字。想要使用 import,必須引入 babel 轉義支持,通過 babel 進行編譯,使其變成 node 的模塊化代碼
如以下文件改寫成 ES6 寫法后,運行 npm run test將會報錯
./sum.js
export?function?sum(a,?b)?{return?a?+?b;
}
./test/sum.test.js
import?{?sum?}?from?'../sum';test('adds?1?+?2?to?equal?3',?()?=>?{expect(sum(1,?2)).toBe(3);
});
報錯

為了能使用這些新特性,我們就需要使用 babel 把 ES6 轉成 ES5 語法
解決辦法
安裝依賴
npm?install?--save-dev?@babel/core?@babel/preset-env
根目錄加入.babelrc
{???"presets":?["@babel/preset-env"]?}
再次運行 npm run test ,問題解決

原理
jest 運行時內部先執行( jest-babel ),檢測是否安裝 babel-core,然后取 .babelrc 中的配置運行測試之前結合 babel 先把測試用例代碼轉換一遍然后再進行測試
4.測試 ts 文件
jest 需要借助 .babelrc 去解析 TypeScript 文件再進行測試
安裝依賴
npm?install?--save-dev?@babel/preset-typescript
**改寫 **.babelrc
{???"presets":?["@babel/preset-env",?"@babel/preset-typescript"]?}
為了解決編輯器對 jest 斷言方法的類型報錯,如 test、expect 的報錯,你還需要安裝
npm?install?--save-dev?@types/jest
./get.ts
/***?訪問嵌套對象,避免代碼中出現類似?user?&&?user.personalInfo???user.personalInfo.name?:?null?的代碼*/
export?function?get<T>(object:?any,?path:?Array<number?|?string>,?defaultValue?:?T)?:?T?{const?result?=?path.reduce((obj,?key)?=>?obj?!==?undefined???obj[key]?:?undefined,?object);return?result?!==?undefined???result?:?defaultValue;
}
./test/get.test.ts
import?{?get?}?from?'./get';test('測試嵌套對象存在的可枚舉屬性?line1',?()?=>?{expect(get({id:?101,email:?'jack@dev.com',personalInfo:?{name:?'Jack',address:?{line1:?'westwish?st',line2:?'washmasher',city:?'wallas',state:?'WX'}}},?['personalInfo',?'address',?'line1'])).toBe('westwish?st');
});
運行 npm run test

5.持續監聽
為了提高效率,可以通過加啟動參數的方式讓 jest 持續監聽文件的修改,而不需要每次修改完再重新執行測試用例
改寫 package.json
"scripts":?{?????"test":?"jest?--watchAll"???},
效果

5.生成測試覆蓋率報告
什么是單元測試覆蓋率?
單元測試覆蓋率是一種軟件測試的度量指標,指在所有功能代碼中,完成了單元測試的代碼所占的比例。有很多自動化測試框架工具可以提供這一統計數據,其中最基礎的計算方式為:
單元測試覆蓋率?=?被測代碼行數?/?參測代碼總行數?*?100%
如何生成?
加入?jest.config.js
?文件
module.exports?=?{//?是否顯示覆蓋率報告collectCoverage:?true,//?告訴?jest?哪些文件需要經過單元測試測試collectCoverageFrom:?['get.ts',?'sum.ts',?'src/utils/**/*'],
}
再次運行效果

參數解讀
參數名 | 含義 | 說明 |
---|---|---|
% stmts | 語句覆蓋率 | 是不是每個語句都執行了? |
% Branch | 分支覆蓋率 | 是不是每個 if 代碼塊都執行了? |
% Funcs | 函數覆蓋率 | 是不是每個函數都調用了? |
% Lines | 行覆蓋率 | 是不是每一行都執行了? |
設置單元測試覆蓋率閥值
個人認為既然在項目中集成了單元測試,那么非常有必要關注單元測試的質量,而覆蓋率則一定程度上客觀的反映了單測的質量,同時我們還可以通過設置單元測試閥值的方式提示用戶是否達到了預期質量。
jest.config.js
?文件
module.exports?=?{collectCoverage:?true,?//?是否顯示覆蓋率報告collectCoverageFrom:?['get.ts',?'sum.ts',?'src/utils/**/*'],?//?告訴?jest?哪些文件需要經過單元測試測試coverageThreshold:?{global:?{statements:?90,?//?保證每個語句都執行了functions:?90,?//?保證每個函數都調用了branches:?90,?//?保證每個?if?等分支代碼都執行了},},
上述閥值要求我們的測試用例足夠充分,如果我們的用例沒有足夠充分,則下面的報錯將會幫助你去完善

6.如何編寫單元測試
下面我們以 fetchEnv 方法作為案例,編寫一套完整的單元測試用例供讀者參考
編寫 fetchEnv 方法
./src/utils/fetchEnv.ts
?文件
/***?環境參數枚舉*/enum?IEnvEnum?{DEV?=?'dev',?//?開發TEST?=?'test',?//?測試PRE?=?'pre',?//?預發PROD?=?'prod',?//?生產
}/***?根據鏈接獲取當前環境參數*?@param?{string?}?url?資源鏈接*?@returns?{IEnvEnum}?環境參數*/
export?function?fetchEnv(url:?string):?IEnvEnum?{const?envs?=?[IEnvEnum.DEV,?IEnvEnum.TEST,?IEnvEnum.PRE];return?envs.find((env)?=>?url.includes(env))?||?IEnvEnum.PROD;
}
編寫對應的單元測試
./test/fetchEnv.test.ts
?文件
import?{?fetchEnv?}?from?'../src/utils/fetchEnv';describe('fetchEnv',?()?=>?{it?('判斷是否?dev?環境',?()?=>?{expect(fetchEnv('https://www.imooc.dev.com/')).toBe('dev');});it?('判斷是否?test?環境',?()?=>?{expect(fetchEnv('https://www.imooc.test.com/')).toBe('test');});it?('判斷是否?pre?環境',?()?=>?{expect(fetchEnv('https://www.imooc.pre.com/')).toBe('pre');});it?('判斷是否?prod?環境',?()?=>?{expect(fetchEnv('https://www.imooc.prod.com/')).toBe('prod');});it?('判斷是否?prod?環境',?()?=>?{expect(fetchEnv('https://www.imooc.com/')).toBe('prod');});
});
執行結果

7.常用斷言方法
關于斷言方法有很多,這里僅摘出常用方法,如果你想了解更多,你可以去 Jest 官網 API (https://www.jestjs.cn/docs/expect)?部分查看
.not 修飾符允許你測試結果不等于某個值的情況
./test/sum.test.js
import?{?sum?}?from?'./sum';test('sum(2,?4)?不等于?5',?()?=>?{expect(sum(2,?4)).not.toBe(5);
})
.toEqual 匹配器會遞歸的檢查對象所有屬性和屬性值是否相等,常用來檢測引用類型
./src/utils/userInfo.js
export?const?getUserInfo?=?()?=>?{return?{name:?'moji',age:?24,}
}
./test/userInfo.test.js
import?{?getUserInfo?}??from?'../src/userInfo.js';test('getUserInfo()返回的對象深度相等',?()?=>?{expect(getUserInfo()).toEqual(getUserInfo());
})test('getUserInfo()返回的對象內存地址不同',?()?=>?{expect(getUserInfo()).not.toBe(getUserInfo());
})
.toHaveLength 可以很方便的用來測試字符串和數組類型的長度是否滿足預期
./src/utils/getIntArray.js
export?const?getIntArray?=?(num)?=>?{if?(!Number.isInteger(num))?{throw?Error('"getIntArray"只接受整數類型的參數');}return?[...new?Array(num).keys()];
};
./test/getIntArray.test.js
./test/getIntArray.test.js
import?{?getIntArray?}??from?'../src/utils/getIntArray';test('getIntArray(3)返回的數組長度應該為3',?()?=>?{expect(getIntArray(3)).toHaveLength(3);
})
.toThorw 能夠讓我們測試被測試方法是否按照預期拋出異常
但是需要注意的是:我們必須使用一個函數將被測試的函數做一個包裝,正如下面 getIntArrayWrapFn 所做的那樣,否則會因為函數拋出錯誤導致該斷言失敗。
./test/getIntArray.test.js
import?{?getIntArray?}??from?'../src/utils/getIntArray';test('getIntArray(3.3)應該拋出錯誤',?()?=>?{function?getIntArrayWrapFn()?{getIntArray(3.3);}expect(getIntArrayWrapFn).toThrow('"getIntArray"只接受整數類型的參數');
})
.toMatch 傳入一個正則表達式,它允許我們來進行字符串類型的正則匹配
./test/userInfo.test.js
import?{?getUserInfo?}??from?'../src/utils/userInfo.js';test("getUserInfo().name?應該包含'mo'",?()?=>?{expect(getUserInfo().name).toMatch(/mo/i);
})
測試異步函數
./servers/fetchUser.js
/**?*?獲取用戶信息
*/
export?const?fetchUser?=?()?=>?{return?new?Promise((resole)?=>?{setTimeout(()?=>?{resole({name:?'moji',age:?24,})},?2000)})
}
./test/fetchUser.test.js
import?{?fetchUser?}?from?'../src/fetchUser';test('fetchUser()?可以請求到一個用戶名字為?moji',?async?()?=>?{const?data?=??await?fetchUser();expect(data.name).toBe('moji')
})
這里你可能看到這樣一條報錯

這是因為?@babel/preset-env
?不支持 async await 導致的,這時候就需要對 babel 配置進行增強,可以安裝 @babel/plugin-transform-runtime
這個插件解決
npm?install?--save-dev?@babel/plugin-transform-runtime
同時改寫 .babelrc
{"presets":?["@babel/preset-env",?"@babel/preset-typescript"],"plugins":?["@babel/plugin-transform-runtime"]
}
再次運行就不會出現報錯了

.toContain 匹配對象中是否包含
./test/toContain.test.js
const?names?=?['liam',?'jim',?'bart'];test('匹配對象是否包含',?()?=>?{expect(names).toContain('jim');
})
檢查一些特殊的值(null,undefined 和 boolean)
toBeNull?僅匹配?null
toBeUndefined?僅匹配?undefined
toBeDefined?與…相反?toBeUndefined
toBeTruthy?匹配?if?語句視為?true?的任何內容
toBeFalsy?匹配?if?語句視為?false?的任何內容檢查數字類型(number)
toBeGreaterThan?大于
toBeGreaterThanOrEqual?至少(大于等于)
toBeLessThan?小于
toBeLessThanOrEqual?最多(小于等于)
toBeCloseTo?用來匹配浮點數(帶小數點的相等)
總結
以上就是文章全部內容,相信你閱讀完這篇文章后,已經掌握了前端單元測試的基本知識,甚至可以按照文章教學步驟,現在就可以在你的項目中接入單元測試。同時在閱讀過程中如果你有任何問題,或者有更好見解,更好的框架推薦,歡迎你在評論區留言!
也許在你閱讀這篇文章之前,你本身就已掌握前端單元測試技能了,甚至已經是這個領域的大牛了,那么首先我感到非常榮幸,同時也誠懇的邀請你在評論區提出寶貴意見,我在這里提前說聲謝謝!
最后感謝你在百忙之中抽出時間閱讀這篇文章,送人玫瑰,手有余香,如果你覺得文章對你有所幫助,希望可以幫我點個贊!
最近組建了一個江西人的前端交流群,如果你是江西人可以加我微信?ruochuan12?私信 江西?拉你進群。
推薦閱讀
整整4個月了,盡全力組織了源碼共讀活動~
我歷時3年才寫了10余篇源碼文章,但收獲了100w+閱讀
老姚淺談:怎么學JavaScript?
我在阿里招前端,該怎么幫你(可進面試群)
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》10余篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經寫了7篇,點擊查看年度總結。
同時,最近組織了源碼共讀活動,幫助1000+前端人學會看源碼。公眾號愿景:幫助5年內前端人走向前列。
識別上方二維碼加我微信、拉你進源碼共讀群
今日話題
略。分享、收藏、點贊、在看我的文章就是對我最大的支持~