
前言
上篇文章 里,我們給自己的 screeps 項(xiàng)目引入了 typescript,這讓我們的代碼可靠性獲得了質(zhì)的飛躍。那么為什么還要引入自動(dòng)測(cè)試呢?問得好,我們先來想象一下下面的場(chǎng)景:
你花了好幾天完成了一個(gè)重要模塊,為了保證其可靠性,你進(jìn)行了詳細(xì)而又認(rèn)真的測(cè)試,測(cè)試完成后,你的模塊像一個(gè)精密的機(jī)械一樣,每行代碼都明確而可靠的運(yùn)行著,你獲得了很大的成就感。
這個(gè)模塊穩(wěn)定的運(yùn)行了好久,直到有一天,你發(fā)現(xiàn)需要往這個(gè)模塊里添加一些代碼,添加完成后模塊依舊正常運(yùn)行,所以你沒有在意,繼續(xù)去開發(fā)其他代碼了。突然有一天,代碼突然報(bào)了個(gè)錯(cuò),之后又恢復(fù)正常,你根據(jù)錯(cuò)誤信息找到了對(duì)應(yīng)的代碼,檢查了一遍之后發(fā)現(xiàn),不對(duì)啊這段代碼沒改過不可能有問題啊。但是災(zāi)難就此降臨,之后代碼偶爾就會(huì)報(bào)一個(gè)錯(cuò),由于沒辦法斷點(diǎn)調(diào)試,你開始往線上的代碼里插一堆 log,但是依舊分析不出問題究竟是什么,你也曾花大功夫在私服里進(jìn)行詳細(xì)測(cè)試,但是問題依舊復(fù)現(xiàn)不出來。
你開始筋疲力盡胸口發(fā)堵,就像是一拳打在了棉花上。之前引以為傲的精密代碼現(xiàn)在就像是屎山一樣堆在那里,里邊到處插滿了 console.log 和調(diào)試代碼,像是一場(chǎng)進(jìn)行不下去的手術(shù)。
是不是已經(jīng)開始難受了,沒錯(cuò),這個(gè)問題同樣困擾著這個(gè)世界上的頂級(jí)開發(fā)者們,他們維護(hù)著比我們的 screeps 復(fù)雜的多的巨型項(xiàng)目。直到有一天,有人想到,如果我能把之前手工做的測(cè)試通過代碼的形式固化下來,以后修改代碼之后直接全部執(zhí)行一遍,不就既省時(shí)又能讓代碼更可靠么,于是,自動(dòng)化測(cè)試誕生了,這也就是我們今天要講的內(nèi)容。
簡(jiǎn)單介紹自動(dòng)化測(cè)試
網(wǎng)上關(guān)于自動(dòng)化測(cè)試的文章有很多,這里就簡(jiǎn)單介紹一下 Screeps 相關(guān)的內(nèi)容。
是騾子是馬拉出來溜溜,測(cè)試的本質(zhì)就是這個(gè)。如果說 typescript 是靜態(tài)檢查,那么我們就可以把測(cè)試稱為動(dòng)態(tài)檢查。通過真實(shí)的運(yùn)行這段代碼并檢查其結(jié)果是否符合預(yù)期,由此來證明這段代碼是否可用。這就是進(jìn)行測(cè)試的目的所在。
由于測(cè)試的內(nèi)容很多,所以我們會(huì)把不同的內(nèi)容分開寫,而每一段測(cè)試內(nèi)容我們就稱為一個(gè) 測(cè)試用例。并且因?yàn)楹芏嗾鎸?shí)環(huán)境里用到的依賴我們?cè)跍y(cè)試環(huán)境里并沒有,所以需要在測(cè)試之前“偽造”他們,讓被測(cè)試的代碼認(rèn)為自己所處的環(huán)境就是真實(shí)環(huán)境。這個(gè)過程我們一般稱為 mock。
當(dāng)前業(yè)內(nèi)已經(jīng)出現(xiàn)了很多成熟的測(cè)試框架,而我們教程里使用的就是最近發(fā)展迅速的 jest。jest 由 facebook 維護(hù),以零配置著稱,更多介紹詳見 jest 官方網(wǎng)站。

jest 還是 mocha?
實(shí)際上,當(dāng)前的 screeps 社區(qū)幾乎絕大多數(shù)項(xiàng)目使用的都是另一個(gè)老牌測(cè)試框架 mocha,包括我之前也在使用 mocha。那么為什么本文會(huì)介紹 jest 呢?
主要原因是 jest 所需的配置更少,適合新手入門。mocha 由于其靈活性,很多需要用到的工具都需要自行安裝,而 jest 已經(jīng)內(nèi)建了足夠好用的相應(yīng)工具。并且這兩者的代碼風(fēng)格都非常類似,你可以輕易的復(fù)用寫好的測(cè)試用例。網(wǎng)上也有很多這兩個(gè)框架的對(duì)比,這里不再贅述。
如果你想使用 mocha,沒有關(guān)系,直接百度 mocha ts 即可,或者參考我之前寫的 typescript 使用 mocha 進(jìn)行單元測(cè)試。下文中除了涉及到 jest 的配置和用例寫法外,其他大部分都可以應(yīng)用在引入了 mocha 的項(xiàng)目里。
在本系列教程里我們會(huì)著重介紹兩個(gè)測(cè)試方式,分別是 單元測(cè)試 和 集成測(cè)試。單元測(cè)試是小,檢查每個(gè)函數(shù)每個(gè)功能是否正常,本文內(nèi)容就是介紹如何使用單元測(cè)試。集成測(cè)試是大,通過運(yùn)行整個(gè)腳本并記錄運(yùn)行情況來檢查 bot 的整體可用性,將在下篇文章中介紹。
不過在深入介紹之前,我們按照慣例先來了解一下引入自動(dòng)測(cè)試的優(yōu)缺點(diǎn),請(qǐng)根據(jù)自己的項(xiàng)目情況認(rèn)真思考自己是否需要用到它。
單元測(cè)試優(yōu)缺點(diǎn)對(duì)比
優(yōu)點(diǎn)
記錄模塊用法:每個(gè)測(cè)試用例都是被測(cè)試代碼的使用例子。并且這段代碼還可以執(zhí)行,通過查閱測(cè)試用例,你可以很輕易的了解到這個(gè)模塊應(yīng)該如何調(diào)用。
更好的代碼質(zhì)量:想要進(jìn)行單元測(cè)試,就需要你的模塊解耦做的足夠好,不然測(cè)試起來會(huì)非常復(fù)雜。所以引入測(cè)試會(huì)迫使你過度耦合的模塊進(jìn)行解耦,將職責(zé)不唯一的函數(shù)進(jìn)行拆分,規(guī)范代碼中的副作用讓業(yè)務(wù)更清晰。
通過對(duì)老項(xiàng)目進(jìn)行大規(guī)模重構(gòu),你的代碼質(zhì)量將更上一層樓。防止 bug 回歸:由于修復(fù)新 bug 導(dǎo)致原來的 bug 復(fù)現(xiàn)了,我們通常將其稱為 回歸,并由此誕生了回歸測(cè)試。而一旦測(cè)試用例寫好了,那在之后的測(cè)試中它都會(huì)被執(zhí)行,如果有 bug 回歸了,那測(cè)試用例就必然會(huì)失敗,由此我們可以非??焖俚陌l(fā)現(xiàn)回歸問題。
方便測(cè)試極端場(chǎng)景:在游戲里有很多極端場(chǎng)景是很難復(fù)現(xiàn)的,例如一個(gè) creep 會(huì)在特定地形、特地房間、有特定建筑、自己在執(zhí)行特定任務(wù)時(shí)才會(huì)出現(xiàn)問題。而在測(cè)試用例里,代碼的執(zhí)行環(huán)境完全是我們創(chuàng)建出來的,所以我們可以輕易的模擬出一個(gè)穩(wěn)定的極端場(chǎng)景。
支持?jǐn)帱c(diǎn)調(diào)試:沒錯(cuò),測(cè)試的終極,由于我們的測(cè)試是在本地而不是游戲服務(wù)器上進(jìn)行的,所以我們終于可以逐行的執(zhí)行代碼并查看其運(yùn)行情況。斷點(diǎn)調(diào)試對(duì)于測(cè)試的重要性想必不須我多言。
缺點(diǎn)
增加開發(fā)工作:俗話說,百行代碼,千行測(cè)試。想要得到一個(gè)完整測(cè)試的模塊,你需要寫非常多的測(cè)試用例,從正常輸入到異常輸入,從大數(shù)據(jù)量壓測(cè)到極端場(chǎng)景測(cè)試,這些測(cè)試代碼都需要你來完成。
需要 mock 工具:還記得我們?cè)谟螒蛑惺褂玫?Creep、Room、Game、Memory 這些習(xí)以為常的變量么,這些在測(cè)試環(huán)境里都沒有,你需要手動(dòng) mock 他們,這對(duì)于你的編碼功底和對(duì)游戲的了解程度是一個(gè)不小的考驗(yàn)。不過下文我們會(huì)介紹如何進(jìn)行 mock。
mock 的不真實(shí)性:測(cè)試環(huán)境就算我們模擬的再真實(shí),它也不是真正的運(yùn)行環(huán)境。有些問題是因?yàn)?mock 偽造的不夠像導(dǎo)致的,并不能說明你的代碼真的有問題。
問題永遠(yuǎn)出現(xiàn)在你想不到的地方:你寫了很多的測(cè)試用例,那也只能說明針對(duì)這些使用場(chǎng)景,你的代碼不會(huì)出現(xiàn)問題。就算引入了自動(dòng)測(cè)試,也并不代表著你的代碼就一定是絕對(duì)穩(wěn)定的。
當(dāng)然,如果你是抱著“可以不用,不能沒有”的想法來的,那么直接開始即可。引入自動(dòng)測(cè)試不會(huì)帶來任何改變,甚至你不需要對(duì)項(xiàng)目進(jìn)行任何改造,測(cè)試用例完全獨(dú)立于原先的游戲代碼,哪怕你一個(gè)測(cè)試用例都不寫也不會(huì)影響什么。
jest 安裝與配置
廢話說了這么多,終于可以開始寫碼了。本項(xiàng)目基于 Screeps 使用 TypeScript 進(jìn)行靜態(tài)類型檢查 文中搭建的項(xiàng)目繼續(xù)完善,請(qǐng)確保你至少讀過這篇文章。
首先我們?cè)陧?xiàng)目中執(zhí)行如下命令來安裝依賴:
npm install --save-dev jest ts-jest @types/jest @screeps/common
安裝完成后在根目錄下新增 jest.config.js 并填入如下內(nèi)容:
const { pathsToModuleNameMapper } = require('ts-jest/utils')
const { compilerOptions } = require('./tsconfig')
module.exports = {
preset: 'ts-jest',
roots: ['<rootDir>'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
},
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { prefix: '<rootDir>/' }),
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
}
配置好了 jest 我們?cè)偃?package.json 里新增測(cè)試命令,你的 scripts 里可能已經(jīng)有了一個(gè) test 命令,直接刪掉即可:
{
"scripts": {
"test": "jest",
"test-c": "jest --coverage"
}
}
OK,至此我們的配置就結(jié)束了,接下來就可以進(jìn)行測(cè)試了,想要測(cè)試我們得先有一個(gè)被測(cè)試的東西,首先在 src/main.ts 里寫一個(gè)如下函數(shù):
/**
* 接受兩個(gè)數(shù)字并相加
*/
export const testFn = function (num1: number, num2: number): number {
return num1 + num2
}
很簡(jiǎn)單對(duì)吧,接下來我們就用 Jest 對(duì)其進(jìn)行測(cè)試,在同目錄下新建文件 main.test.ts 并填入如下內(nèi)容:
import { testFn } from './main'
it('可以正常相加', () => {
const result = testFn(1, 2)
expect(result).toBe(3)
})
然后執(zhí)行測(cè)試命令 npm run test 即可進(jìn)行測(cè)試,很快控制臺(tái)中就會(huì)輸出測(cè)試結(jié)果:

至此,我們的 jest 測(cè)試框架就引入成功了,接下來我們就來從剛才寫的 main.test.ts 文件開始,了解下什么叫做單元測(cè)試。
單元測(cè)試:代碼可用性的保障
單元測(cè)試(unit testing,簡(jiǎn)稱單測(cè)),是指對(duì)軟件中的最小單元進(jìn)行檢查和驗(yàn)證。我們剛才寫的就是一個(gè)單測(cè)用例。注意這里的最小單元并不是指函數(shù),哪怕一個(gè)類很復(fù)雜,如果他沒有對(duì)外部暴露其他細(xì)節(jié),那他本身就是一個(gè)“單元”,所以這個(gè)概念與代碼量的多少無關(guān)。
在單元測(cè)試之上,還有功能測(cè)試,模塊測(cè)試乃至集成測(cè)試。越往后,每個(gè)用例所測(cè)試的范圍也就更大,同時(shí)更注重其他方面而不是細(xì)節(jié)。而作為測(cè)試的基石,單元測(cè)試的數(shù)量和質(zhì)量就直接決定了你代碼是否可靠。
光說可能不太直觀,讓我們回到剛才寫的測(cè)試代碼:
import { testFn } from './main'
// 這個(gè) it 就代表了一個(gè)測(cè)試用例
it('可以正常相加', () => {
// 執(zhí)行測(cè)試
const result = testFn(1, 2)
// 比較測(cè)試結(jié)果和我們的期望
expect(result).toBe(3)
})
可以看到我們調(diào)用了一個(gè) it 方法,每個(gè) it 方法就是一個(gè)測(cè)試用例,他接受兩個(gè)參數(shù),第一個(gè)參數(shù)是用例的介紹,第二個(gè)參數(shù)是一個(gè)函數(shù),包含實(shí)際的測(cè)試代碼。一個(gè)文件里可以包含多個(gè) it 方法調(diào)用。而這個(gè)文件就被稱為一個(gè)測(cè)試套件(suit)。在測(cè)試時(shí),jest 會(huì)自動(dòng)去尋找項(xiàng)目中所有以 .test.ts 結(jié)尾的文件,并將其作為測(cè)試文件執(zhí)行。
可以看到我們并沒有引入 it 方法,因?yàn)?Jest 會(huì)自動(dòng)的將測(cè)試需要的工具函數(shù)都注入到全局變量 global 上,所以我們可以直接調(diào)用。
幾乎每個(gè)測(cè)試用例都由三部分構(gòu)成:構(gòu)建測(cè)試素材、執(zhí)行測(cè)試、檢查期望:
- 構(gòu)建測(cè)試素材:由于我們測(cè)試的這個(gè)函數(shù)太簡(jiǎn)單了,所以不需要構(gòu)建什么素材,對(duì)于復(fù)雜一些的測(cè)試,例如一個(gè)函數(shù)的參數(shù)是 creep 和一個(gè) source。我們就需要先 mock 出來這些對(duì)象,然后再執(zhí)行測(cè)試。
- 執(zhí)行測(cè)試:執(zhí)行測(cè)試不必多說,就是正常的代碼調(diào)用。
-
檢查期望:最后我們使用了一個(gè) expect 函數(shù),它也是 jest 的一個(gè)全局對(duì)象,我們就是使用它進(jìn)行的期望檢查。
expect(result).toBe(3)這句話的意思就是 result 這個(gè)變量的值應(yīng)該等于 3。關(guān)于 expect 的詳細(xì)文檔見 jest 官方文檔 - expect。
實(shí)際上,expect 這類工具函數(shù)被統(tǒng)稱為斷言庫。它做的事情很簡(jiǎn)單,讓我們可以更語義化的描述我們的期望,如果不符合期望的話它就會(huì)報(bào)錯(cuò)。并且,除了報(bào)錯(cuò)后它還會(huì)詳情的描述你究竟錯(cuò)到那里了,例如我們把上面 toBe 里的 3 改成 100 再運(yùn)行測(cè)試,就可以看到如下輸出:

可以看到,除了指出了哪里報(bào)錯(cuò),代碼還給出了期望值(Expected)和收到的實(shí)際值(Received),由此,我們就可以更直觀的了解到究竟錯(cuò)在了哪里。
斷點(diǎn)調(diào)試
其實(shí)現(xiàn)在我們就可以借助 IDE 的能力對(duì)代碼進(jìn)行斷點(diǎn)調(diào)試了,以 vscode 為例,我們?cè)诖a里插入 debugger 關(guān)鍵字,然后 點(diǎn)擊 test npm 腳本后的調(diào)試按鈕 即可進(jìn)入調(diào)試模式。當(dāng)進(jìn)程執(zhí)行到 debbuger 后就會(huì)暫停代碼運(yùn)行并啟動(dòng)斷點(diǎn)調(diào)試,如下:

我的測(cè)試文件應(yīng)該寫在哪里?
你可以選擇新建
test/unit目錄,然后把所有的測(cè)試用例都寫在這里。又或者分開寫在src/目錄下的對(duì)應(yīng)模塊里,相比起來我更推薦后者,因?yàn)?screeps 里有可能會(huì)包含很多個(gè)相互獨(dú)立的模塊,把測(cè)試文件寫在對(duì)應(yīng)的文件夾里可以提高模塊的內(nèi)聚性。不過無論你用哪種方法,請(qǐng)記得測(cè)試文件的名字應(yīng)該與被測(cè)試文件保持一致。
使用 jest 測(cè)試 screeps 代碼
上面我們了解了 jest 的基本使用,接下來就來介紹一下如何用 jest 測(cè)試我們的 screeps 代碼,這一部分最主要的內(nèi)容,就是 screeps 環(huán)境的 mock。
上面我們?cè)?jīng)提到過,由于測(cè)試用例是在我們本地執(zhí)行的,所以默認(rèn)情況下測(cè)試環(huán)境就是一個(gè)純粹的 Node 環(huán)境(加上一點(diǎn) jest 的全局注入)。而 screeps 環(huán)境里是有不少全局變量的,所以我們要先將其偽造出來,防止我們的 screeps 代碼因?yàn)檎也坏叫枰膶?duì)象而報(bào)錯(cuò)。
首先我們來整理一下最基本的幾個(gè) screeps 全局依賴:
- Game:這么沒得說,肯定是要有的。
- Memory:數(shù)據(jù)儲(chǔ)存對(duì)象,也要有。
- lodash:screeps 默認(rèn)在全局引入了 lodash,所以我們也要添加進(jìn)來。
- 一大堆的全局常量:screeps 里的常量都在全局,沒什么好說的,加就完事了。
ok,接下來開始干活,首先找到你的全局類型定義的地方(比如 src/global.d.ts 之類的,沒有就直接創(chuàng)建一個(gè)),我們來聲明一下接下來要設(shè)置的幾個(gè)全局變量:
declare module NodeJS {
interface Global {
Game: Game
Memory: Memory
_: _.LoDashStatic
}
}
如果不設(shè)置的話,ts 有可能會(huì)禁止你往全局寫入這些變量。接下來我們執(zhí)行如下命令來安裝 lodash 工具庫:
npm install --save-dev lodash@3.10.1
這里指定了 --save-dev,因?yàn)槲覀冎恍枰诒镜氐臏y(cè)試環(huán)境使用它。之后我們會(huì)把它添加到 global 里,現(xiàn)在來思考一個(gè)嚴(yán)峻的問題,那些全局常量怎么辦,那么多我總不能一個(gè)一個(gè)寫吧?
欸,不用擔(dān)心,還記得我們?cè)谝婚_始安裝的 @screeps/common 依賴么,這個(gè)庫也被用在 screeps 的官方私服中,其中就定義著我們需要的所有常量。我們只需要將其引入即可。咱們?cè)?mock 目錄里新建一個(gè) index.ts 并填入如下內(nèi)容,全局常量的引入就在最后一行:
import * as _ from 'lodash'
import constants from './constant'
/**
* 偽造的全局 Game 類
*/
export class GameMock {
creeps = {}
rooms = {}
spawns = {}
time = 1
}
/**
* 偽造的全局 Memory 類
*/
export class MemoryMock {
creeps = {}
rooms = {}
}
/**
* 包含任意鍵值對(duì)的類
*/
type AnyClass = {
new (): any;
[key: string]: any
}
/**
* util - 快捷生成游戲?qū)ο髣?chuàng)建函數(shù)
*
* @param MockClass 偽造的基礎(chǔ)游戲類
* @returns 一個(gè)函數(shù),可以指定要生成類的任意屬性
*/
export const getMock = function<T> (MockClass: AnyClass): (props?: Partial<T>) => T {
return (props = {}) => Object.assign(new MockClass() as T, props)
}
/**
* 創(chuàng)建一個(gè)偽造的 Game 實(shí)例
*/
export const getMockGame = getMock<Game>(GameMock)
/**
* 創(chuàng)建一個(gè)偽造的 Memory 實(shí)例
*/
export const getMockMemory = getMock<Memory>(MemoryMock)
/**
* 刷新游戲環(huán)境
* 將 global 改造成類似游戲中的環(huán)境
*/
export const refreshGlobalMock = function () {
global.Game = getMockGame()
global.Memory = getMockMemory()
global._ = _
// 下面的 @screeps/common/lib/constants 就是所有的全局常量
Object.assign(global, require("@screeps/common/lib/constants"))
}
為了方便介紹,我把這些代碼都放在了同一個(gè)文件里,你可以根據(jù)自己需要把上面的代碼拆分到不同文件。
這段代碼里比較復(fù)雜的有兩個(gè)地方,一是 getMock 函數(shù),這個(gè)咱們待會(huì)再講,二是末尾的 refreshGlobalMock 函數(shù),這個(gè)就是我們 screeps 環(huán)境 mock 的入口,只需要調(diào)用這個(gè)函數(shù),代碼執(zhí)行環(huán)境就可以被我們改造成近似于 screeps 的樣子。
事實(shí)上,screeps 的全局變量遠(yuǎn)不止這些,很多對(duì)象的原型類,比如 Creep、Room 也都被掛載在 global 上,不過我并不推薦你先 mock 整個(gè) screeps 然后再開始寫測(cè)試用例,相反,我推薦 先 mock 一個(gè)基本的環(huán)境,然后根據(jù)你測(cè)試用例的依賴,一步步增加你的 mock 工具。
ok,現(xiàn)在我們已經(jīng)完成了環(huán)境偽造函數(shù)的準(zhǔn)備工作,那么怎么調(diào)用它呢?首先,我們 打開 jest.config.js,然后在 module.exports 導(dǎo)出的對(duì)象中填寫如下字段:
module.exports = {
// ...
// 當(dāng) jest 環(huán)境準(zhǔn)備好后執(zhí)行的代碼文件
setupFilesAfterEnv : [
'<rootDir>/test/setup.ts'
],
// ...
}
之后,我們?cè)趯?duì)應(yīng)的 test 文件夾中新建一個(gè) setup.ts 文件,并填入如下內(nèi)容即可:
import { refreshGlobalMock } from './mock'
// 先進(jìn)行環(huán)境 mock
refreshGlobalMock()
// 然后在每次測(cè)試用例執(zhí)行前重置 mock 環(huán)境
beforeEach(refreshGlobalMock)
這里邊的 beforeEach 是什么呢?它也是 jest 注入的全局變量之一,作用是 在每個(gè)測(cè)試用例調(diào)用前執(zhí)行傳入的函數(shù)。也就是說,我們每個(gè)測(cè)試用例執(zhí)行前都會(huì)運(yùn)行一遍 refreshGlobalMock,這樣不僅可以偽造 screeps 環(huán)境,也防止了上個(gè)測(cè)試用例污染了全局環(huán)境。
現(xiàn)在我們的 screeps 環(huán)境偽造就已經(jīng)基本完成了,接下來就可以回到 src/main.test.ts 中測(cè)試一下了:
it('可以正常相加', () => { /** ... */ })
it('全局環(huán)境測(cè)試', () => {
// 全局應(yīng)定義了 Game
expect(Game).toBeDefined()
// 全局應(yīng)定義了 lodash
expect(_).toBeDefined()
// 全局的 Memory 應(yīng)該定義且包含基礎(chǔ)的字段
expect(Memory).toMatchObject({ rooms: {}, creeps: {} })
})
執(zhí)行后即可看到測(cè)試通過,如果你沒有通過測(cè)試的話請(qǐng)根據(jù)報(bào)錯(cuò)提示查找原因。
偽造好了測(cè)試環(huán)境后,現(xiàn)在我們回頭講一下 test/mock/index.ts 中出現(xiàn)的 getMock 函數(shù),它實(shí)際上是一個(gè)工具函數(shù),用于快速創(chuàng)建 mock 的實(shí)例(的生成函數(shù)),在上面我們已經(jīng)用其生成了 getGameMock 和 getMemoryMock,除此之外,我們也可以用它來生成其他的游戲?qū)ο螅缱顬槌S玫?creep:
test/mock/Creep.ts
import { getMock } from './index'
// 偽造 creep 的默認(rèn)值
class CreepMock {
body: BodyPartDefinition[] = [{ type: MOVE, hits: 100 }]
fatigue: number = 0
hits: number = 100
hitsMax: number = 100
id: Id<this> = `${new Date().getTime()}${Math.random()}` as Id<this>
memory: CreepMemory = { role: 'harvester' , working: false }
my: boolean = true
name: string = `creep${this.id}`
owner: Owner = { username: 'hopgoldy' }
room: Room
spawning: boolean = false
saying: string
store: StoreDefinition
ticksToLive: number | undefined = 1500
}
/**
* 偽造一個(gè) creep
* @param props 該 creep 的屬性
*/
export const getMockCreep = getMock<Creep>(CreepMock)
然后我們就可以在測(cè)試用例里使用 getMockCreep 來創(chuàng)建我們需要的 creep 實(shí)例:
// 需要提前在 tsconfig.json 的 paths 中配置 "@mock/*": ["./test/mock/*"]
import { getMockCreep } from '@mock/Creep'
it('mock Creep 可以正常使用', () => {
// 創(chuàng)建一個(gè) creep 并指定其屬性
const creep = getMockCreep({ name: 'test', ticksToLive: 100 })
expect(creep.name).toBe('test')
expect(creep.ticksToLive).toBe(100)
})
可以看到,我們可以通過 getMockCreep 創(chuàng)建一個(gè)類型為 Creep,并且還擁有我們自定義屬性的 creep 實(shí)例,我們可以通過給 getMockCreep 傳入 creep 原型上存在的任意屬性(包括方法)來進(jìn)行自定義。
接下來我們來學(xué)習(xí)一個(gè)可以讓測(cè)試更加方便的 mock 小工具,它同樣被集成到了 jest 中。
Jest mock 函數(shù)
假如我們有一個(gè)函數(shù),它接受一個(gè) creep 和一個(gè) source 作為參數(shù),當(dāng) source 的容量大于 0 時(shí),就會(huì)調(diào)用 creep 的 harvest 方法,那么怎么檢查它調(diào)用了幾次呢。你可能會(huì)想到給 harvest 方法賦值一個(gè)函數(shù)并閉包保存一個(gè)值,當(dāng)函數(shù)調(diào)用時(shí)進(jìn)行累加。
這種方法也可以,不過還有種更簡(jiǎn)單的方法,那就是我們接下來要介紹的 mock function:它可以記錄自己被調(diào)用的次數(shù)、被調(diào)用時(shí)接受的參數(shù)等等,在測(cè)試領(lǐng)域這類函數(shù)被稱為 spy。在 jest 中我們可以通過 jest.fn() 創(chuàng)建一個(gè) mock function,如下:
/**
* 當(dāng) source 里有能量時(shí)讓 creep 執(zhí)行采集
*/
const useHarvest = function (creep: Creep, source: Source): void {
if (source.energy > 0) creep.harvest(source)
}
it('useHarvest 可以正確調(diào)用 harvest 方法', () => {
const mockHarvest = jest.fn()
// 構(gòu)建測(cè)試素材
const creep = getMockCreep({ harvest: mockHarvest })
const hasEnergySource = { energy: 100 } as Source
const noEnergySource = { energy: 0 } as Source
// 執(zhí)行測(cè)試
useHarvest(creep, hasEnergySource)
useHarvest(creep, hasEnergySource)
useHarvest(creep, noEnergySource)
// 檢查期望
expect(mockHarvest).toBeCalledTimes(2)
// 這兩種寫法結(jié)果相同
expect(mockHarvest.mock.calls).toHaveLength(2)
console.log(mockHarvest.mock.calls)
// > [ [ { energy: 100 } ], [ { energy: 100 } ] ]
})
可以看到,由于 mockHarvest 可以記錄調(diào)用內(nèi)容,所以我們可以很輕易的進(jìn)行判斷。并且被調(diào)用的參數(shù)也會(huì)被保存到 mockHarvest.mock.calls 中,你也可以用它來檢查具體的傳入?yún)?shù)是否正確。更多相關(guān)文檔可以參閱 jest 官方文檔 mock-function。
單元測(cè)試覆蓋率
教程的最后,我們來介紹一下什么是單測(cè)覆蓋率,我們可以用一些工具監(jiān)聽測(cè)試用例的執(zhí)行,并分析我們的代碼,由此來展示測(cè)試用例“覆蓋”了哪些邏輯。我們可以簡(jiǎn)單的認(rèn)為這個(gè)指標(biāo)越高,代碼就越可靠。
在 jest 中已經(jīng)集成了覆蓋率檢查工具 istanbul。并且我們剛開始配置時(shí)已經(jīng)新增了測(cè)試命令,所以我們直接執(zhí)行如下命令即可查看單測(cè)覆蓋率:
npm run test-c
執(zhí)行結(jié)果如下:

一堆綠啊,很好看,不過由于測(cè)試覆蓋率只會(huì)檢查測(cè)試用例涉及到的函數(shù),所以這里的測(cè)試結(jié)果其實(shí)并不怎么準(zhǔn)確。所以接下來我會(huì)以之前開發(fā)的 screeps 漢化補(bǔ)丁 為例來進(jìn)行講解,下面是其覆蓋率報(bào)告:

其中的分列含義如下:
- Stmts:語句覆蓋率,是不是每個(gè)語句都執(zhí)行了
- Btanch:分支覆蓋率,由分支語句如 if-else 產(chǎn)生的分支覆蓋了多少
- Funcs:函數(shù)覆蓋率,測(cè)試覆蓋了多少函數(shù)
- Lines:行覆蓋率,測(cè)試覆蓋了本文件多少行
- Uncovered Line:哪些主要代碼行沒有覆蓋到
- Path:路徑覆蓋率,這個(gè)報(bào)告中并沒有包含,路徑覆蓋率是分支覆蓋率的升級(jí)版,例如三個(gè)同級(jí)的 if-else 會(huì)產(chǎn)生 8 中不同的路徑分支,這也是對(duì)代碼覆蓋率的終極體現(xiàn)。
主要的衡量指標(biāo)就是 all files 的語句覆蓋率,一般認(rèn)為應(yīng)至少達(dá)到 80%,越高越好。
不僅如此,我們還可以項(xiàng)目根目錄的 coverage 目錄中找到它生成的詳細(xì)覆蓋率報(bào)告,我們可以直接在瀏覽器中打開 coverage\lcov-report\index.html 文件,就可以看到哪些內(nèi)容被覆蓋到,非常直觀,這里不再贅述。
總結(jié)
恭喜你看完了這篇超長(zhǎng)教程,本篇文章介紹了如何在 screeps 項(xiàng)目中引入單測(cè)和如何書寫單測(cè)用例,之后對(duì) screeps 環(huán)境進(jìn)行了基本模擬以及簡(jiǎn)單介紹了一下單測(cè)覆蓋率。要記住,我們現(xiàn)在有測(cè)試環(huán)境和 screeps 游戲環(huán)境兩套環(huán)境,在 screeps 環(huán)境中(src 目錄下的代碼)我們只能使用游戲提供的 api。而在測(cè)試環(huán)境中,我們可以使用完整的 node 能力進(jìn)行開發(fā)。牢記這一點(diǎn)并注意代碼是運(yùn)行在哪個(gè)環(huán)境里的,別讓 screeps 局限了你的想象力。
現(xiàn)在你就可以好好審視一下自己的項(xiàng)目,然后開始自己的測(cè)試之旅吧!
想要查看更多教程?歡迎訪問 《Screeps 中文教程》或者訪問 《Screeps 搭建開發(fā)環(huán)境 - 導(dǎo)言》 來繼續(xù)升級(jí)你的項(xiàng)目!