Screeps 使用 Jest 添加单元测试

screeps 系列教程

前言

上篇文章 里,我们给自己的 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: [''],
    transform: {
        '^.+\\.tsx?$': 'ts-jest'
    },
    moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { prefix: '/' }),
    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 [email protected]

这里指定了 --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 (MockClass: AnyClass): (props?: Partial) => T {
    return (props = {}) => Object.assign(new MockClass() as T, props)
}

/**
 * 创建一个伪造的 Game 实例
 */
export const getMockGame = getMock(GameMock)

/**
 * 创建一个伪造的 Memory 实例
 */
export const getMockMemory = getMock(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 : [
        '/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 = `${new Date().getTime()}${Math.random()}` as Id
    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(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 搭建开发环境 - 导言》 来继续升级你的项目!

你可能感兴趣的:(Screeps 使用 Jest 添加单元测试)