前言
上篇文章 里,我們給自己的 screeps 項目引入了 typescript,這讓我們的代碼可靠性獲得了質的飛躍。那么為什么還要引入自動測試呢?問得好,我們先來想象一下下面的場景:
你花了好幾天完成了一個重要模塊,為了保證其可靠性,你進行了詳細而又認真的測試,測試完成后,你的模塊像一個精密的機械一樣,每行代碼都明確而可靠的運行著,你獲得了很大的成就感。
這個模塊穩定的運行了好久,直到有一天,你發現需要往這個模塊里添加一些代碼,添加完成后模塊依舊正常運行,所以你沒有在意,繼續去開發其他代碼了。突然有一天,代碼突然報了個錯,之后又恢復正常,你根據錯誤信息找到了對應的代碼,檢查了一遍之后發現,不對啊這段代碼沒改過不可能有問題啊。但是災難就此降臨,之后代碼偶爾就會報一個錯,由于沒辦法斷點調試,你開始往線上的代碼里插一堆 log,但是依舊分析不出問題究竟是什么,你也曾花大功夫在私服里進行詳細測試,但是問題依舊復現不出來。
你開始筋疲力盡胸口發堵,就像是一拳打在了棉花上。之前引以為傲的精密代碼現在就像是屎山一樣堆在那里,里邊到處插滿了 console.log 和調試代碼,像是一場進行不下去的手術。
是不是已經開始難受了,沒錯,這個問題同樣困擾著這個世界上的頂級開發者們,他們維護著比我們的 screeps 復雜的多的巨型項目。直到有一天,有人想到,如果我能把之前手工做的測試通過代碼的形式固化下來,以后修改代碼之后直接全部執行一遍,不就既省時又能讓代碼更可靠么,于是,自動化測試誕生了,這也就是我們今天要講的內容。
簡單介紹自動化測試
網上關于自動化測試的文章有很多,這里就簡單介紹一下 Screeps 相關的內容。
是騾子是馬拉出來溜溜,測試的本質就是這個。如果說 typescript 是靜態檢查,那么我們就可以把測試稱為動態檢查。通過真實的運行這段代碼并檢查其結果是否符合預期,由此來證明這段代碼是否可用。這就是進行測試的目的所在。
由于測試的內容很多,所以我們會把不同的內容分開寫,而每一段測試內容我們就稱為一個 測試用例。并且因為很多真實環境里用到的依賴我們在測試環境里并沒有,所以需要在測試之前“偽造”他們,讓被測試的代碼認為自己所處的環境就是真實環境。這個過程我們一般稱為 mock。
當前業內已經出現了很多成熟的測試框架,而我們教程里使用的就是最近發展迅速的 jest。jest 由 facebook 維護,以零配置著稱,更多介紹詳見 jest 官方網站。
jest 還是 mocha?
實際上,當前的 screeps 社區幾乎絕大多數項目使用的都是另一個老牌測試框架 mocha,包括我之前也在使用 mocha。那么為什么本文會介紹 jest 呢?
主要原因是 jest 所需的配置更少,適合新手入門。mocha 由于其靈活性,很多需要用到的工具都需要自行安裝,而 jest 已經內建了足夠好用的相應工具。并且這兩者的代碼風格都非常類似,你可以輕易的復用寫好的測試用例。網上也有很多這兩個框架的對比,這里不再贅述。
如果你想使用 mocha,沒有關系,直接百度 mocha ts 即可,或者參考我之前寫的 typescript 使用 mocha 進行單元測試。下文中除了涉及到 jest 的配置和用例寫法外,其他大部分都可以應用在引入了 mocha 的項目里。
在本系列教程里我們會著重介紹兩個測試方式,分別是 單元測試 和 集成測試。單元測試是小,檢查每個函數每個功能是否正常,本文內容就是介紹如何使用單元測試。集成測試是大,通過運行整個腳本并記錄運行情況來檢查 bot 的整體可用性,將在下篇文章中介紹。
不過在深入介紹之前,我們按照慣例先來了解一下引入自動測試的優缺點,請根據自己的項目情況認真思考自己是否需要用到它。
單元測試優缺點對比
優點
記錄模塊用法:每個測試用例都是被測試代碼的使用例子。并且這段代碼還可以執行,通過查閱測試用例,你可以很輕易的了解到這個模塊應該如何調用。
更好的代碼質量:想要進行單元測試,就需要你的模塊解耦做的足夠好,不然測試起來會非常復雜。所以引入測試會迫使你過度耦合的模塊進行解耦,將職責不唯一的函數進行拆分,規范代碼中的副作用讓業務更清晰。
通過對老項目進行大規模重構,你的代碼質量將更上一層樓。防止 bug 回歸:由于修復新 bug 導致原來的 bug 復現了,我們通常將其稱為 回歸,并由此誕生了回歸測試。而一旦測試用例寫好了,那在之后的測試中它都會被執行,如果有 bug 回歸了,那測試用例就必然會失敗,由此我們可以非常快速的發現回歸問題。
方便測試極端場景:在游戲里有很多極端場景是很難復現的,例如一個 creep 會在特定地形、特地房間、有特定建筑、自己在執行特定任務時才會出現問題。而在測試用例里,代碼的執行環境完全是我們創建出來的,所以我們可以輕易的模擬出一個穩定的極端場景。
支持斷點調試:沒錯,測試的終極,由于我們的測試是在本地而不是游戲服務器上進行的,所以我們終于可以逐行的執行代碼并查看其運行情況。斷點調試對于測試的重要性想必不須我多言。
缺點
增加開發工作:俗話說,百行代碼,千行測試。想要得到一個完整測試的模塊,你需要寫非常多的測試用例,從正常輸入到異常輸入,從大數據量壓測到極端場景測試,這些測試代碼都需要你來完成。
需要 mock 工具:還記得我們在游戲中使用的 Creep、Room、Game、Memory 這些習以為常的變量么,這些在測試環境里都沒有,你需要手動 mock 他們,這對于你的編碼功底和對游戲的了解程度是一個不小的考驗。不過下文我們會介紹如何進行 mock。
mock 的不真實性:測試環境就算我們模擬的再真實,它也不是真正的運行環境。有些問題是因為 mock 偽造的不夠像導致的,并不能說明你的代碼真的有問題。
問題永遠出現在你想不到的地方:你寫了很多的測試用例,那也只能說明針對這些使用場景,你的代碼不會出現問題。就算引入了自動測試,也并不代表著你的代碼就一定是絕對穩定的。
當然,如果你是抱著“可以不用,不能沒有”的想法來的,那么直接開始即可。引入自動測試不會帶來任何改變,甚至你不需要對項目進行任何改造,測試用例完全獨立于原先的游戲代碼,哪怕你一個測試用例都不寫也不會影響什么。
jest 安裝與配置
廢話說了這么多,終于可以開始寫碼了。本項目基于 Screeps 使用 TypeScript 進行靜態類型檢查 文中搭建的項目繼續完善,請確保你至少讀過這篇文章。
首先我們在項目中執行如下命令來安裝依賴:
npm install --save-dev jest ts-jest @types/jest @screeps/common
安裝完成后在根目錄下新增 jest.config.js
并填入如下內容:
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 我們再去 package.json 里新增測試命令,你的 scripts 里可能已經有了一個 test 命令,直接刪掉即可:
{
"scripts": {
"test": "jest",
"test-c": "jest --coverage"
}
}
OK,至此我們的配置就結束了,接下來就可以進行測試了,想要測試我們得先有一個被測試的東西,首先在 src/main.ts 里寫一個如下函數:
/**
* 接受兩個數字并相加
*/
export const testFn = function (num1: number, num2: number): number {
return num1 + num2
}
很簡單對吧,接下來我們就用 Jest 對其進行測試,在同目錄下新建文件 main.test.ts
并填入如下內容:
import { testFn } from './main'
it('可以正常相加', () => {
const result = testFn(1, 2)
expect(result).toBe(3)
})
然后執行測試命令 npm run test
即可進行測試,很快控制臺中就會輸出測試結果:
至此,我們的 jest 測試框架就引入成功了,接下來我們就來從剛才寫的 main.test.ts 文件開始,了解下什么叫做單元測試。
單元測試:代碼可用性的保障
單元測試(unit testing,簡稱單測),是指對軟件中的最小單元進行檢查和驗證。我們剛才寫的就是一個單測用例。注意這里的最小單元并不是指函數,哪怕一個類很復雜,如果他沒有對外部暴露其他細節,那他本身就是一個“單元”,所以這個概念與代碼量的多少無關。
在單元測試之上,還有功能測試,模塊測試乃至集成測試。越往后,每個用例所測試的范圍也就更大,同時更注重其他方面而不是細節。而作為測試的基石,單元測試的數量和質量就直接決定了你代碼是否可靠。
光說可能不太直觀,讓我們回到剛才寫的測試代碼:
import { testFn } from './main'
// 這個 it 就代表了一個測試用例
it('可以正常相加', () => {
// 執行測試
const result = testFn(1, 2)
// 比較測試結果和我們的期望
expect(result).toBe(3)
})
可以看到我們調用了一個 it 方法,每個 it 方法就是一個測試用例,他接受兩個參數,第一個參數是用例的介紹,第二個參數是一個函數,包含實際的測試代碼。一個文件里可以包含多個 it 方法調用。而這個文件就被稱為一個測試套件(suit)。在測試時,jest 會自動去尋找項目中所有以 .test.ts
結尾的文件,并將其作為測試文件執行。
可以看到我們并沒有引入 it 方法,因為 Jest 會自動的將測試需要的工具函數都注入到全局變量 global 上,所以我們可以直接調用。
幾乎每個測試用例都由三部分構成:構建測試素材、執行測試、檢查期望:
- 構建測試素材:由于我們測試的這個函數太簡單了,所以不需要構建什么素材,對于復雜一些的測試,例如一個函數的參數是 creep 和一個 source。我們就需要先 mock 出來這些對象,然后再執行測試。
- 執行測試:執行測試不必多說,就是正常的代碼調用。
-
檢查期望:最后我們使用了一個 expect 函數,它也是 jest 的一個全局對象,我們就是使用它進行的期望檢查。
expect(result).toBe(3)
這句話的意思就是 result 這個變量的值應該等于 3。關于 expect 的詳細文檔見 jest 官方文檔 - expect。
實際上,expect 這類工具函數被統稱為斷言庫。它做的事情很簡單,讓我們可以更語義化的描述我們的期望,如果不符合期望的話它就會報錯。并且,除了報錯后它還會詳情的描述你究竟錯到那里了,例如我們把上面 toBe 里的 3 改成 100 再運行測試,就可以看到如下輸出:
可以看到,除了指出了哪里報錯,代碼還給出了期望值(Expected)和收到的實際值(Received),由此,我們就可以更直觀的了解到究竟錯在了哪里。
斷點調試
其實現在我們就可以借助 IDE 的能力對代碼進行斷點調試了,以 vscode 為例,我們在代碼里插入 debugger 關鍵字,然后 點擊 test npm 腳本后的調試按鈕 即可進入調試模式。當進程執行到 debbuger 后就會暫停代碼運行并啟動斷點調試,如下:
我的測試文件應該寫在哪里?
你可以選擇新建
test/unit
目錄,然后把所有的測試用例都寫在這里。又或者分開寫在src/
目錄下的對應模塊里,相比起來我更推薦后者,因為 screeps 里有可能會包含很多個相互獨立的模塊,把測試文件寫在對應的文件夾里可以提高模塊的內聚性。不過無論你用哪種方法,請記得測試文件的名字應該與被測試文件保持一致。
使用 jest 測試 screeps 代碼
上面我們了解了 jest 的基本使用,接下來就來介紹一下如何用 jest 測試我們的 screeps 代碼,這一部分最主要的內容,就是 screeps 環境的 mock。
上面我們曾經提到過,由于測試用例是在我們本地執行的,所以默認情況下測試環境就是一個純粹的 Node 環境(加上一點 jest 的全局注入)。而 screeps 環境里是有不少全局變量的,所以我們要先將其偽造出來,防止我們的 screeps 代碼因為找不到需要的對象而報錯。
首先我們來整理一下最基本的幾個 screeps 全局依賴:
- Game:這么沒得說,肯定是要有的。
- Memory:數據儲存對象,也要有。
- lodash:screeps 默認在全局引入了 lodash,所以我們也要添加進來。
- 一大堆的全局常量:screeps 里的常量都在全局,沒什么好說的,加就完事了。
ok,接下來開始干活,首先找到你的全局類型定義的地方(比如 src/global.d.ts
之類的,沒有就直接創建一個),我們來聲明一下接下來要設置的幾個全局變量:
declare module NodeJS {
interface Global {
Game: Game
Memory: Memory
_: _.LoDashStatic
}
}
如果不設置的話,ts 有可能會禁止你往全局寫入這些變量。接下來我們執行如下命令來安裝 lodash 工具庫:
npm install --save-dev lodash@3.10.1
這里指定了 --save-dev
,因為我們只需要在本地的測試環境使用它。之后我們會把它添加到 global 里,現在來思考一個嚴峻的問題,那些全局常量怎么辦,那么多我總不能一個一個寫吧?
欸,不用擔心,還記得我們在一開始安裝的 @screeps/common
依賴么,這個庫也被用在 screeps 的官方私服中,其中就定義著我們需要的所有常量。我們只需要將其引入即可。咱們在 mock
目錄里新建一個 index.ts
并填入如下內容,全局常量的引入就在最后一行:
import * as _ from 'lodash'
import constants from './constant'
/**
* 偽造的全局 Game 類
*/
export class GameMock {
creeps = {}
rooms = {}
spawns = {}
time = 1
}
/**
* 偽造的全局 Memory 類
*/
export class MemoryMock {
creeps = {}
rooms = {}
}
/**
* 包含任意鍵值對的類
*/
type AnyClass = {
new (): any;
[key: string]: any
}
/**
* util - 快捷生成游戲對象創建函數
*
* @param MockClass 偽造的基礎游戲類
* @returns 一個函數,可以指定要生成類的任意屬性
*/
export const getMock = function<T> (MockClass: AnyClass): (props?: Partial<T>) => T {
return (props = {}) => Object.assign(new MockClass() as T, props)
}
/**
* 創建一個偽造的 Game 實例
*/
export const getMockGame = getMock<Game>(GameMock)
/**
* 創建一個偽造的 Memory 實例
*/
export const getMockMemory = getMock<Memory>(MemoryMock)
/**
* 刷新游戲環境
* 將 global 改造成類似游戲中的環境
*/
export const refreshGlobalMock = function () {
global.Game = getMockGame()
global.Memory = getMockMemory()
global._ = _
// 下面的 @screeps/common/lib/constants 就是所有的全局常量
Object.assign(global, require("@screeps/common/lib/constants"))
}
為了方便介紹,我把這些代碼都放在了同一個文件里,你可以根據自己需要把上面的代碼拆分到不同文件。
這段代碼里比較復雜的有兩個地方,一是 getMock
函數,這個咱們待會再講,二是末尾的 refreshGlobalMock
函數,這個就是我們 screeps 環境 mock 的入口,只需要調用這個函數,代碼執行環境就可以被我們改造成近似于 screeps 的樣子。
事實上,screeps 的全局變量遠不止這些,很多對象的原型類,比如 Creep、Room 也都被掛載在 global 上,不過我并不推薦你先 mock 整個 screeps 然后再開始寫測試用例,相反,我推薦 先 mock 一個基本的環境,然后根據你測試用例的依賴,一步步增加你的 mock 工具。
ok,現在我們已經完成了環境偽造函數的準備工作,那么怎么調用它呢?首先,我們 打開 jest.config.js
,然后在 module.exports
導出的對象中填寫如下字段:
module.exports = {
// ...
// 當 jest 環境準備好后執行的代碼文件
setupFilesAfterEnv : [
'<rootDir>/test/setup.ts'
],
// ...
}
之后,我們在對應的 test
文件夾中新建一個 setup.ts
文件,并填入如下內容即可:
import { refreshGlobalMock } from './mock'
// 先進行環境 mock
refreshGlobalMock()
// 然后在每次測試用例執行前重置 mock 環境
beforeEach(refreshGlobalMock)
這里邊的 beforeEach
是什么呢?它也是 jest 注入的全局變量之一,作用是 在每個測試用例調用前執行傳入的函數。也就是說,我們每個測試用例執行前都會運行一遍 refreshGlobalMock
,這樣不僅可以偽造 screeps 環境,也防止了上個測試用例污染了全局環境。
現在我們的 screeps 環境偽造就已經基本完成了,接下來就可以回到 src/main.test.ts
中測試一下了:
it('可以正常相加', () => { /** ... */ })
it('全局環境測試', () => {
// 全局應定義了 Game
expect(Game).toBeDefined()
// 全局應定義了 lodash
expect(_).toBeDefined()
// 全局的 Memory 應該定義且包含基礎的字段
expect(Memory).toMatchObject({ rooms: {}, creeps: {} })
})
執行后即可看到測試通過,如果你沒有通過測試的話請根據報錯提示查找原因。
偽造好了測試環境后,現在我們回頭講一下 test/mock/index.ts
中出現的 getMock 函數,它實際上是一個工具函數,用于快速創建 mock 的實例(的生成函數),在上面我們已經用其生成了 getGameMock 和 getMemoryMock,除此之外,我們也可以用它來生成其他的游戲對象,例如最為常用的 creep:
test/mock/Creep.ts
import { getMock } from './index'
// 偽造 creep 的默認值
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
}
/**
* 偽造一個 creep
* @param props 該 creep 的屬性
*/
export const getMockCreep = getMock<Creep>(CreepMock)
然后我們就可以在測試用例里使用 getMockCreep 來創建我們需要的 creep 實例:
// 需要提前在 tsconfig.json 的 paths 中配置 "@mock/*": ["./test/mock/*"]
import { getMockCreep } from '@mock/Creep'
it('mock Creep 可以正常使用', () => {
// 創建一個 creep 并指定其屬性
const creep = getMockCreep({ name: 'test', ticksToLive: 100 })
expect(creep.name).toBe('test')
expect(creep.ticksToLive).toBe(100)
})
可以看到,我們可以通過 getMockCreep 創建一個類型為 Creep
,并且還擁有我們自定義屬性的 creep 實例,我們可以通過給 getMockCreep 傳入 creep 原型上存在的任意屬性(包括方法)來進行自定義。
接下來我們來學習一個可以讓測試更加方便的 mock 小工具,它同樣被集成到了 jest 中。
Jest mock 函數
假如我們有一個函數,它接受一個 creep 和一個 source 作為參數,當 source 的容量大于 0 時,就會調用 creep 的 harvest 方法,那么怎么檢查它調用了幾次呢。你可能會想到給 harvest 方法賦值一個函數并閉包保存一個值,當函數調用時進行累加。
這種方法也可以,不過還有種更簡單的方法,那就是我們接下來要介紹的 mock function:它可以記錄自己被調用的次數、被調用時接受的參數等等,在測試領域這類函數被稱為 spy。在 jest 中我們可以通過 jest.fn()
創建一個 mock function,如下:
/**
* 當 source 里有能量時讓 creep 執行采集
*/
const useHarvest = function (creep: Creep, source: Source): void {
if (source.energy > 0) creep.harvest(source)
}
it('useHarvest 可以正確調用 harvest 方法', () => {
const mockHarvest = jest.fn()
// 構建測試素材
const creep = getMockCreep({ harvest: mockHarvest })
const hasEnergySource = { energy: 100 } as Source
const noEnergySource = { energy: 0 } as Source
// 執行測試
useHarvest(creep, hasEnergySource)
useHarvest(creep, hasEnergySource)
useHarvest(creep, noEnergySource)
// 檢查期望
expect(mockHarvest).toBeCalledTimes(2)
// 這兩種寫法結果相同
expect(mockHarvest.mock.calls).toHaveLength(2)
console.log(mockHarvest.mock.calls)
// > [ [ { energy: 100 } ], [ { energy: 100 } ] ]
})
可以看到,由于 mockHarvest 可以記錄調用內容,所以我們可以很輕易的進行判斷。并且被調用的參數也會被保存到 mockHarvest.mock.calls
中,你也可以用它來檢查具體的傳入參數是否正確。更多相關文檔可以參閱 jest 官方文檔 mock-function。
單元測試覆蓋率
教程的最后,我們來介紹一下什么是單測覆蓋率,我們可以用一些工具監聽測試用例的執行,并分析我們的代碼,由此來展示測試用例“覆蓋”了哪些邏輯。我們可以簡單的認為這個指標越高,代碼就越可靠。
在 jest 中已經集成了覆蓋率檢查工具 istanbul。并且我們剛開始配置時已經新增了測試命令,所以我們直接執行如下命令即可查看單測覆蓋率:
npm run test-c
執行結果如下:
一堆綠啊,很好看,不過由于測試覆蓋率只會檢查測試用例涉及到的函數,所以這里的測試結果其實并不怎么準確。所以接下來我會以之前開發的 screeps 漢化補丁 為例來進行講解,下面是其覆蓋率報告:
其中的分列含義如下:
- Stmts:語句覆蓋率,是不是每個語句都執行了
- Btanch:分支覆蓋率,由分支語句如 if-else 產生的分支覆蓋了多少
- Funcs:函數覆蓋率,測試覆蓋了多少函數
- Lines:行覆蓋率,測試覆蓋了本文件多少行
- Uncovered Line:哪些主要代碼行沒有覆蓋到
- Path:路徑覆蓋率,這個報告中并沒有包含,路徑覆蓋率是分支覆蓋率的升級版,例如三個同級的 if-else 會產生 8 中不同的路徑分支,這也是對代碼覆蓋率的終極體現。
主要的衡量指標就是 all files 的語句覆蓋率,一般認為應至少達到 80%,越高越好。
不僅如此,我們還可以項目根目錄的 coverage
目錄中找到它生成的詳細覆蓋率報告,我們可以直接在瀏覽器中打開 coverage\lcov-report\index.html
文件,就可以看到哪些內容被覆蓋到,非常直觀,這里不再贅述。
總結
恭喜你看完了這篇超長教程,本篇文章介紹了如何在 screeps 項目中引入單測和如何書寫單測用例,之后對 screeps 環境進行了基本模擬以及簡單介紹了一下單測覆蓋率。要記住,我們現在有測試環境和 screeps 游戲環境兩套環境,在 screeps 環境中(src 目錄下的代碼)我們只能使用游戲提供的 api。而在測試環境中,我們可以使用完整的 node 能力進行開發。牢記這一點并注意代碼是運行在哪個環境里的,別讓 screeps 局限了你的想象力。
現在你就可以好好審視一下自己的項目,然后開始自己的測試之旅吧!
想要查看更多教程?歡迎訪問 《Screeps 中文教程》或者訪問 《Screeps 搭建開發環境 - 導言》 來繼續升級你的項目!