- 投稿日:
ローカルで動作するNode.jsのライブラリ(node_modules
)欲しくないですか?欲しいですよね?という訳で作ってみました。
要件としてはTypeScriptで実装出来、Jestでテスト可能で、ESLintでLintが可能というところです。
確認環境
Env | Ver |
---|---|
@swc/cli | 0.1.62 |
@swc/core | 1.3.93 |
@swc/jest | 0.2.29 |
@types/jest | 29.5.5 |
@types/node | 20.8.6 |
@typescript-eslint/eslint-plugin | 6.7.5 |
@typescript-eslint/parser | 6.7.5 |
eslint | 8.51.0 |
eslint-config-prettier | 9.0.0 |
eslint-plugin-jest | 27.4.2 |
jest | 29.7.0 |
jest-watch-typeahead | 2.2.2 |
prettier | 3.0.3 |
ts-jest | 29.1.1 |
typescript | 5.2.2 |
Node.js | 20.8.0 |
npm | 10.1.0 |
サンプル実装
https://github.com/Lycolia/ts-library-example
実装について
フォルダ構成
monorepoでmainからlibrary/singleやlibrary/multi/hoge, piyoを参照するような構成です。
root
└─packages
├─library // ライブラリ側
│ └─packages
│ ├─multi // 複数ファイルのライブラリ
│ │ └─src
│ │ └─utils
│ │ ├─hoge
│ │ └─piyo
│ └─single // 単一ファイルのライブラリ
│ └─src
└─main // ライブラリを使う側
└─src
└─libs
実装時のポイント
これが全てというわけではないと思いますが、一旦今回作ったもののポイントを解説していきます。利用側がtsc
を利用しない場合、ライブラリ側と利用側で構成は別々になります。
またこの実装はエディタにVSCodeを利用し、import
するパスが相対パスであることを前提に説明しています。
ライブラリ側のポイント
ビルドに関して
まずビルドに関してはtsc
でやります。
これはTypeScriptで開発するためにはビルド成果物として.d.ts
ファイルが必要になるのと、Jestを通すためにビルド成果物がCommonJS形式(以下CJS)である必要があるためです。CJSが吐けるなら何でもいいとは思いますが、.d.ts
も必要になるので、tsc
を使うのが無難な選択肢だと思います。
テストファイルを出力したくないので、tsconfig.json
はビルドと開発で分けます。
またtsconfig.json
は各ワークスペースのルートに置いて置く必要があります。これはTypeScriptがtsconfig.json
のパスを起点として動作するためです。
ビルド用の設定ポイントとしては以下の通りです。
"compilerOptions"
"module": "NodeNext"
- これがないとパス解決が上手く行きません
"moduleResolution": "NodeNext"
- これがないとパス解決が上手く行きません
"declaration": true,
.d.ts
の出力に使います
"outDir": "./dist",
- ビルド成果物の出力先です
"exclude": ["src/**/*.spec.ts"]
- ビルド時にテストファイルは不要なので無視します
"include": ["src/**/*"]
- 参照するソースコードです
Jestに関して
ビルドにtsc
を使うため、Jestのローダーとしてもts-jestを利用します。公式ではbabelが推奨されているようですが、構成方法が不明だったので諦めました。BabelはJest公式の案内通りにやっても多分上手く行きません。
jest.config.js
には以下の設定を追加します。
preset: 'ts-jest'
開発用の設定について
開発とビルドでtsconfig.json
を分けるので、開発用のも必要です。これはビルド用から"exclude": ["src/**/*.spec.ts"]
を抜くだけです。
ライブラリを外部参照させる方法
基本的にはpackage.json
に外部参照させるための定義を書くことによって行います。
この辺りの仕様はdocs.npmjs.comにはなく、nodejs.orgにあります。
単一ファイルの外部参照
単一ファイルを外部参照(import出来るように)するときに使う手法です。
import
をimport { hoge } from '@my-lib/example'
みたいな書き方をしたい時に必要になるやつです。
以下の様にビルド成果物の.js
のパスをpackage.json
に追加してやると出来るようになります。
"main": "./dist/index.js",
以下の書き方でも同様に可能です。
"exports": {
".": {
"default": "./dist/index.js"
}
},
上記には他にtypes
というフィールドがあり、本来ここに.d.ts
を追加するのですが、TypeScriptが解決してくれるので、なくても動きます。一応ない場合はファイル探索を行うようなので、あった方が少しだけパフォーマンスが上がるかもしれません。
複数ファイルの外部参照
複数ファイルを外部参照(import出来るように)するときに使う手法です。
基本的に何もしなくてよいですが、import { hoge } from '@my-lib/example'
みたいな書き方もしたい場合は以下の記述が必要です。
"main": "./dist/core/index.js"
ここで指定していないものはimport { hoge } from '@my-lic/example/dist/hoge'
みたいにして参照します。dist
がダサくて嫌な場合は適当な名前に変えます。
参考までに@actions/githubはバージョン6.0.0時点でdist
に相当する部分をlib
にしており、import { Context } from '@actions/github/lib/context';
の様にして参照するようになっています。
exports
を使ってdist
を隠すことも出来るとは思うのですが面倒なので試してません。
ライブラリ側を利用する側のポイント
今回利用する側はビルドにswcを使う想定ですが、たぶんなんでも動くと思います。参考までにswc-loader + webpackでも動きました。
開発用の設定について
tsconfig.json
に以下の設定があれば恐らく最低限大丈夫だと思います。
"compilerOptions"
"module": "NodeNext"
- これがないとパス解決が上手く行きません
"moduleResolution": "NodeNext"
- これがないとパス解決が上手く行きません
"include": ["src/**/*"]
- 参照するソースコードです
ライブラリのインストール方法について
以下のように相対パスを指定するとインストールできます。
npm i ../package/library/package/single
消すときはパッケージ名を指定すれば消せます。
npm un @lycolia/library-example-single
試したけどダメだったやつら
色々してる過程で試行錯誤した名残。
ライブラリ側のJestでbabelを使おうとやったこと
以下の二通りの設定は試しましたが、どっちもダメだったので諦めました。何よりtscを使うならts-jestの方が楽なのは確定的に明らかですし…。
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
presets: [
['@babel/preset-env', {targets: {node: 'current'}, modules: 'commonjs'}],
'@babel/preset-typescript',
],
ライブラリ側をswcでビルド
.swcrc
を以下の設定にしても
"module": {
"type": "commonjs"
},
以下の出力がされるため、jest.spyOn()
が上手く動かない(get
が邪魔でhello
が見れない
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
Object.defineProperty(exports, "hello", {
enumerable: true,
get: function() {
return hello;
}
});
const hello = (param)=>{
console.log(param);
};
どの道これでは.d.ts
が出せないので、あんま意味ないなと…。
参考にしたもの
実装方法は@actions/githubが一番単純で参考になると思います。
- 仕様
- 実装
- Jest周り
実装はビルド成果物とビルド前のコード、package.json
やtsconfig.json
辺りがどうなっているのかを参考にしました。
- 投稿日:
ESM化が叫ばれて久しいですが、未だにJestはESMとCJSが混在したコードを処理してくれません。
Getting StartedにもSWCに対する言及がないので、きっともう忘れられているのでしょう。swc-project/jestの方も特にやる気はなさそうだし、やりたければ自分でPR書きましょうって感じだと思います。きっと。
確認環境
node_modules配下にESMで作られたモジュールが存在し、コードはTypeScript、トランスパイルにはSWCを利用する。
Env | Ver |
---|---|
@swc/cli | 0.1.62 |
@swc/core | 1.3.92 |
@swc/jest | 0.2.29 |
@swc/register | 0.1.10 |
jest | 29.7.0 |
typescript | 5.2.2 |
やったけど意味がなかったこと
package.json
のtype
をmodule
にするjest.config.js
のtransformIgnorePatterns
にESMモジュールのパスだけ除外する設定を書く- 上記に加えて
transform.jsc.path
にpkg-name: ['node_modules/pkg-name']
を追記する node --experimental-vm-modules node_modules/jest/bin/jest.js
で実行する- 多少マシになったがコケるものはコケる
- ESMで書かれたモジュールを丸ごとモックする
- 一切効果なし
所感
多分Webpackでバンドルしてnode_modules
の中身も外も関係ない状態にするのが一番無難なのではないかと思いました。
Node.jsの組み込みテストランナーにすれば解決するかな?と思ったものの、こちらは現状SWCでは使えそうにないので諦めました。
参考までに以下のコマンドで走らさせられます。
node --require @swc/register --test ./src/**/*.spec.ts
取り敢えずESMに引っかったモジュールはCJS時のバージョンを維持しておくことにしましたが、このままだとSWC使えないし、なんとかなって欲しいですね。Webpack使えば解決できるのはわかるんですが、このために使いたくないので、テストを重視する場合、Vitestを持つViteが有力候補になって来そうです。
2024-02-17追記
esbuild + Node.js built-in test runnerの組み合わせであればテストはできるが肝心の実行ができず無意味だった
- 投稿日:
本記事ではTypeScriptとSWC、Jestの組み合わせ環境でjest.spyOn
を使う方法をexport
パターン別に書いていきます。また、jest.spyOn
対象の実装の詳細はテストせず、モック化したインターフェースのテストのみを行う前提で書いています。
確認環境
Env | Ver |
---|---|
Node.js | 18.14.0 |
typescript | 4.9.5 |
@swc/cli | 0.1.62 |
@swc/core | 1.3.36 |
@swc/jest | 0.2.24 |
jest | 29.4.3 |
サンプルコード
全編は以下にあります
jest.spyOn pattern example for SWC + TypeScript
親関数から子関数を呼び出すパターンで、子関数のjest.spyOn
をするシチュエーションで考えます。
default export編
以下のdefaultエクスポートをjest.spyOn
する方法です。
child.ts
export const child = (param: string) => {
return param;
};
parent.ts
import child from 'src/default/child';
const parent = (param: string) => {
return child(param);
};
export const Parent = {
parent,
};
parent.spec.ts
この形式の場合import
側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOn
するのには向いていません。
import * as child from 'src/default/child';
import { Parent } from 'src/default/parent';
jest.mock('src/default/child');
describe('default', () => {
it('called by child function', () => {
const spiedChild = jest.spyOn(child, 'default');
Parent.parent('foo');
expect(spiedChild).toHaveBeenCalledWith('foo');
});
});
named export編
以下の名前付きエクスポートをjest.spyOn
する方法です。
child.ts
export const child = (param: string) => {
return param;
};
parent.ts
import { child } from 'src/named-export/child';
export const parent = (param: string) => {
return child(param);
};
parent.spec.ts
default exportと同じくimport
側では書き換え不能なのでファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOn
するのには向いていません。
import * as child from 'src/named-export/child';
import { parent } from 'src/named-export/parent';
jest.mock('src/named-export/child');
describe('function', () => {
it('called by child function', () => {
const spiedChild = jest.spyOn(child, 'child');
parent('foo');
expect(spiedChild).toHaveBeenCalledWith('foo');
});
});
namespace export編
やり方はnamed export編と同じです。
child.ts
export namespace Child {
export const child = (param: string) => {
return param;
};
}
parent.ts
import { Child } from 'src/namespace/child';
export namespace Parent {
export const parent = (param: string) => {
return Child.child(param);
};
}
parent.spec.ts
namespaceの場合、トランスパイル後にクロージャになり、書き換え以前に関数にアクセスできなくなるため、ファイルごとモックにして解決します。
このやり方はファイルごとモックするため、同じファイルにある関数をjest.spyOn
するのには向いていません。
import { Child } from 'src/namespace/child';
import { Parent } from 'src/namespace/parent';
jest.mock('src/namespace/child');
describe('namespace', () => {
it('called by child function', () => {
const spiedChild = jest.spyOn(Child, 'child');
Parent.parent('foo');
expect(spiedChild).toHaveBeenCalledWith('foo');
});
});
module export編
これだけやり方が変わります。
child.ts
const child = (param: string) => {
return param;
};
export const Child = {
child,
};
parent.ts
import { Child } from 'src/module/child';
const parent = (param: string) => {
return Child.child(param);
};
export const Parent = {
parent,
};
parent.spec.ts
このケースの場合オブジェクトをexport
していて書き換えができるため、モック化せずにそのままjest.spyOn
することが出来ます。
このやり方であれば、ファイルのモックはしないため、同じファイルにある関数をjest.spyOn
する事ができますが、裏を返せばオブジェクトの書き換えが可能であるため、実装方法によっては予期せぬ不具合が生まれる可能性があり、危険です。
何かしらの悪意ある攻撃を受けた場合にオブジェクトがすり替わるなどすると致命的だと思います。
import { Child } from 'src/module/child';
import { Parent } from 'src/module/parent';
describe('module', () => {
it('called by child function', () => {
const spiedChild = jest.spyOn(Child, 'child');
Parent.parent('foo');
expect(spiedChild).toHaveBeenCalledWith('foo');
});
});
おまけ:それぞれのトランスパイル結果
.spec.js
は省いてます
default export編
child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'default', {
enumerable: true,
get: () => _default,
});
const child = (param) => {
return param;
};
const _default = child;
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'Parent', {
enumerable: true,
get: () => Parent,
});
const _child = /*#__PURE__*/ _interopRequireDefault(require('./child'));
function _interopRequireDefault(obj) {
return obj && obj.__esModule
? obj
: {
default: obj,
};
}
const parent = (param) => {
return (0, _child.default)(param);
};
const Parent = {
parent,
};
named export編
child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'child', {
enumerable: true,
get: () => child,
});
const child = (param) => {
return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'parent', {
enumerable: true,
get: () => parent,
});
const _child = require('./child');
const parent = (param) => {
return (0, _child.child)(param);
};
namespace export編
child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'child', {
enumerable: true,
get: () => child,
});
const child = (param) => {
return param;
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'Parent', {
enumerable: true,
get: () => Parent,
});
const _child = require('./child');
var Parent;
(function (Parent) {
var parent = (Parent.parent = (param) => {
return _child.Child.child(param);
});
})(Parent || (Parent = {}));
module export編
child.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'Child', {
enumerable: true,
get: () => Child,
});
const child = (param) => {
return param;
};
const Child = {
child,
};
parent.js
'use strict';
Object.defineProperty(exports, '__esModule', {
value: true,
});
Object.defineProperty(exports, 'Parent', {
enumerable: true,
get: () => Parent,
});
const _child = require('./child');
const parent = (param) => {
return _child.Child.child(param);
};
const Parent = {
parent,
};
この記事を書いた切っ掛け
一言でいうとTSC(TypeScript Compiler) からSWCに移行した際にjest.spyOn
を使ったら、上手く使えずにハマったためです。
まずTypeScript + JestではTypeScriptをCJS(CommonJS) にトランスパイルするためjest.spyOn
が有効でした。しかし、SWCはTypeScriptをESM(ES Modules) にトランスパイルします。ESMではjest.spyOn
が有効になりません。
これはCJSではexport
したObjectの書き換えが可能なのに対し、ESMでは出来ないためです。(jest.spyOn
は実行時にオブジェクトを書き換えることで動いています)
この対策としてファイルそのものをモックに置き換えることで、jestの管理下に起き、自由に書き換えられるようにするのが本記事のアプローチです。以前と異なり、呼び出している関数の実装の詳細を見ることはできなくなりましたが、これは単体テストの観点としては正しいため、ある意味本来の単体テストになったとも取れると考えています。(モックに元の処理を注入することで今まで通り実装の詳細を確認することも出来る可能性がありますが、確認してません)