Screeps 使用 Jest 添加单元测试
前言
上篇文章 里,我们给自己的 screeps 项目引入了 typescript,这让我们的代码可靠性获得了质的飞跃。那么为什么还要引入自动测试呢?问得好,我们先来想象一下下面的场景:
你花了好几天完成了一个重要模块,为了保证其可靠性,你进行了详细而又认真的测试,测试完成后,你的模块像一个精密的机械一样,每行代码都明确而可靠的运行着,你获得了很大的成就感。
这个模块稳定的运行了好久,直到有一天,你发现需要往这个模块里添加一些代码,添加完成后模块依旧正常运行,所以你没有在意,继续去开发其他代码了。突然有一天,代码突然报了个错,之后又恢复正常,你根据错误信息找到了对应的代码,检查了一遍之后发现,不对啊这段代码没改过不可能有问题啊。但是灾难就此降临,之后代码偶尔就会报一个错,由于没办法断点调试,你开始往线上的代码里插一堆 log,但是依旧分析不出问题究竟是什么,你也曾花大功夫在私服里进行详细测试,但是问题依旧复现不出来。
你开始筋疲力尽胸口发堵,就像是一拳打在了棉花上。之前引以为傲的精密代码现在就像是屎山一样堆在那里,里边到处插满了 console.log 和调试代码,像是一场进行不下去的手术。
是不是已经开始难受了,没错,这个问题同样困扰着这个世界上的顶级开发者们,他们维护着比我们的 screeps 复杂的多的巨型项目。直到有一天,有人想到,如果我能把之前手工做的测试通过代码的形式固化下来,以后修改代码之后直接全部执行一遍,不就既省时又能让代码更可靠么,于是,自动化测试诞生了,这也就是我们今天要讲的内容。
简单介绍自动化测试
网上关于自动化测试的文章有很多,这里就简单介绍一下 Screeps 相关的内容。
是骡子是马拉出来溜溜,测试的本质就是这个。如果说 typescript 是静态检查,那么我们就可以把测试称为动态检查。通过真实的运行这段代码并检查其结果是否符合预期,由此来证明这段代码是否可用。这就是进行测试的目的所在。
由于测试的内容很多,所以我们会把不同的内容分开写,而每一段测试内容我们就称为一个 测试用例。并且因为很多真实环境里用到的依赖我们在测试环境里并没有,所以需要在测试之前“伪造”他们,让被测试的代码认为自己所处的环境就是真实环境。这个过程我们一般称为 mock。
当前业内已经出现了很多成熟的测试框架,而我们教程里使用的就是最近发展迅速的 jest。jest 由 facebook 维护,以零配置著称,更多介绍详见 jest 官方网站。
Jest 是一个令人愉快的 JavaScript 测试框架,专注于简洁明快。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 汉化补丁 为例来进行讲解,下面是其覆盖率报告:
screeps-chinese-pack 的单测覆盖率报告其中的分列含义如下:
- 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 搭建开发环境 - 导言》 来继续升级你的项目!