从 Jest 继承对象测试结果不符合预期发现的 ts 与 es2022 之间的冲突
今天把我的请求封装库重构了一遍,因为用到了 typescript
的面向对象类继承的方式,然后发现了一个在 jest
ts-jest
中 比较少见且不符合预期的测试用例。
案例
该案例简化后如下所示
class Super {
a!: number;
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
it('right', () => {
class Sub extends Super {
b!: number;
constructor() {
super();
this.b = 2;
}
}
const s = new Sub();
expect(s).toEqual({ a: 1, b: 2 }); // {a:1, b:2}
});
it('error', () => {
class Sub extends Super {
public b!: number;
init() {
super.init();
this.b = 2; // expect 2 but got undefined
}
}
const s = new Sub();
expect(s).toEqual({ a: 1, b: 2 }); // expect {a:1, b:2} but got {a:1, b:undefined}
});
在 error
这个测试用例里面期待得到{a:1, b:2}
,不过测试用例并没有通过,获得的是{a:1, b:undefined}
。
这个问题就很奇怪了。
猜测
然后我推断它可能是 ts
编译导致的问题。
该 ts
类编译后是这样的
'use strict';
class Super {
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
class Sub extends Super {
init() {
super.init();
this.b = 2; // expect 2 but got undefined
}
}
console.log(new Sub()); // {a:1, b:2}
运行后的结果是{a:1, b:2}
。
就算设置 tsconfig
的 target
设置为 es5
'use strict';
var __extends =
(this && this.__extends) ||
(function () {
var extendStatics = function (d, b) {
extendStatics =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (d, b) {
d.__proto__ = b;
}) ||
function (d, b) {
for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p];
};
return extendStatics(d, b);
};
return function (d, b) {
if (typeof b !== 'function' && b !== null)
throw new TypeError('Class extends value ' + String(b) + ' is not a constructor or null');
extendStatics(d, b);
function __() {
this.constructor = d;
}
d.prototype = b === null ? Object.create(b) : ((__.prototype = b.prototype), new __());
};
})();
var Super = /** @class */ (function () {
function Super() {
this.init();
}
Super.prototype.init = function () {
this.a = 1;
};
return Super;
})();
var Sub = /** @class */ (function (_super) {
__extends(Sub, _super);
function Sub() {
return (_super !== null && _super.apply(this, arguments)) || this;
}
Sub.prototype.init = function () {
_super.prototype.init.call(this);
this.b = 2; // expect 2 but got undefined
};
return Sub;
})(Super);
console.log(new Sub()); // {a:1, b:2}
运行后的结果也是{a:1, b:2}
。
然后猜测是 babel
的问题,使用 babel
编译后虽然代码不尽相同,
但是运行后的结果也是{a:1, b:2}
。
这就很没脾气了。
搜索答案
在以上尝试未果后,果断尝试了询问群友,以及在搜索引擎搜索问题。
然而万能的群友不会,搜索引擎也不行,搜索出来的问题都不对。
然后在 ts-jest
的 github
仓库里的 issue
和 Discussions
一番搜索,也没找到答案。
查找文档
在各种地方都搜索不到答案后去看了 ts-jest
的 github
源码,然后看到里面有一个 example
,我就有点想对比一下官方的配置与我的有何不同。
打开examples/ts-only/jest-esm.config.js
文件,发现对方是这么写的:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest/presets/default-esm',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig-esm.json',
useESM: true,
},
],
},
};
而我的jest.config.js
是这样的:
module.exports = {
- preset: 'ts-jest/presets/default-esm',
- transform: {
- '^.+\\.tsx?$': [
- 'ts-jest',
- {
- tsconfig: 'tsconfig-esm.json',
- useESM: true,
- },
- ],
- },
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest',
+ },
}
我的tsconfig
是用的默认的也就是tsconfig.json
,而ts-jest
官方的是指定的某个配置文件。
我的 tsconfig 配置如下
{
"compilerOptions": {
"baseUrl": ".",
"lib": ["esnext", "dom"],
"target": "esnext",
"sourceMap": true,
"allowJs": true,
"moduleResolution": "node",
"declaration": true,
"forceConsistentCasingInFileNames": false,
"noImplicitReturns": true,
"noImplicitThis": false,
"noImplicitAny": false,
"importHelpers": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true,
"experimentalDecorators": true,
"esModuleInterop": true,
"strict": true
},
"include": ["src", "__test__"]
}
然后我把官方的配置复制到我的配置文件,把 tsconfig
改为tsconfig.build.json
(我的项目上的打包配置)
jest.config.js
:
module.exports = {
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.build.json',
useESM: true,
},
],
},
};
tsconfig.build.json
:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"target": "es2017",
"outDir": "dist",
"declaration": true,
"declarationDir": "types",
"sourceMap": false
},
"include": ["src"]
}
这样一番改动后,发现测试果然就通过了。
定位问题
使用排除法后发现**问题是tsconfig
导致的**,与preset
和useESM
没什么关系。
然后再在tsconfig.build.json
里一番二分注释查找,
最终把**问题定位到了 tsconfig
配置文件上 的 target
选项上**。
原来 ts-jest
需要的 target
不能是 esnext
或es2022
。
而刚好我的tsconfig.json
里设置的就是esnext
。
从 ts-jest
文档ESM Support | ts-jest (kulshekhar.github.io)
Starting from v28.0.0, ts-jest will gradually switch to esbuild/swc to transform ts to js. To make the transition smoothly, we introduce legacy presets as a fallback when the new codes don't work yet.
可以看出,它的 ts 转 js 工具默认用的是esbuild/swc
然后使用这两种打包工具打包排查,发现最终是 esbuild
的锅,esbuild
会把 ts
代码编译成以下 js
代码
var Super = class {
a;
constructor() {
this.init();
}
init() {
this.a = 1;
}
};
var Sub = class extends Super {
b;
init() {
super.init();
this.b = 2;
}
};
console.log(new Sub()); // {a: 1, b: undefined}
从默认的 tsconfig 中的target: "exnext"
可以发现这是 es2022
的特性:类字段可以在类的顶层被定义和初始化
es2022
- 声明类的字段:类字段可以在类的顶层被定义和初始化
- ...
在此之前 js 中是不能像下面这样写的(es2022)
class Super {
a = 1;
b = 2;
}
必须写成这样(es2015 - es2021)
class Super {
constructor() {
this.a = 1;
this.b = 2;
}
}
但是在 ts 中,这种写法(类字段可以在类的顶层被定义和初始化)早就用上了。
所以就导致了两种编译结果:
在 ts es2022 以前的 target 中,会编译成 es2015-es2021 那种声明方式;
在 ts target es2022 中,会编译成 es2022 那种声明方式。
这就导致了问题的出现。
swc
和 esbuild
两家编译后的代码也不完全一样,
swc
在成员变量初始值为 undefined
的时候并不会把成员变量移动到顶层:
初始变量为 undefined
ts
class Super {
a!: number;
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
class Sub extends Super {
public b!: number;
init() {
super.init();
this.b = 2; // expect 2 but got undefined
}
}
console.log(new Sub());
js
class Super {
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
class Sub extends Super {
init() {
super.init();
this.b = 2;
}
}
console.log(new Sub()); // {a:1, b:2}
初始变量为非 undefined
ts
class Super {
a!: number = 3;
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
class Sub extends Super {
public b!: number = 5;
init() {
super.init();
this.b = 2; // expect 2 but got undefined
}
}
console.log(new Sub());
js
class Super {
a = 3;
constructor() {
this.init();
}
init() {
this.a = 1;
}
}
class Sub extends Super {
b = 5;
init() {
super.init();
this.b = 2;
}
}
const s = new Sub();
console.log(s); // {a: 1, b: 5}
s.init();
console.log(s); // {a: 1, b: 2}
该例子也是不符合预期的,你可能会认为实例是{a: 1, b: 2
}然而实际是{a: 1, b: 5}
。
从中可以看出:你只能在 constructor
里设置成员变量,无法在 constructor
里调用其他方法更新成员的变量,
constructor
调用其他方法更新了成员变量后会再次被覆盖,变为默认的成员变量;
不过如果你在外面再次调用方法更新还是能更新成功的。
总之在 class
初始化时你只能在 constructor
里设置成员变量,实例化以后你怎么改都行。
总结
原因:
在 es2022
以前是不能在 class 顶层声明成员变量的,
所以那时候的 ts
class 的成员变量编译后也只是在 constructor
里设置值,
但 es2022
有了顶层成员变量后,ts
class
成员变量编译后是直接设置了 es2022
的成员变量。
由于 js
es2022的
class
成员变量 class
初始化时只能在 constructor
里设置成员变量 ,导致的问题.
最后是 es2022 这种方式的执行顺序导致问题的出现,例子
class Super {
a = (function () {
console.log('a');
return 3;
})();
constructor() {
console.log('constructor');
this.init();
}
init() {
console.log('super init');
this.a = 1;
}
}
class Sub extends Super {
b = (function () {
console.log('b');
return 5;
})();
init() {
super.init();
console.log('sub init');
this.b = 2;
}
}
const s = new Sub();
// --- outputs ---
// a
// constructor
// super init
// sub init
// b
从上面代码的输出结果可以看出:成员变量 b
是最后执行的,覆盖了 Sub
内 init
方法所设置的值;这就导致了问题的出现。
解决方案:
把 target
设置为 es2022
以下就不会有这种问题了。
用最新的功能还是需要谨慎一点。
评论