前言
最近在升级重制一些核心玩法,做完,也完整验证一遍过后,不巧有一个细节需要修改,改完之后肯定要验证呀。
但是,作为一个底层函数,完整覆盖验证,可以有十几种情况… 血压一下就上来了。
于是,原本时程靠后的单元测试搭建,提到了最前面。
文章主要内容是如何引入Jest,以及如何兼容Cocos做测试。在单元测试本身上则没有那么详细,相关文章很多,大家可以自行了解一番~
好处就不讲了,一句话: 引入要慎重 。
单元测试的成本, 非常高 。
大部分的项目代码,可能一开始没有考虑到要做单元测试。此时引入单元测试,可能会有很多逻辑无法直接覆盖测试 ,甚至可能要做一些花样。
另外,想要做出优秀的单元测试,需要对应的逻辑支持(甚至需要重构),也需要花时间处理如何 覆盖所有路径 。
总结,接入简单,难在如何提高单测的覆盖率 。所以,还是根据实际情况,慎重做决定 。
本文项目环境:Cocos Creator 2.4.3 + JS。使用VS Code。
接下来就进入主题吧
1. Jest接入流程
1.1 初始化nodejs项目
Jest基于nodejs,所以第一步是将我们的项目初始化成nodejs项目。
在项目根目录下,命令行运行指令 npm init
即可。
此时会弹出输入提示,一直按回车确认就可以,还可以再改,问题不大。
1.2 安装Jest
运行命令行指令:
npm install --save-dev jest
npm install --save-dev @types/jest
其中第一行为Jest本体,第二行是在我们写单元测试时,提供代码提示的工具。
之后,修改nodejs默认的测试指令。
// package.json
{
"scripts": {
"test": "jest"
},
}
改完测试一下是否配置成功,运行命令行 npm test
得到提示“ No tests found, exiting with code 1 ”,即为安装成功。
但是,Jest说没有找到测试用例,这就来写一个!
1.3 简单测试用例编写
1.3.1 目录创建
我们可以在项目根目录下创建一个“ test ”目录,用来放测试相关的文件。
对应每一个要测试的代码,可以在test目录下建立相同的目录结构,并在文件名中间加上test。举例:
.
├── assets
│ ├── Tool
│ │ └── ArrayTool.js
├── test
我们想要测试ArrayTool,则建立对应的目录及文件。
.
├── assets
│ ├── Tool
│ │ └── ArrayTool.js
├── test
│ ├── Tool
│ │ └── ArrayTool.test.js
1.3.2 测试用例编写
我们有一个快速移除数组元素的辅助函数fastRemoveArrayItemAt
class ArrayTool {
static fastRemoveArrayItemAt(array, index) {
if (array) {
let length = array.length;
if (index < 0 || index >= length) {
return false;
}
array[index] = array[length - 1];
array.length = length - 1;
}
return true;
}
}
现在来编写测试用例
const ArrayTool = require("../../assets/Tool/ArrayTool");
test("测试数组移除", () => {
expect(ArrayTool.fastRemoveArrayItemAt([1,2,3], 0)).toBe(true);
expect(ArrayTool.fastRemoveArrayItemAt([1,2,3], 4)).toBe(false);
expect(ArrayTool.fastRemoveArrayItemAt([1,2,3], -1)).toBe(false);
});
使用命令行运行 npm test
绿色,Good
2. 兼容Cocos
代码中不可避免地会使用到Cocos的接口,比如Vec2、3等,但是Jest并不会帮你导入引擎代码。
此时运行测试用例会直接报错,导致这部分代码无法覆盖测试。
我们可以 手动导入 Cocos编译出来的 Web版引擎 ,来支持引擎的相关功能。
2.1 创建辅助目录
在 test目录 下,创建 test_tool 文件夹,用来放一些工具类等。当然,你也可以放在其他地方。
2.2 复制引擎代码
把 cocos2d-js-for-preview.js 复制到 test_tool 文件夹下。
注:此文件在引擎目录下,如Creator\2.4.3\resources\engine\bin\cocos2d-js-for-preview.js
2.3 创建配置文件
在项目根目录下创建 jest.config.js 文件,用来配置jest。
// jest.config.js
module.exports = {
testEnvironment: 'jsdom', // 测试代码所运行的环境
rootDir: "./test", // 测试文件所在的目录
globals: { // 全局属性。如果你的被测试的代码中有使用、定义全局变量,那你应该在这里定义全局属性
window: {},
cc: {},
},
setupFiles: [
'jest-canvas-mock', // npm 套件只需要名稱
'<rootDir>/test_tool/cocos2d-js-for-preview.js',
]
};
Cocos的运行需要Canvas,Canvas需要dom的支持。
testEnvironment 指测试的运行环境。默认为Node.js。
这里我们将其设置为 jsdom
,它可以 模拟浏览器DOM 。
setupFiles 指设置文件。可以配置一个路径数组,这些文件会设置测试环境的时候被运行。
我们使用了 jest-canvas-mock
,来 支持Cocos对Canvas的需求 。然后将 Cocos自己 写上。
需要注意,jest-canvas-mock
必须在Cocos之前 。
2.4 安装依赖项
运行命令行:
npm install --save-dev jest-canvas-mock
npm install --save-dev jest-environment-jsdom
2.5 测试接口
test("测试Cocos接口", () => {
expect(cc.js.array.remove([1,2,3], 1)).toBe(true);
expect(cc.js.array.remove([1,2,3], 4)).toBe(false);
expect(cc.js.array.remove([1,2,3], 0)).toBe(false);
});
绿色,Good
3. 兼容Component
如果你想测试一个Component,你大概会想require,new,测试,一切都很顺利。
然鹅!代码的路上并不总是一帆风顺
TypeError: XXX is not a constructor
你得到了这么一个报错
为什么呢?
我们写的组件代码中,并 没有进行导出 ,导出的代码是运行的时候Cocos帮我们加上去的。
另外,脱离了Prefab, 所有的属性,都需要手动创建一遍!!! 。
那要怎么才能实现测试呢?我们可以模仿Cocos,它能自动加导出, 我们也能 !
3.1 导出Component
3.1.1 配置transform
Jest提供了 transform 配置项,通过transform,我们可以对某些文件进行处理。Jest自带了babel-jest。
{
transform: {
'\\.js$': ['babel-jest', {
plugins: ['./test/test_tool/babel-cc-class.js'],
presets: [
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
]
}]
},
transformIgnorePatterns: [
'cocos2d-js-for-preview.js',
"/node_modules/",
],
}
transform中,我们配置对所有的js文件使用 babel-cc-class.js 进行处理。
transformIgnorePatterns 可以配置要跳过转换的文件。我们跳过Cocos引擎和node_modules。
3.1.2 实现转换逻辑
在test_tool目录下新建babel-cc-class.js文件
// babel-cc-class.js
module.exports = (babel) => {
const { types: t } = babel;
return {
visitor: {
AssignmentExpression(path) {
// 直接跳過有 module.exports 的 path
path.skip();
},
CallExpression(path, state) {
/* 判斷是不是 cc.Class 形式 */
// 檢查是呼叫某類別裡面的 member method
if (!t.isMemberExpression(path.node.callee)) return;
// 檢查是 .cc 物件
if (!t.isIdentifier(path.node.callee.object, { name: 'cc' })) return;
// 檢查是 Class
if (!t.isIdentifier(path.node.callee.property, { name: 'Class' })) return;
// 建立 MemberExpression modules.exports
const moduleExports = t.memberExpression(t.identifier('module'), t.identifier('exports'));
const assignment = t.assignmentExpression('=', moduleExports, path.node);
path.replaceWith(assignment);
// 避免 traverse children 造成 infinite loop
path.skip();
},
},
};
};
照抄即可。不要问,我也是抄的。
代码中会对代码文件进行判断,只有使用cc.Class的文件才会被转换,所以可以放心, 它不会转换你的其他代码 。
3.1.3 测试一下
这是一个我们要测试的Component:
// assets\Tool\TitleComponent.js
cc.Class({
extends: cc.Component,
properties: {
labelTitle: cc.Label
},
setTitle(title) {
this.labelTitle.string = title;
}
});
这是单元测试代码:
const TitleComponent = require("../../assets/Tool/TitleComponent");
console.log(TitleComponent);
不出意外应该顺利打印出像这样的内容了:
3.2 测试Component
test("测试Component接口", () => {
const TitleComponent = require("../../assets/Tool/TitleComponent");
cc.js.setClassName('TitleComponent', TitleComponent);
let node = new cc.Node();
let titleComponent = node.addComponent("TitleComponent");
let labelTitle = node.addComponent(cc.Label);
titleComponent.labelTitle = labelTitle;
titleComponent.setTitle("新标题");
expect(labelTitle.string).toBe("新标题");
});
是的,为了测试这个Label,你需要先手动创建一个Label,再手动把它挂上去。
虽然能实现测试的效果,但是用起来还是有些难受。如果大家有更好的测试方法,欢迎一起讨论~
4. 兼容require
Cocos提供了一个极其好用的require接口,你只需要提供类名,它会帮你自动加载对应的js文件。
很棒对不对?让我们愉快地使用它!
Cannot find module ‘XXX’ from 'tool/ArrayTool.test.js’
哎,require不能用了。现在 没!有!Cocos!了!
Cocos在运行的时候,帮你把所有的类都 注册 到了一个 运行时文件 中,当你使用require的时候,会帮你自动把类名再 转换成完整的路径 ,完成require的功能。
没有Cocos,胆子大一点,接着自己造
4.1 修改配置
这里会用到上面提到的 setupFiles 配置项。
{
setupFiles: [
'<rootDir>/test_tool/setup.js',
],
globals: {
gtest: {},
},
}
在test_tool目录下创建一个 setup.js 文件,用来处理相关的逻辑。
这里还注册了一个全局变量gtest,知道即可。
4.2 记录路径
我们模拟Cocos的操作,将目录下所有的js文件全部找出来,然后记录他们的路径。
这里我把功能提取成了一个独立的辅助类ScriptLoader,代码如下:
// scriptLoader.js
const Fs = require('fs');
const Path = require('path');
const projectName = Path.join(__dirname, "../../", "assets");
// console.log("projectName", projectName);
class ScriptLoader {
constructor() {
this.scripts = {};
this.load();
// console.log(this.scripts);
}
requireClass(name) {
return require(this.scripts[name]);
}
load() {
this.dfsFile(projectName, ".js", (path, stat) => {
let fileName = Path.basename(path);
let name = fileName.slice(0, fileName.length - 3);
this.scripts[name] = path;
});
}
dfsFile(path, suffix, handler) {
if (!Fs.existsSync(path)) return;
const stat = Fs.statSync(path);
if (stat.isDirectory()) {
const names = Fs.readdirSync(path);
for (const name of names) {
this.dfsFile(Path.join(path, name), suffix, handler);
}
} else if (stat.isFile() && Path.extname(path) === suffix) {
handler(path, stat);
}
}
}
module.exports = ScriptLoader;
入口为requireClass,通过这个接口调用即可实现和Cocos一致的require效果。
4.3 接入测试环境
// setup.js
let ScriptLoader = require("./scriptLoader");
let scriptLoader = new ScriptLoader();
scriptLoader.load();
gtest.scriptLoader = scriptLoader;
创建ScriptLoader实例。并挂到全局变量 gtest 下。
最后,将测试代码中对应的require改为 gtest.scriptLoader.requireClass 。
test("测试Require", () => {
console.log(gtest.scriptLoader.requireClass("TitleComponent"));
});
如此即可实现require
缺点是,require 不能被直接覆盖 ,我们可以间接解决这个问题:
项目中,使用一个工具函数调用require。
测试时,mock该工具函数,转而调用scriptLoader.requireClass即可。
关于require,如果你有更好的方案,也欢迎讨论~
3. 兼容项目框架
项目中常常会有一些功能,引用到了框架中的函数。这里提供两个解决方法。
-
使用Mock,直接改成你需要的样子#狗头。
-
将它们一起加载进来。
如果有幸你的框架内的基础组件都在若干文件夹内,你可以这样:
// scriptLoader.js
constructor() {
this.scripts = {};
this.load();
this.requireFrameScript();
}
requireFrameScript() {
let dir = Path.join(projectName, "frame");
this.requireScriptByDir(dir);
}
requireScriptByDir(dir) {
this.dfsFile(dir, ".js", (path) => {
require(path);
});
}
在配置运行环境的时候,就会自动将框架内的js文件统统require进来。这可以解决一部分问题。
4. 可能遇到的问题
4.1 全局变量报错
ReferenceError: xxx is not defined
你需要在 globals 配置项中配置对应的全局变量名。
4.2 库版本
建议使用和我一致的库版本,避免一些意外问题。
npm install会帮你安装最新版本,升级过后一些配置项/支持的功能可能会发生改变,不要问怎么知道的。
// package.json
"devDependencies": {
"@babel/preset-env": "^7.19.0",
"@types/jest": "^29.0.0",
"jest": "^29.0.2",
"jest-canvas-mock": "^2.4.0",
"jest-environment-jsdom": "^29.0.2"
}
4.3 TS怎么办
文末的相关链接中,有一些TS项目的说明,可以参考。
5. VSCode Jest插件
我还使用了一个Jest的同名插件,它长这样。
它提供了一个面板
你可以方便地使用它执行单个单元测试、单个文件测试等功能。
6. 相关链接
7. 结尾
文章内容有一部分参(zhao)考(chao)了相关链接中的文章。感谢各位前人的脚步。
同时欢迎大家提供更好的解决方案~