- 投稿日:
Node.jsのスクリプトを適当に書き次のようなWorkflowsを記述することで実行できる。勿論Dockerイメージを作ってそこから立ち上げることもできるので好みの問題。直に呼べる分、取り回しは楽だと思う
name: hoge
description: hoge
inputs:
GITHUB_TOKEN:
description: 'Workflowsを実行するリポジトリのGITHUB_TOKEN'
required: true
on:
workflow_call:
runs:
using: node20
main: dist/index.js
inputs:
やoutputs:
を記述すれば入出力の引数も定義できる。
inputs.GITHUB_TOKEN
について
これを書いておくとoctokitを使ってGitHubのAPIを叩けるようになる。普通は何かAPIを叩くはずなので書いておいた方が良い。
以下は一例。
const github = require('@actions/github');
const core = require('@actions/core');
async function run() {
const token = core.getInput('GITHUB_TOKEN');
const octokit = github.getOctokit(token);
const { data: pullRequest } = await octokit.rest.pulls.get({
owner: 'octokit',
repo: 'rest.js',
pull_number: 123,
mediaType: {
format: 'diff'
}
});
console.log(pullRequest);
}
参考
- Metadata syntax for GitHub Actions (docs.github.com)
- @actions/github
- 投稿日:
サーバーサイドでMarkedを使いたかったので。Node.js 20系はC++やclangのバージョンが古くエラーになると思われるので18系にした
前提
- さくらのレンタルサーバースタンダードプラン
- zshを利用している
手順
所要時間はさくらのレンタルサーバースタンダードプランで2時間丁度くらい
- nodebrewを落としてくる
wget git.io/nodebrew
.zshrc
にパスを通すPATH=${HOME}/.nodebrew/current/bin:${PATH}
コンパイルして使えるようにする
perl nodebrew setup
## バージョン確認
# nodebrew ls-remote
## プロセスが殺されるのを防ぐためにniceで優先度を下げている
## コンパイルには2h程かかる
nice -n 20 nodebrew compile v18.19.0
nodebrew use v18.19.0
試したけどうまくいかなかったやつ
nvm
nice
が効かないのでプロセスキルされて終わる
フルスクラッチコンパイル
OpenSSL周りに何か問題があるらしくコンパイルがこける。バージョンが合ってないのかも
# OpenSSL
wget https://www.openssl.org/source/openssl-1.1.1w.tar.gz
tar -zxvf openssl-1.1.1w.tar.gz
cd openssl-1.1.1w
./config --prefix=/home/$USERNAME/openssl --openssldir=/home/$USERNAME/local/openssl
make
make install
# Node.js
wget https://nodejs.org/dist/v18.9.1/node-v18.9.1.tar.gz
tar -zxvf node-v18.9.1.tar.gz
cd node-v18.9.1
./configure --shared-openssl --shared-openssl-includes=/home/$USERNAME/openssl/include/
export LD_LIBRARY_PATH=/home/$USERNAME/openssl/lib
make install DESTDIR=/home/$USERNAME/local PREFIX=
所感
Node.jsなので負荷が心配だったが、Markedでブログ記事をMarkdownからHTMLに落とす程度なら問題なく動いて安心した。
- 投稿日:
JestからNode.js組み込みのテストランナーに移行する時の方法や注意点をまとめたメモ
- 確認環境
- Node.jsの組み込みテストランナーとは
- Jest関数との対応リスト
describe(name, fn)
,it(name, fn, timeout?)
,test(name, fn, timeout?)
beforeAll(fn, timeout?)
,afterAll(fn, timeout?)
,beforeEach(fn, timeout?)
,afterEach(fn, timeout?)
.toBe(value)
,.not.toBe(value)
.toStrictEqual(value)
,.not.toStrictEqual(value)
.toHaveLength(number)
,.toBeNull()
,.toBeUndefined()
,.toBeNaN()
.toBeTruthy()
.toBeInstanceOf(Class)
.toThrow
,not.toThrow
expect.arrayContaining(array)
,expect.objectContaining(object)
,expect.stringContaining(string)
jest.spyOn(object, methodName, accessType?)
.toHaveBeenCalled()
,.toHaveBeenCalledTimes(number)
.toHaveBeenCalledWith(arg1, arg2, ...)
jest.fn(implementation?)
jest.useFakeTimers(fakeTimersConfig?)
,jest.runAllTimers()
jest.useRealTimers()
mockFn.mockClear()
mockFn.mockReset()
- モック機能についての備考
確認環境
Env | ver |
---|---|
Node.js | 20.8.0 |
Jest | 29.7 |
Node.jsの組み込みテストランナーとは
英語圏ではNode.js built-in test runnerとか呼ばれている存在で、Node.jsの組み込みテストランナーのこと。恐らくDenoのテストランナーに対抗して生まれた気配がする(Node.jsはDenoに機能追加があると真似する傾向があるため)
Node.jsの組み込みテストランナーは機能が二分されており、テストランナーとアサートに分かれている。Jestみたいにエコシステムが発達しておらず、標準ではdescribe
などはimportする必要がある。TypeScriptをテストする場合は間にレジスター[^1]を嚙ます必要がある。噛まし方はswc/jestでESM, CJS混在のコードをJestを使ってテストする有力な方法は今のところ多分ないに書いた。
Jest関数との対応リスト
describe, it, beforeAll, afterAll, beforeEach, afterEach辺りは違和感がなかったが、それ以外は軒並み互換性がないので大きく書き換えが必要だと感じた。便利なMatcherは完膚なきまでに全滅している。お陰で覚えることが減ったのは逆に良くなったと感じる
なお、not始まりの機能は実際の動作を確認しておらず、Jestと同じ機能かどうかは確認していないことに留意すること
describe(name, fn)
, it(name, fn, timeout?)
, test(name, fn, timeout?)
Node.jsのTest機能では、以下となる
describe([name][, options][, fn])
it([name][, options][, fn])
test([name][, options][, fn])
基本的な差はないがJestにあったtimeout
引数はNode.jsではoptions
パラメーターで設定するようになっている模様。今回調べるまで存在自体を知らなかったのもあり、このtimeout
がJestと同じ機能かどうかは確認していない
import { describe, it } from 'node:test';
describe('hoge', () => {
it('hoge', () => {
// ここにテストコード
});
it.todo('piyo');
});
beforeAll(fn, timeout?)
, afterAll(fn, timeout?)
, beforeEach(fn, timeout?)
, afterEach(fn, timeout?)
Node.jsのTest機能では、以下となる
before([fn][, options])
after([fn][, options])
beforeEach([fn][, options])
afterEach([fn][, options])
基本的な差はないがJestにあったtimeout
引数はNode.jsではoptions
パラメーターで設定するようになっている模様。今回調べるまで存在自体を知らなかったのもあり、このtimeout
がJestと同じ機能かどうかは確認していない
+import { after, before, beforeEach, afterEach, describe } from 'node:test';
+
describe('test', () => {
- beforeAll(() => {
+ before(() => {
console.log('before');
});
beforeEach(() => {
console.log('beforeEach');
});
afterEach(() => {
console.log('afterEach');
});
- afterAll(() => {
+ after(() => {
console.log('after');
});
});
.toBe(value)
, .not.toBe(value)
Node.jsのTest機能では、以下となる
assert.strictEqual(actual, expected[, message])
assert.notStrictEqual(actual, expected[, message])
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
const actual = 123;
- expect(actual).toBe(123);
+ assert.deepStrictEqual(actual, 123);
});
});
.toStrictEqual(value)
, .not.toStrictEqual(value)
Node.jsのTest機能では、以下となる
assert.deepStrictEqual(actual, expected[, message])
assert.notDeepStrictEqual(actual, expected[, message])
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
const actual = {
hoge: {
piyo: {
id: 1,
value: 'one'
}
},
fuga: [[123, 456], 'ABC', 'EFG']
};
- expect(actual).toStrictEqual({
+ assert.deepStrictEqual(actual, {
hoge: {
piyo: {
id: 1,
value: 'one'
}
},
fuga: [[123, 456], 'ABC', 'EFG']
});
});
});
.toHaveLength(number)
, .toBeNull()
, .toBeUndefined()
, .toBeNaN()
Node.jsのTest機能では、assert.strictEqual()
となる
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
const arr = [1, 2, 3];
const undef = undefined;
const nil = null;
const nan = NaN;
- expect(arr).toHaveLength(3);
- expect(undef).toBeUndefined();
- expect(nil).toBeNull();
- expect(nan).toBeNaN();
+ assert.strictEqual(arr.length, 3);
+ assert.strictEqual(undef, undefined);
+ assert.strictEqual(nil, null);
+ assert.strictEqual(nan, NaN);
});
});
.toBeTruthy()
Node.jsのTest機能では、以下となる
assert.ok(value[, message])
否定版は不明
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
- expect(true).toBeTruthy();
- expect({}).toBeTruthy();
- expect([]).toBeTruthy();
- expect(42).toBeTruthy();
- expect('0').toBeTruthy();
- expect('false').toBeTruthy();
- expect(new Date()).toBeTruthy();
- expect(-42).toBeTruthy();
- expect(12n).toBeTruthy();
- expect(3.14).toBeTruthy();
- expect(Infinity).toBeTruthy();
- expect(-Infinity).toBeTruthy();
+ assert.ok(true);
+ assert.ok({});
+ assert.ok([]);
+ assert.ok(42);
+ assert.ok('0');
+ assert.ok('false');
+ assert.ok(new Date());
+ assert.ok(-42);
+ assert.ok(12n);
+ assert.ok(3.14);
+ assert.ok(Infinity);
+ assert.ok(-Infinity);
});
});
.toBeInstanceOf(Class)
Node.jsのTest機能では、assert.ok()
となる
結果がTruthyであればなんでもpassするので注意
+import { describe, it } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
const actual = new Error('hoge');
- expect(actual).toBeInstanceOf(Error);
+ assert.ok(actual instanceof Error);
});
});
.toThrow
, not.toThrow
Node.jsのTest機能では、以下となる
assert.throws(fn[, error][, message])
assert.doesNotThrow(fn[, error][, message])
Jestより便利になっており、Error型以外も扱えるので後述する
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
const err = new Error('test');
- expect(() => {
+ assert.throws(() => {
throw err;
- }).toThrow(err);
+ }, err);
});
});
Node.jsではJestと異なりError型以外も扱える
Jestの`.toThrow()
はError型以外を扱うことができず、オブジェクトをThrowするようなコードではエラーになる
describe('test', () => {
it('test', () => {
const obj = { id: 1, value: 'hoge' };
// このテストは失敗する。またTypeScriptの型エラーにもなる
expect(() => {
throw obj;
}).toThrow(obj);
// このテストは失敗する。またTypeScriptの型エラーにもなる
expect(() => {
throw obj;
}).toThrow({ id: 1, value: 'hoge' });
});
});
しかしNode.jsであればこれはエラーにならない。例えば次のテストコードは成功する
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
describe('test', () => {
it('test', () => {
const obj = { id: 1, value: 'hoge' };
assert.throws(() => {
throw obj;
}, obj);
assert.throws(
() => {
throw obj;
},
{ id: 1, value: 'hoge' }
);
});
});
expect.arrayContaining(array)
, expect.objectContaining(object)
, expect.stringContaining(string)
恐らく非対称マッチャーはないので自分でロジックを書いてassert.strictEqual()
で判定するしかないと思われる。元々微妙な機能だったのでやむなし
jest.spyOn(object, methodName, accessType?)
Node.jsのTest機能では、node:test
からmock
をimportして以下を使う
mock.method(object, methodName[, implementation][, options])
+import { describe, it, mock } from 'node:test';
+
describe('test', () => {
it('test', () => {
- jest.spyOn(console, 'log');
+ mock.method(console, 'log');
});
});
本来の挙動を塞ぎたい場合
例えば以下のようにテストコードを書いた場合、execSync()
が実際の挙動で動作してしまい、テストとして機能しない。
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';
const mockedExecSync = mock.method(child_process, 'execSync');
describe('execSync', () => {
it('execSyncが正しい引数で呼ばれること', () => {
execSync('false');
assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
});
});
このような場合、以下のようにmock.method()
の第三引数を指定してやると封じることができる。単体テストの観点では基本的に第三引数には空関数を入れておくのが望ましいだろう。
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';
- const mockedExecSync = mock.method(child_process, 'execSync');
+ const mockedExecSync = mock.method(child_process, 'execSync', () => {});
describe('execSync', () => {
it('execSyncが正しい引数で呼ばれること', () => {
execSync('false');
assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
});
});
モックパターン
module.exports
されている関数のモック
import foo from 'foo';
形式でmock.method()
の第一引数を埋める
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
import child_process, { execSync } from 'node:child_process';
const mockedExecSync = mock.method(child_process, 'execSync', () => {});
describe('execSync', () => {
it('execSyncが正しい引数で呼ばれること', () => {
execSync('false');
assert.strictEqual(mockedExecSync.mock.calls[0].arguments[0], 'false');
});
});
上記が正常に動作することはNode.js v20.0.0時点のコードで確認している
Global objectから生えている関数のモック
以下のようにmock.method()
の第一引数にGlobal objectを設定すればよい
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
describe('exit', () => {
// exitが実際に走って落ちるのでmock.methodの第三引数を指定している
const mockedExit = mock.method(process, 'exit', () => {});
it('call exit', () => {
process.exit(1);
assert.strictEqual(mockedExit.mock.calls.length, 1);
});
});
Named exportされている関数のモックは今のところ無理そう
ファイルモックをする手段がないので正攻法では無理そう
https://github.com/nodejs/help/issues/4298
2024-10-15追記
Node.js v22.3.0でテスト走行時に--experimental-test-module-mocks
を渡すことで近いことができるようになった模様だが、試したところ上手く動かないし、spy的な使い方はできなさそうだ。mock.method()との組み合わせも試してみたが、上手くいかなかった。
ObjectやNamespaceでラップされている関数のモック
実装例(Object)
export const Hoge = {
validateFoo() {
// 例外を飛ばす可能性のある何かの処理
},
hoge() {
Hoge.validateFoo();
return 1;
},
};
実装例(Namespace)
export namespace Hoge {
export const validateFoo = () => {
// 例外を飛ばす可能性のある何かの処理
};
export const hoge = () => {
validateFoo();
return 1;
};
}
実装例に対するテストコード
ObjectもNamespaceも同じ書き方でテスト可能
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Hoge } from './hoge';
describe('hoge', () => {
it('validateFooが例外をスローした場合、例外がスローされること', (t) => {
t.mock.method(Hoge, 'validateFoo', () => {
throw new Error('foo');
});
assert.throws(() => {
Hoge.hoge();
}, Error('foo'));
});
it('全ての関数が正常終了した場合、戻り値を返すこと', () => {
const actual = Hoge.hoge();
assert.strictEqual(actual, 1);
});
});
.toHaveBeenCalled()
, .toHaveBeenCalledTimes(number)
Node.jsのTest機能では、assert.strictEqual()
でモックから生えてるやつを調べる。returnも同様の手法で実現できる
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
- const spiedConsoleLog = jest.spyOn(console, 'log');
+ const mockedConsoleLog = mock.method(console, 'log');
console.log();
- expect(spiedConsoleLog).toHaveBeenCalled();
- expect(spiedConsoleLog).toHaveBeenCalledTimes(1);
+ assert.deepStrictEqual(mockedConsoleLog.mock.calls.length, 1);
});
});
.toHaveBeenCalledWith(arg1, arg2, ...)
Node.jsのTest機能では、assert.strictEqual()
でモックから生えてるやつを調べる。returnも同様の手法で実現できる。
Jestでは.toEqual()
処理されるがNode.jsの組み込みテストランナーの場合、厳密比較ができるので便利
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
- const spiedConsoleLog = jest.spyOn(console, 'log');
+ const mockedInfo = mock.method(console, 'log');
console.log('test');
- expect(spiedConsoleLog).toHaveBeenCalledWith('test');
+ assert.deepStrictEqual(mockedInfo.mock.calls[0].arguments[0], 'test');
});
});
jest.fn(implementation?)
Node.jsのTest機能では、以下となる
mock.fn([original[, implementation]][, options])
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
+// 動作確認用の関数
const testTarget = (cbParam: string, callbackFn: (param: string) => number) => {
return callbackFn(cbParam);
};
describe('test', () => {
it('test', () => {
- const mockFn = jest.fn((_: string) => {
+ const mockFn = mock.fn((_: string) => {
return 5;
});
const actual = testTarget('hoge', mockFn);
- expect(mockFn).toBeCalledWith('hoge');
- expect(mockFn).toReturnWith(5);
- expect(actual).toBe(5);
+ assert.deepStrictEqual(mockFn.mock.calls[0].arguments[0], 'hoge');
+ assert.deepStrictEqual(mockFn.mock.calls[0].result, 5);
+ assert.deepStrictEqual(actual, 5);
});
});
jest.useFakeTimers(fakeTimersConfig?)
, jest.runAllTimers()
Node.jsのTest機能では、以下となる
mock.timers.enable([timers])
mock.timers.runAll()
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
+describe('test', () => {
- jest.useFakeTimers();
+ mock.timers.enable();
+ it('test', () => {
- const mockFn = jest.fn();
+ const mockFn = mock.fn();
setTimeout(() => {
mockFn();
}, 9999);
- expect(mockFn).not.toHaveBeenCalled();
- jest.runAllTimers();
- expect(mockFn).toHaveBeenCalledTimes(1);
+ assert.deepStrictEqual(mockFn.mock.calls.length, 0);
+ mock.timers.runAll();
+ assert.deepStrictEqual(mockFn.mock.calls.length, 1);
});
});
jest.useRealTimers()
Node.jsのTest機能では、以下となる
mock.timers.reset()
+import { mock } from 'node:test';
-jest.useRealTimers();
+mock.timers.reset();
mockFn.mockClear()
Node.jsのTest機能では、以下となる
ctx.resetCalls()
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
- const mockFn = jest.fn((param: string) => `<${param}>`);
+ const mockFn = mock.fn((param: string) => `<${param}>`);
mockFn('hoge');
- expect(mockFn).toHaveBeenCalledTimes(1);
- expect(mockFn).toReturnWith('<hoge>');
+ assert.deepStrictEqual(mockFn.mock.calls.length, 1);
+ assert.deepStrictEqual(mockFn.mock.calls[0].result, '<hoge>');
- mockFn.mockClear();
+ mockFn.mock.resetCalls();
- expect(mockFn).toHaveBeenCalledTimes(0);
+ assert.deepStrictEqual(mockFn.mock.calls.length, 0);
mockFn('piyo');
- expect(mockFn).toHaveBeenCalledTimes(1);
- expect(mockFn).toReturnWith('<piyo>');
+ assert.deepStrictEqual(mockFn.mock.calls.length, 1);
+ assert.deepStrictEqual(mockFn.mock.calls[0].result, '<piyo>');
});
});
mockFn.mockReset()
Node.jsのTest機能では、以下となる
mockFn.mock.restore()
振る舞いが微妙に違うため後述する
+import { describe, it, mock } from 'node:test';
+import assert from 'node:assert';
+
describe('test', () => {
it('test', () => {
- const spiedConsoleLog = jest
- .spyOn(console, 'log')
- .mockImplementation((param: any) => {
- return `<${param}>`;
- });
+ const mockedConsoleLog = mock.method(console, 'log', (param: any) => {
+ return `<${param}>`;
+ });
console.log('hoge');
- expect(spiedConsoleLog).toHaveBeenCalledTimes(1);
- expect(spiedConsoleLog).toReturnWith('<hoge>');
+ assert.deepStrictEqual(mockedConsoleLog.mock.calls.length, 1);
+ assert.deepStrictEqual(mockedConsoleLog.mock.calls[0].result, '<hoge>');
- spiedConsoleLog.mockReset();
+ mockedConsoleLog.mock.restore();
});
});
JestとNode.jsでの振る舞いの差異
但しJestとNode.jsのTest機能では微妙に差異がある
例えばJestでは以下の実装が正しくPASSするが
describe('test', () => {
it('test', () => {
const mockFn = jest
.spyOn(console, 'log')
.mockImplementation((param: any) => {
return `<${param}>`;
});
console.log('hoge');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toReturnWith('<hoge>');
mockFn.mockReset();
expect(mockFn).toHaveBeenCalledTimes(0);
console.log('piyo');
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).not.toHaveBeenCalledWith();
expect(mockFn).toReturnWith(undefined);
});
});
Node.jsで以下の実装を書いても同じように機能しない
import { describe, it, mock } from 'node:test';
import assert from 'node:assert';
describe('test', () => {
it('test', () => {
const mockFn = mock.method(console, 'log', (param: any) => {
return `<${param}>`;
});
console.log('hoge');
assert.deepStrictEqual(mockFn.mock.calls.length, 1);
assert.deepStrictEqual(mockFn.mock.calls[0].result, '<hoge>');
mockFn.mock.resetCalls();
mockFn.mock.restore();
assert.deepStrictEqual(mockFn.mock.calls.length, 0);
console.log('piyo');
// 以降のテストはいずれも落ちる
assert.deepStrictEqual(mockFn.mock.calls.length, 1);
assert.deepStrictEqual(mockFn.mock.calls[0].result, '<piyo>');
});
});
但しモック実装を削除したうえで再度呼び出すという行為には意味がないので、特に問題にはならないと思われる
モック機能についての備考
it([name][, options][, fn])
の第三引数のコールバックの第一引数にはTestContext
が入っており、これを使ってモックすることもできる
これを使う場合、スコープアウトでモックが復元されるため、例えば以下のような関数をテストするときに便利である。
実装
export namespace Hoge {
export const validateFoo = () => {
// 例外を飛ばす可能性のある何かの処理
};
export const validateBar = () => {
// 例外を飛ばす可能性のある何かの処理
};
export const validateBaz = () => {
// 例外を飛ばす可能性のある何かの処理
};
export const hoge = () => {
validateFoo();
validateBar();
validateBaz();
return 1;
};
}
テストコード
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { Hoge } from './hoge';
describe('hoge', () => {
it('validateFooが例外をスローした場合、例外がスローされること', (t) => {
t.mock.method(Hoge, 'validateFoo', () => {
throw new Error('foo');
});
assert.throws(() => {
Hoge.hoge();
}, Error('foo'));
});
it('validateBarが例外をスローした場合、例外がスローされること', (t) => {
t.mock.method(Hoge, 'validateBar', () => {
throw new Error('bar');
});
assert.throws(() => {
Hoge.hoge();
}, Error('bar'));
});
it('validateBazが例外をスローした場合、例外がスローされること', (t) => {
t.mock.method(Hoge, 'validateBaz', () => {
throw new Error('baz');
});
assert.throws(() => {
Hoge.hoge();
}, Error('baz'));
});
it('全ての関数が正常終了した場合、戻り値を返すこと', () => {
const actual = Hoge.hoge();
assert.strictEqual(actual, 1);
});
});
- 投稿日:
ローカルで動作する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
辺りがどうなっているのかを参考にしました。