从Jest继承对象测试结果不符合预期发现的ts与es2022之间的冲突

169 阅读5分钟

前言

今天把我的请求封装库重构了一遍,因为用到了 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 bug 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 bug got undefined
  }
}

console.log(new Sub()); // {a:1, b:2}

运行后的结果是{a:1, b:2}

就算设置 tsconfigtarget 设置为 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 bug got undefined
  };
  return Sub;
})(Super);

console.log(new Sub()); // {a:1, b:2}

运行后的结果也是{a:1, b:2}

然后猜测是 babel 的问题,使用 babel 编译后虽然代码不尽相同,

但是运行后的结果也是{a:1, b:2}

这就很没脾气了。

搜索答案

在以上尝试未果后,果断尝试了询问群友,以及在搜索引擎搜索问题。

然而万能的群友不会,搜索引擎也不行,搜索出来的问题都不对。

然后在 ts-jestgithub 仓库里的 issueDiscussions一番搜索,也没找到答案。

查找文档

在各种地方都搜索不到答案后去看了 ts-jestgithub 源码,然后看到里面有一个 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导致的,与presetuseESM没什么关系。

然后再在tsconfig.build.json里一番二分注释查找,

最终把问题定位到了 tsconfig配置文件上 的 target 选项上

原来 ts-jest 需要的 target 不能是 esnextes2022

而刚好我的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

  1. 声明类的字段:类字段可以在类的顶层被定义和初始化
  2. ...

es2015-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 那种声明方式。

这就导致了问题的出现。

swcesbuild 两家编译后的代码也不完全一样,

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 bug 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 bug 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 里设置成员变量**,导致的问题

解决方案:

target 设置为 es2022 以下就不会有这种问题了。

用最新的功能还是需要谨慎一点。