前言
最近在做一個採用 TypeScript
語言編寫的項目,測試庫選擇了 Jest
。我跟着 Jest
文檔完成了入門教程後依然不知道從何開始,主要是有以下幾個問題:
- 測試的時機是什麼時候,即什麼時候運行
jest
; - 測試文件放在哪個目錄下比較好,業界是不是有比較成熟的規範;
- 測試配置文件中常用的有哪些配置以及
TypeScript
項目需要有哪些特殊的配置。
帶着這些問題,我前往 Github
上尋找了 5 個用 TypeScript
編寫並且使用 Jest
作爲測試框架的熱門項目進行研究,這 5 個項目分別是:
其中,我認爲 Vuetify
最有參考價值,所以本文會以 Vuetify
爲例詳細地分析它的測試入口和配置,其他的幾個庫如果有和 Vuetify
不同的地方也會指出來,從而有一個更具全局觀的認識。
也希望大家在閱讀本文之後,能夠組織讓自己滿意的測試代碼結構,也能夠看懂 Jest
大多數配置的含義。
另外,本文還涉及以下庫的一些內容,也希望能喚起大家的一些思考:
測試入口
首先需要找到自動化測試是從哪裏開始的,這裏的技巧一般是看項目根目錄下的 package.json
中有沒有相關的腳本。很幸運,在 Vuetify
項目根目錄下的 package.json
中,發現了這段代碼:
{
// ...
"husky": {
"hooks": {
"pre-commit": "node scripts/warn-npm-install.js && yarn run lint && yarn lerna run test -- -- -o"
}
}
// ...
}
注意 yarn lerna run test -- -- -o
這段命令,很顯然這就是我要找的測試入口的線索。這段命令的作用是運行項目的 packages
目錄下所有包的 test
腳本,並帶上 -o
參數。
如果你不瞭解
Yarn
和Lerna
這兩個工具,你也許會看不懂上述的命令,這裏做個簡單的解釋。如果你仔細地看過 Yarn 的文檔,你會發現
Yarn
是支持yarn <command> [--args]
的寫法的,這個寫法就是調用項目本地的命令行工具(即 node_modules/.bin/ 下的腳本)執行一段命令(command
)。上述命令lerna run test
就是command
,所以上述命令實際上就是調用項目本地的Lerna
工具來執行一行命令。細心的你也許會注意到上述命令行中存在兩個
--
,這又是什麼意思呢?我運行了上述命令後,會發現一個這樣的提示:warning From Yarn 1.0 onwards, scripts don't require "--" for options to be forwarded. In a future version, any explicit "--" will be forwarded as-is to the scripts.
這行提示是說命令行中存在一個過時的
--
參數語法,說明有一個--
是Yarn
的老語法,新版本不用傳了,但後面我們會講到在這種場景下不得不傳。如此一來,剩下的
--
和-o
便是Lerna
命令的參數了,所以上述命令實際上是調用項目本地的Lerna
執行lerna run test -- -o
。從
Learn
的文檔可知,lerna run test -- -o
的作用是執行項目的packages
目錄下所有包中含有test
腳本的命令(不含有的包會自動跳過),而--
符號可以將後面的參數傳遞給test
腳本,和Yarn
的--
語法一致。這裏可以回答之前的一個問題了,即爲什麼必須寫
Yarn
的--
參數?我們可以想一想,如果只留一個--
,那麼這個--
還是會被Yarn
識別,最終導致實際運行的命令行是lerna run test -o
,這將會報錯,所以兩個--
都得保留。還有一個思考題,就是不知道大家有沒有想過這裏爲什麼不直接寫
lerna run test -- -o
而要寫成yarn lerna run test -- -- -o
。我一開始也進入了這個直覺陷阱,認爲一般這樣寫不也是調用本地的Lerna
工具嗎?後來才反應過來,那只是npm scripts
的特性,只在scripts
裏面那麼寫,而這裏是husky
的配置,不能直接支持調用本地的命令行工具,需要藉助Yarn
的這個特性。於是我又去翻了翻husky
的文檔,發現裏面提到如果命令要支持調用本地命令行工具執行,還需要配置~/.huskyrc
文件,這還不如使用Yarn
的特性來得方便。
接下來便是尋找哪些包裏面有 test
腳本,幸運地是,只有 packages/vuetify
這個包包含 test
腳本,腳本對應的命令如下:
"test": "node build/run-tests.js"
test
腳本實際執行了 build/run-tests.js
文件,於是我看了下 build
目錄下的 run-tests.js
,發現它針對不同系統運行了不同的測試腳本,Windows
下運行 yarn test:win32 -o
,其他系統運行 yarn test:unix -o
。(提示:這裏的 -o
就是之前入口處帶的 -o
參數)
通過 package.json
文件可以知道 yarn test:win32 -o
實際上運行的是 jest -i -o
,yarn test:unix -o
實際上運行的是 jest -o
(提示:當運行 yarn <script> [...args]
的時候,運行腳本對應的命令時也會加上 args
裏的那些參數)。
那麼,帶有 -i
和 沒帶有 -i
參數有什麼區別呢?通過 Jest
文檔可知,-i
是 --runInBand
的短命名方式,-i
代表所有測試會串行地在當前進程中執行,這就能夠逐步調試了,而 Jest
默認是將測試通過創建子進程的方式運行,無法調試。想必 Windows
不通過串行方式在當前進程中執行,會遇到一些問題吧,具體是什麼問題還需要大家親身測試下告訴我哈。
而 -o
參數則代表僅對更改的文件進行測試,配合 Git
使用就很節約時間了。
至此,忽略無關緊要的 -i
參數,我們知道了 Vuetify
項目的測試入口實際就是在 packages/vuetify
下運行 jest -o
。
與 Vuetify
不同的是,其他項目很少將測試時機放在 Git
鉤子上觸發,而是手動執行一個 npm script
觸發,所以不需要依賴 Yarn
的運行本地命令的功能。另外,其他項目也都不和 Vuetify
一樣是個多個 Packages
在一起的項目,所以也不需要用到 Lerna
。
測試配置
Jest
的運行離不開它的配置,Jest
默認的配置文件是 jest.config.js
,或者在 package.json
中直接寫配置也行,大多數項目都是採用前一種方式,mobx
則是採用後面這種方式,這個看個人喜好吧,我更喜歡前面的方式,不至於讓 package.json
變得很長。另外,ant-design
是通過 --config
參數來指定自己的配置文件,這是因爲 ant-design
需要有不同的測試環境來進行測試,如果你的項目也有這個需求,就可以自定義配置文件了。
以下是 Vuetify
項目中 packages/vuetify
的配置(提示:已經將配置中依賴的其他配置展開),基本上搞清楚 Vuetify
的配置,你就明白大多數配置有哪些,並且都是什麼意思了:
{
// 多於一個測試文件運行時不展示每個測試用例測試通過情況
verbose: false,
// 測試用例運行在一個類似於瀏覽器的環境裏,可以調用瀏覽器的 API
testEnvironment: 'jest-environment-jsdom-fourteen',
// 以 <rootDir>/src 這個目錄做爲根目錄來搜索測試文件(模塊)
roots: [
'<rootDir>/src',
],
// 在測試環境準備好之後且每個測試文件執行之前運行下述文件
setupFilesAfterEnv: [
'<rootDir>/test/index.ts',
],
// 測試文件模塊之間的引用應該是自己實現了一套類似於 Node 的引用機制
// 不過自己可以配置,下面 module 開頭的都是配置這個的,都用例子來說明
// 例如,require('./a') 語句會先找 `a.ts`,找不到找 `a.js`
moduleFileExtensions: [
'ts',
'js',
],
// 例如,require('a') 語句會遞歸往上層的 node_modules 中尋找 a 模塊
moduleDirectories: [
'node_modules',
],
// 例如,require('@/a.js') 會解析成 require('<rootDir>/src/a.js')
moduleNameMapper: {
'^@/test$': '<rootDir>/test/index.js',
'^@/test/(.*)$': '<rootDir>/test/$1',
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|sass|scss)$': 'identity-obj-proxy',
},
// 轉譯下列模塊爲 Jest 能識別的代碼
transform: {
'\\.(styl)$': 'jest-css-modules',
'\\.(sass|scss)$': 'jest-css-modules',
'.*\\.(j|t)s$': 'ts-jest',
},
// 收集這些文件的測試覆蓋率
collectCoverageFrom: [
'src/**/*.{js,ts,tsx}',
'!**/*.d.ts',
],
// 排除 node_modules/vue-router 包以外的都被忽略
// 說明 vue-router 這個還是要被 `ts-jest` 轉譯
transformIgnorePatterns: [
'node_modules/(?!vue-router)',
],
snapshotSerializers: [
'jest-serializer-html',
],
// 從下列文件中尋找測試文件
testMatch: [
// Default
'**/test/**/*.js',
'**/__tests__/**/*.spec.js',
'**/__tests__/**/*.spec.ts',
],
// 將 `ts-jest` 的配置注入到運行時的全局變量中
globals: {
'ts-jest': {
// 是否使用 babel 配置來轉譯
babelConfig: true,
// 編譯 Typescript 所依賴的配置
tsConfig: '<rootDir>/tsconfig.test.json',
// 是否啓用報告診斷,這裏是不啓用
diagnostics: false,
},
},
// Jest 文檔中無此配置,應該已經過時了
name: 'Vuetify',
// 測試文件名旁邊顯示的標識
displayName: 'Vuetify',
// 在測試環境準備好之前且每個測試文件執行之前運行下述模塊
setupFiles: [
'jest-canvas-mock'
]
}
在進入每一項配置的解讀之前,我們對項目中的文件分成兩類,一類是測試文件,一類是非測試文件。所謂測試文件就是指該文件內寫的是測試代碼,非測試文件與之相反,他們之間的關係是非測試文件中的功能可能會被測試文件引用並測試。
下面進入每一項配置屬性的詳細解讀,讓大家對 Jest
運行時遵循的一些規則有所瞭解,順序按筆者認爲的重要程度排列:
rootDir
字符串類型,這個就是設置 Jest
配置中 <rootDir>
模版字符串的值。默認就是 Jest
配置所在的目錄,如果你的配置寫在 package.json
文件中,那就是 package.json
文件所在的目錄,如果都沒有,則是你運行 jest
命令所在的目錄。由於配置中經常用到,所以明白其含義是非常重要的。
roots
這個配置就是定義 Jest
從哪些目錄裏面去搜索測試文件。默認值就是 <rootDir>
。
testMatch
很重要的屬性,不知道爲什麼官網不最先說,我連 Jest
怎麼搜索到測試文件來跑的都不知道,那我怎麼知道該在哪寫測試文件呢?
如果說 roots
屬性規定了 Jest
搜索測試文件的範圍,那麼 testMatch
屬性就能讓 Jest
在這個範圍內精準地規定哪些文件是測試文件。如果 testMatch
定義的搜索範圍超出了 roots
定義的範圍,這些超出範圍之外的測試文件是不會執行的。
默認情況下,Jest 搜索測試文件的原則如下:
- 以
jest.config.js
配置文件或package.json
所在位置(一般也是Jest
命令運行的目錄)爲根目錄; - 搜索根目錄下所有
__test__
文件夾下的js
、.jsx
、ts
、.tsx
的文件,作爲測試文件放到測試環境中運行; - 搜索根目錄下所有後綴爲
.test.[js|ts|jsx|tsx]
或.spec.[js|ts|jsx|tsx]
的文件; - 搜索根目錄下所有名爲
test.[js|ts|jsx|tsx]
或spec.[js|ts|jsx|tsx]
的文件。
自定義後,搜索的文件就從根目錄按自定義的路徑和文件類型來搜尋測試文件。
瞭解這個屬性後,就知道測試文件寫在哪裏了,比如有些開源項目不配置這個屬性,直接在需要測試的源代碼文件所在的目錄中建立 __test__
文件夾,在其中寫 js
或 ts
文件,後面的系列中我們會看到。
同時,與此類似的屬性是 testRegex
,除 Vuetify
之外的其他四個項目基本都是使用 testRegex
,可以自行前往 Jest 中文文檔研究。
testEnvironment
字符串類型,表示測試用例運行的環境,內置環境可以有兩個選擇:
- jsdom。默認值,一個類似於瀏覽器的環境。
- node。一個類似於 Node 的環境。
另外,還可以通過自己安裝環境包來設置環境:
-
<package-name>
。使用 NPM 包中的某個環境。例如,第一個jsdom
選項其實是Jest
默認帶的jsdom@11
,如果想用更高版本的jsdom
,就得自己安裝了。Vuetify
使用的就是自己安裝的高版本jsdom
環境,即jest-environment-jsdom-fourteen
。
文檔上還提到,如果要讓某個文件下的測試用例走單獨的環境,可以在文件頂部加 @jest-environment <env>
註釋來實現。
另外,Jest
還支持自定義測試環境,這部分可以自行查看官方文檔進行了解,後續有機會我也會新開一篇文章進行講解。
至於 Vuetify
爲什麼要用高版本 jsdom
,我的想法是越高的版本實現的瀏覽器的 API 越多,正好 Vuetify
要用到或者將來要用到更高級的 API
,所以就用了高版本的 jsdom
了。
transform
這個屬性是設置哪些文件中的代碼是需要被相應的轉譯器轉換成 Jest
能識別的代碼,Jest
默認是能識別 JS
代碼的,其他語言,例如 Typescript
、CSS
等都需要被轉譯。例如 Vuetify
中就用到了 ts-jest
來轉譯 Typescript
,用到了 jest-css-modules
來轉譯樣式模塊。這有點像 Webpack
的 loader
。
transformIgnorePatterns
這個屬性是設置哪些文件不需要轉譯的。默認是 node_modules
中的模塊不需要轉譯,當然如果 node_modules
中有些模塊仍需要被轉譯,你可以像 Vuetify
一樣設置該屬性。
moduleFileExtensions
接下來的三個屬性 moduleFileExtensions
、moduleDirectories
、moduleNameMapper
這些以 module
開頭的屬性都是設置模塊引用代碼的解析規則。這些規則既作用於測試文件也作用於非測試文件,默認情況下這些規則和 Node 的模塊解析規則有點像,但是請記住測試時代碼並不是運行在 Node 中,而是 testEnvironment
屬性定義的測試環境中,因此你可以更靈活的定義模塊解析的規則。
moduleFileExtensions
屬性就規定了模塊檢索的文件類型,例如你寫了 require('./a)
,然後 moduleFileExtensions 配的是 ['ts', 'js'],那就先找 ./a.ts
,找不到就找 ./a.js
。所以你的項目用什麼語言寫的,就應該把該語言的後綴名放在最前面,這樣檢索效率也會更高。值得注意的是,這個屬性配置中必須包含 js
配置。
moduleDirectories
這個屬性對直接引用模塊名而不是路徑的方式定義了模塊搜索路徑,例如你在代碼裏面寫了 require(<module-name>)
這樣一句代碼,默認情況下就會和 Node
一樣遞歸搜索 node_modules
目錄中是否含有這個名字的模塊。如果你配置了其他目錄名,就會遞歸搜索那個文件夾下面的模塊了。
moduleNameMapper
這個屬性是對模塊路徑映射,鍵是正則表達式,值是模塊地址,可以用 $1
之類的符號來表示路徑中匹配的字符串。值得注意的是,值必須是完整的模塊路徑,和 Webpack
中的 alias
有所不同,比如以 Webpack
的思維,我想用 @
代替項目根目錄的 src
,那麼直接寫 '@': 'src'
就行了,而這裏就不能寫成類似的 @: '<rootDir>/src'
了,得寫成 '^@/(.*)$': '<rootDir>/src/$1'
,$1
解析出來後就是個完整的模塊路徑。總的來說,Webpack
做的更像是對路徑上的部分字符串做個替換,而 Jest
則是匹配路徑後,將整個路徑用對應的值給完全替換掉,這個思維的轉變很重要。
但是,對於 Typescript
項目來說,可能光配置 moduleNameMapper
屬性還是無法解析別名路徑的,如果項目和 Vuetify
一樣,用了 ts-jest
來解析 Typescript
,那麼還得配置以下內容才能生效:
globals: {
'ts-jest': {
diagnostics: false
}
}
這裏的原因是,如果開啓了 diagnostics
,就會在編譯前執行診斷,診斷時會報模塊無法找到的錯誤,從而終止編譯。
globals
這個屬性是設置測試環境中的全局變量的,如果你在 globals
中新增了一個 name: 'vabaly'
的配置,那麼你將在代碼(包含測試文件和非測試文件)中無需聲明而直接使用 name
或者 global.name
來獲得 vabaly
這個值。
setupFiles
這個屬性是定義在每個測試文件運行之前,且在測試環境準備好前就會立即執行的文件或模塊。
setupFilesAfterEnv
這個屬性是定義在每個測試文件運行之前,且在測試環境準備好後就會立即執行的文件或模塊。
無論是 setupFiles
還是 setupFilesAfterEnv
,都會在測試文件運行第一行代碼前執行。
collectCoverageFrom
這個屬性是收集符合 Glob 模式匹配的文件的測試覆蓋率,這個和 collectCoverage
屬性是相關聯的,只有 collectCoverage
設爲 true
,或者命令行中帶有 --coverage
參數,這個配置纔會生效。
值得注意的是,當只設置 collectCoverage
爲 true
時,那麼就只會收集測試過程中用到的非測試文件的測試覆蓋率。如果又設置了 collectCoverageFrom
,則只會收集 collectCoverageFrom
指定的非測試文件的測試覆蓋率,而且不管該文件有沒有在測試中被用到,都會收集,只不過該文件的測試覆蓋率是零罷了。如果你想知道你的代碼有哪些還沒被覆蓋到,設置這個屬性是十分有必要的。
verbose
布爾值,默認值是 false
。設置是否在運行期間展示每個單獨測試用例的測試情況(PASS or Fail)。不過無論設置什麼,都會在尾部顯示測試錯誤信息以及總體通過情況,可看下面兩張圖體會:
- verbose 設爲 false 的時候:
- verbose 設爲 true 的時候:
值得注意的是,當 verbose
設爲 false
,且只有一個測試文件運行時,還是會顯示出每個單獨測試用例的測試情況,多個測試文件運行時,就不會顯示出來了。只有 verbose
爲 true
時纔會顯示出來。
displayName
字符串類型,定義在每個測試文件名旁邊高亮顯示的名字,方便區分不同包的測試,比如設置 displayName: 'TEST'
之後,終端運行測試時就會顯示成下圖這樣:
總結
至此爲止,開頭的幾個問題都有了答案:
- 測試腳本的運行時機基本都是手動用
npm script
執行,也可以放到Git
鉤子上,保證每次提交都測試通過,不過要注意這兩者細微的使用差別; - 測試目錄和測試文件名字基本都是自己通過配置文件中的屬性配的,但基本都是名字中帶有
.spec
,並放在test
目錄下; - 測試常用配置就是上面那些,基本都搞清楚了意思。
在弄清楚 Vuetify
的測試命令和測試配置之後,接下來就到了激動人心的測試代碼欣賞時間,不過由於本章篇幅過長以及作者精力問題,我們下回再講!
宣傳
另外,也歡迎大家關注筆者的公衆號,獲取最新文章的推送: