Screeps 使用 Jest 添加單元測(cè)試

screeps 系列教程

前言

上篇文章 里,我們給自己的 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 是一個(gè)令人愉快的 JavaScript 測(cè)試框架,專注于簡(jiǎn)潔明快。

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é)果:

測(cè)試通過

至此,我們的 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è)試,就可以看到如下輸出:

測(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)告:

screeps-chinese-pack 的單測(cè)覆蓋率報(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)目!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容