探索 TypeScript + Jest 開源項目的自動化測試(上)

前言

最近在做一個採用 TypeScript 語言編寫的項目,測試庫選擇了 Jest。我跟着 Jest 文檔完成了入門教程後依然不知道從何開始,主要是有以下幾個問題:

  1. 測試的時機是什麼時候,即什麼時候運行 jest
  2. 測試文件放在哪個目錄下比較好,業界是不是有比較成熟的規範;
  3. 測試配置文件中常用的有哪些配置以及 TypeScript 項目需要有哪些特殊的配置。

帶着這些問題,我前往 Github 上尋找了 5 個用 TypeScript 編寫並且使用 Jest 作爲測試框架的熱門項目進行研究,這 5 個項目分別是:

  1. ant-design
  2. mobx
  3. oni
  4. prettier
  5. vuetify

其中,我認爲 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 參數。

如果你不瞭解 YarnLerna 這兩個工具,你也許會看不懂上述的命令,這裏做個簡單的解釋。

如果你仔細地看過 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 -oyarn 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.jsxts.tsx 的文件,作爲測試文件放到測試環境中運行;
  • 搜索根目錄下所有後綴爲 .test.[js|ts|jsx|tsx].spec.[js|ts|jsx|tsx] 的文件;
  • 搜索根目錄下所有名爲 test.[js|ts|jsx|tsx]spec.[js|ts|jsx|tsx] 的文件。

自定義後,搜索的文件就從根目錄按自定義的路徑和文件類型來搜尋測試文件。

瞭解這個屬性後,就知道測試文件寫在哪裏了,比如有些開源項目不配置這個屬性,直接在需要測試的源代碼文件所在的目錄中建立 __test__ 文件夾,在其中寫 jsts 文件,後面的系列中我們會看到。

同時,與此類似的屬性是 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 代碼的,其他語言,例如 TypescriptCSS 等都需要被轉譯。例如 Vuetify 中就用到了 ts-jest 來轉譯 Typescript,用到了 jest-css-modules 來轉譯樣式模塊。這有點像 Webpackloader

transformIgnorePatterns

這個屬性是設置哪些文件不需要轉譯的。默認是 node_modules 中的模塊不需要轉譯,當然如果 node_modules 中有些模塊仍需要被轉譯,你可以像 Vuetify 一樣設置該屬性。

moduleFileExtensions

接下來的三個屬性 moduleFileExtensionsmoduleDirectoriesmoduleNameMapper 這些以 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 參數,這個配置纔會生效。

值得注意的是,當只設置 collectCoveragetrue 時,那麼就只會收集測試過程中用到的非測試文件的測試覆蓋率。如果又設置了 collectCoverageFrom,則只會收集 collectCoverageFrom 指定的非測試文件的測試覆蓋率,而且不管該文件有沒有在測試中被用到,都會收集,只不過該文件的測試覆蓋率是零罷了。如果你想知道你的代碼有哪些還沒被覆蓋到,設置這個屬性是十分有必要的。

verbose

布爾值,默認值是 false。設置是否在運行期間展示每個單獨測試用例的測試情況(PASS or Fail)。不過無論設置什麼,都會在尾部顯示測試錯誤信息以及總體通過情況,可看下面兩張圖體會:

  1. verbose 設爲 false 的時候:
    verbose-false
  2. verbose 設爲 true 的時候:
    verbose

值得注意的是,當 verbose 設爲 false,且只有一個測試文件運行時,還是會顯示出每個單獨測試用例的測試情況,多個測試文件運行時,就不會顯示出來了。只有 verbosetrue 時纔會顯示出來。

displayName

字符串類型,定義在每個測試文件名旁邊高亮顯示的名字,方便區分不同包的測試,比如設置 displayName: 'TEST' 之後,終端運行測試時就會顯示成下圖這樣:

顯示文字

總結

至此爲止,開頭的幾個問題都有了答案:

  1. 測試腳本的運行時機基本都是手動用 npm script 執行,也可以放到 Git 鉤子上,保證每次提交都測試通過,不過要注意這兩者細微的使用差別;
  2. 測試目錄和測試文件名字基本都是自己通過配置文件中的屬性配的,但基本都是名字中帶有 .spec,並放在 test 目錄下;
  3. 測試常用配置就是上面那些,基本都搞清楚了意思。

在弄清楚 Vuetify 的測試命令和測試配置之後,接下來就到了激動人心的測試代碼欣賞時間,不過由於本章篇幅過長以及作者精力問題,我們下回再講!

宣傳

另外,也歡迎大家關注筆者的公衆號,獲取最新文章的推送:

微信公衆號二維碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章