Jest单元测试框架接入及兼容Cocos方案实现

前言

最近在升级重制一些核心玩法,做完,也完整验证一遍过后,不巧有一个细节需要修改,改完之后肯定要验证呀。
但是,作为一个底层函数,完整覆盖验证,可以有十几种情况… 血压一下就上来了:triumph:
于是,原本时程靠后的单元测试搭建,提到了最前面:sunglasses:

文章主要内容是如何引入Jest,以及如何兼容Cocos做测试。在单元测试本身上则没有那么详细,相关文章很多,大家可以自行了解一番~

好处就不讲了,一句话: 引入要慎重

单元测试的成本, 非常高
大部分的项目代码,可能一开始没有考虑到要做单元测试。此时引入单元测试,可能会有很多逻辑无法直接覆盖测试 ,甚至可能要做一些花样。
另外,想要做出优秀的单元测试,需要对应的逻辑支持(甚至需要重构),也需要花时间处理如何 覆盖所有路径
总结,接入简单,难在如何提高单测的覆盖率 。所以,还是根据实际情况,慎重做决定

本文项目环境:Cocos Creator 2.4.3 + JS。使用VS Code。

接下来就进入主题吧:grinning:

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

image

得到提示“ 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

image

绿色,Good​:grin:

2. 兼容Cocos

代码中不可避免地会使用到Cocos的接口,比如Vec2、3等,但是Jest并不会帮你导入引擎代码。
此时运行测试用例会直接报错,导致这部分代码无法覆盖测试:disappointed_relieved:

我们可以 手动导入 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);
});

image
绿色,Good​:grin:

3. 兼容Component

如果你想测试一个Component,你大概会想require,new,测试,一切都很顺利。
然鹅!代码的路上并不总是一帆风顺

TypeError: XXX is not a constructor

你得到了这么一个报错:confused:

为什么呢?

我们写的组件代码中,并 没有进行导出 ,导出的代码是运行的时候Cocos帮我们加上去的。
另外,脱离了Prefab, 所有的属性,都需要手动创建一遍!!! :face_with_symbols_over_mouth:

那要怎么才能实现测试呢?我们可以模仿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();
                },
            },
        };
    };

照抄即可。不要问,我也是抄的:laughing:

代码中会对代码文件进行判断,只有使用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);

不出意外应该顺利打印出像这样的内容了:

image

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,再手动把它挂上去:tired_face:

虽然能实现测试的效果,但是用起来还是有些难受。如果大家有更好的测试方法,欢迎一起讨论~

4. 兼容require

Cocos提供了一个极其好用的require接口,你只需要提供类名,它会帮你自动加载对应的js文件。
很棒对不对?让我们愉快地使用它!

Cannot find module ‘XXX’ from 'tool/ArrayTool.test.js’

哎,require不能用了:sweat_smile:。现在 没!有!Cocos!了!

Cocos在运行的时候,帮你把所有的类都 注册 到了一个 运行时文件 中,当你使用require的时候,会帮你自动把类名再 转换成完整的路径 ,完成require的功能。

没有Cocos,胆子大一点,接着自己造:sunglasses:

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​:tada:

缺点是,require 不能被直接覆盖 ,我们可以间接解决这个问题:
项目中,使用一个工具函数调用require。
测试时,mock该工具函数,转而调用scriptLoader.requireClass即可。

关于require,如果你有更好的方案,也欢迎讨论~

3. 兼容项目框架

项目中常常会有一些功能,引用到了框架中的函数。这里提供两个解决方法。

  1. 使用Mock,直接改成你需要的样子#狗头。

  2. 将它们一起加载进来。

如果有幸你的框架内的基础组件都在若干文件夹内,你可以这样:

// 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会帮你安装最新版本,升级过后一些配置项/支持的功能可能会发生改变,不要问怎么知道的:upside_down_face:

 // 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的同名插件,它长这样。
image
它提供了一个面板

image
你可以方便地使用它执行单个单元测试、单个文件测试等功能。

6. 相关链接

CocosCreator 中单元测试入门

JEST& with cocos creator

快速开始-Jest官方文档

如何写出有效的单元测试

7. 结尾

文章内容有一部分参(zhao)考(chao)了相关链接中的文章。感谢各位前人的脚步。

同时欢迎大家提供更好的解决方案~

10赞

!太卷了!大佬666

战术马克~

大佬,非常赞呀:+1:t2::+1:t2::+1:t2::+1:t2::+1:t2:

如果搬运到 3.4 或者 3.6 的版本,那是不是就无法进行单元测试了 ?

需要做一些修改。单元测试应该跟引擎版本无关。但是需要对TS做些处理,比如里面有一些库应该改成支持TS的库。
CocosCreator 中单元测试入门
JEST& with cocos creator
上面这两篇帖子中都有都有同时提到TS的安装流程

1赞

OK,我看看先 :laughing:

请问资源加载这一块怎么做呢,例如我要加载一些json配置

amazing

大佬,跪求3.x版本对应的 cocos2d-js-for-preview.js在哪里找

其实这些应该官方提供的

2赞