- 投稿日:
関数の仮引数にthis
が入っているタイプの奴。FastifyのsetErrorHandlerに渡す関数のテストをする時に詰まったので書いておく。滅多に遭遇しないケースだと思う。
確認環境
Env | Ver |
---|---|
jest | 29.7.0 |
typescript | 5.3.3 |
実装コード
export class HogeServer {
constructor() {
console.log();
}
}
interface HogeServerInstance {
setErrorHandler(
handler: (
this: HogeServer,
error: unknown,
callback: (code: number) => void
) => void
): HogeServer;
}
// 型定義にthisが入っていてテスト時に上手くいかない
// const handle: (this: HogeServer, error: unknown, callback: (code: number) => void) => void
type HandlerFunc = Parameters<HogeServerInstance['setErrorHandler']>[0];
export const handle: HandlerFunc = (err, cb) => {
if (err instanceof Error) {
cb(500);
} else {
cb(200);
}
};
export const fuga = (s: HogeServerInstance) => {
s.setErrorHandler(handle);
};
テストコード
import { HogeServer, handle } from '.';
describe('handle', () => {
it('errがErrorインスタンスの時', () => {
const thisMock = {} as unknown as HogeServer;
const err = new Error('error-hoge');
const cbFn = jest.fn();
// .call()を使うことでthis付きで呼び出せる
handle.call(thisMock, err, cbFn);
expect(cbFn).toHaveBeenCalledWith(500);
});
it('errがErrorインスタンスではない時', () => {
const thisMock = {} as unknown as HogeServer;
const err = 1234;
const cbFn = jest.fn();
handle.call(thisMock, err, cbFn);
expect(cbFn).toHaveBeenCalledWith(200);
});
});
- 投稿日:
親クラスをモックして子クラスの単体テストをしたいときに
確認環境
Env | Ver |
---|---|
@swc/cli | 0.1.65 |
@swc/core | 1.3.105 |
@swc/jest | 0.2.31 |
@types/jest | 29.5.11 |
jest | 29.7.0 |
typescript | 5.3.3 |
サンプルコード
コードのフルセットは以下
https://github.com/Lycolia/typescript-code-examples/tree/main/mocking-base-class-with-extension-class-swc-ts
ディレクトリ構成
src
├─BaseClass
│ └─index.ts
└─ChildrenClass
├─index.ts
└─index.spec.ts
src/BaseClass/index.ts
親クラス。この例は抽象クラスだが別に具象クラスでも何でもいいと思う
export abstract class BaseClass {
constructor(
private baseValue: string,
private ctx: { [key: string]: unknown }
) {}
public hoge<ResultT>(payload: { [key: string]: unknown }) {
// テスト走行時には全て無効な値を流し込むため
// それらの影響で落ちないことの確認のために、オブジェクトの子を意図的に書いている
console.log(this.baseValue, this.ctx.hoge, payload.piyo);
// サンプルコードなので戻り値はスタブ
return {} as ResultT;
}
}
src/ChildrenClass/index.ts
子クラス
import { BaseClass } from '../BaseClass';
export class ChildrenClass extends BaseClass {
constructor(baseValue: string, ctx: { [key: string]: unknown }) {
super(baseValue, ctx);
}
public piyo<ResultT>(payload: { [key: string]: unknown }) {
try {
// このsuper.hoge()をモックして、
// catchの中に流れるかどうかを確認するのが目的
return super.hoge<ResultT>(payload);
} catch (err) {
// 実際はロガーが動くような部分だが、サンプルコードのためconsole.logで代用する
console.log(err);
throw err;
}
}
}
src/ChildrenClass/index.spec.ts
子クラスのテスト
import { ChildrenClass } from '.';
import { BaseClass } from '../BaseClass';
describe('fetch', () => {
it('親のfetchが呼ばれ、親の戻り値が返ってくること', () => {
// 子が正しくreturnしてくるかどうかを確認するための値
const expected = { code: 200 };
// Class.prototypeとやるとモック出来る
const spiedSuperFetch = jest
.spyOn(BaseClass.prototype, 'hoge')
.mockReturnValue(expected);
// 親クラスの処理はモックするので適当な値を入れておく
// この内容が実行されていればテストが落ちるのでテストコードが間違っていることの検証に使える
const inst = new ChildrenClass('aaaa', {} as { [key: string]: unknown });
const actual = inst.piyo({ foo: 123 });
// 親クラスのメソッドが正しい引数で呼ばれたことの確認
expect(spiedSuperFetch).toHaveBeenCalledWith({ foo: 123 });
// 子クラスのメソッドの戻り値が正しいことの確認
expect(actual).toStrictEqual(expected);
});
it('親のfetchで例外が出たときに、ログ出力とリスローがされること', () => {
const expected = Error('ERR');
// 親クラスのメソッドが例外をスローするケースを作る
jest.spyOn(BaseClass.prototype, 'hoge').mockImplementation(() => {
throw expected;
});
// catch句の中でロガーが動いているかどうかの検査用
const spiedConsoleLog = jest.spyOn(console, 'log');
const inst = new ChildrenClass('aaaa', {} as { [key: string]: unknown });
// 例外がリスローされていることを確認
expect(() => {
inst.piyo({ foo: 123 });
}).toThrow(expected);
// ロガーが動いていることを確認
expect(spiedConsoleLog).toHaveBeenCalled();
});
});
- 投稿日:
以下のようなコードを書いたときにmockFnにイベント引数が渡らないが、これをどうにかして取る方法。結論から言うとまともに取れないが、試行錯誤した時のログとして残しておく
const mockFn = jest.fn();
render(<input onChange={mockFn} />);
fireEvent.change(inputElement, { target: { value: 'aaa' } });
確認環境
Env | Ver |
---|---|
@swc/core | 1.3.66 |
@swc/jest | 0.2.26 |
@testing-library/jest-dom | 5.16.5 |
@testing-library/react | 14.0.0 |
jest | 29.5.0 |
react | 18.2.0 |
サンプルコード
これは以下のように書くとfireEvent
の第二引数で指定したtarget.value
の部分だけは一応取れる。
理由としてはfireEvent
がelement.dispatchEvent
を読んでいると思われるためだ。余り深くは追っていないが、react-testing-libraryの実装上は多分そうなっていると思われる。
import { fireEvent, render } from '@testing-library/react';
it('test', () => {
const mockFn = jest.fn((ev) => {
console.log(ev);
});
const { container } = render(<input id="hoge" onChange={mockFn} value={'a'} />);
const element = container.querySelector('input');
if (element === null) throw new Error();
element.dispatchEvent = jest.fn();
fireEvent.change(element, {
target: {
value: 'bbb'
}
});
expect(element.dispatchEvent.mock.instances[0].value).toBe('bbb');
});
- 投稿日:
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の組み合わせであればテストはできるが肝心の実行ができず無意味だった
- 投稿日:
この記事ではGitHub ActionsのCustom actionをJavaScriptで実装するJavaScript actionをTypeScriptとSWCを使って実装した方法を書いてます。
モチベーション
- GitHub ActionsのWorkflowsを共通化したい
- TypeScriptでロジックを書きたい
- SWCを使いたい(nccはtscを使うので避けたい
- github-scriptは一定以上のボリュームがあるものには向かない
- これを使いつつUTを書いたりするとなると結構面倒になると思う
今回作るもの
Custom actionのうちJavaScript actionを作成します。
実装コードはTypeScript、トランスパイラはSWC、バンドラはwebpackを利用します。
バンドラを利用するのは、node_modules/
をGitで管理したくないためです。
ビルド成果物であるdist/
は実行時に必要なため、Gitで管理します。
(CI上でビルドしてキャッシュさせておくことも出来ると思いますが、今回は扱いません)
確認環境
Env | Ver |
---|---|
@actions/core | 1.10.0 |
@actions/github | 5.1.1 |
@swc/cli | 0.1.57 |
@swc/core | 1.3.26 |
swc-loader | 0.2.3 |
typescript | 4.9.5 |
webpack | 5.75.0 |
webpack-cli | 5.0.1 |
サンプルコード
Custom Actions本体
Custom action本体のサンプルコードです。以下に一式があります。
https://github.com/Lycolia/typescript-code-examples/tree/main/swc-ts-custom-actions
ディレクトリ構成
dist/
配下を叩くため、ここはGit管理に含めます。バンドルするのでnode_modules/
はGit管理から外して問題ありません。
├─dist/
│ └─index.js # Custom Actionsとして実行するファイル本体
├─node_modules/
├─src/
│ └─index.ts # TypeScript実装
├─action.yaml # Custom Actionsの定義
├─package-lock.json
├─package.json
├─swcrc-base.js # SWCの設定
├─tsconfig.json # tscの設定
└─webpack.config.js # webpackの設定
swcrc-base.js
SWCの設定例。特にJavaScript actionのための設定はなく、CLI向けのトランスパイルが出来る設定ならおk。
ファイル名は何でも大丈夫ですが、この場では.swcrc
にしないことで、直接SWCで利用しないことを判りやすくするために違う名前にしています。
module.exports = {
module: {
type: 'commonjs',
},
jsc: {
target: 'es2020',
parser: {
syntax: 'typescript',
tsx: false,
decorators: false,
dynamicImport: false,
},
baseUrl: '.',
paths: {
'src/*': ['src/*'],
},
},
};
webpack.config.js
SWCを使って.ts
ファイルをバンドルするための設定。これがないとimport
の解決ができずにコケます。
node_modules/
配下をGit管理に含める場合は不要かもしれませんが、それをするのは微妙だと思います。
const path = require('path');
const swcrcBase = require(path.resolve(__dirname, 'swcrc-base'));
module.exports = {
// エントリポイント
entry: path.resolve(__dirname, 'src/index.ts'),
// 出力設定
output: {
// クリーンアップ後に出力
clean: true,
// 出力ファイル名
filename: 'index.js',
// 出力パス
path: path.resolve(__dirname, 'dist'),
},
// 設定必須なので何か指定しておく
mode: 'production',
// 指定してないとNode.jsのネイティブAPIが呼べない
target: ['node'],
module: {
// swc-loaderの設定
rules: [
{
test: /\.ts$/,
exclude: /(node_modules)/,
use: {
loader: 'swc-loader',
// swcrcの設定
options: {
...swcrcBase,
},
},
},
],
},
resolve: {
// import時のファイル拡張子を省略してる場合にパスを解決するための設定
extensions: ['', '.ts', '.js'],
},
};
src/index.ts
最低限これだけ確認できれば応用して実装できるだろうという程度のサンプルコード。
@actions/*
系の使い方は以下のリンクから確認できます。
actions/toolkit: The GitHub ToolKit for developing GitHub Actions.
import * as core from '@actions/core';
import * as github from '@actions/github';
const githubToken = core.getInput('GITHUB_TOKEN', { required: true });
const octokit = github.getOctokit(githubToken);
console.log('octokit', octokit);
console.log('context', github.context);
core.setOutput('RESULT_MESSAGE', 'test result message');
action.yaml
実装の参考例として引数と出力を定義してます。特に不要な場合は書かなくてもいいです。
Node.jsのバージョンを詳細に指定したい場合は、composite action にすれば可能だとは思いますが、試してない。
composite actionにしてnvmか何かでインストールしてやれば恐らく可能。
構文は以下のページで確認できます。
GitHub Actions のメタデータ構文 - GitHub Docs
name: example
description: custom actions example
inputs:
GITHUB_TOKEN:
description: 'Repogitory GITHUB_TOKEN'
required: true
outputs:
RESULT_MESSAGE:
description: 'Result message'
on:
workflow_call:
runs:
using: node16
main: dist/index.js
Custom actionを使う側
Custom actionを使うWorkflowのサンプルコードです。以下にソースがあります。
https://github.com/Lycolia/custom-actions-usage-example
.github/workflows/example.yaml
uses
のところにはリポジトリの組織名と、リポジトリ名、action.yaml
が配置されているディレクトリまでのパスを書きます。ルートディレクトリにある場合はパスを書かなくてOK
最後に@sha-hash
でコミットハッシュかタグを付けてやれば呼べるようになります。
動作確認中はハッシュが頻繁に変わるので、最新のハッシュを取得してきて設定されるようにしておくと便利かもしれません。
name: run example
on:
workflow_dispatch:
jobs:
example:
runs-on: ubuntu-latest
steps:
- name: run custom actions
id: test
uses: org-name/repo-name/path/to/file@sha-hash
with:
# Custom action側で定義されている引数(input)の設定
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: show custom actions output
# Custom action側で定義されている出力(output)の取得
run: echo ${{ steps.test.outputs.RESULT_MESSAGE }}
参考資料
- Getting Started – SWC
- swcrcの書き方やswc-loaderの使い方
- Concepts | webpack
- webpackの設定
- カスタム アクションについて - GitHub Docs
- GitHub Actions のメタデータ構文 - GitHub Docs
- プライベート リポジトリからのアクションとワークフローの共有 - GitHub Docs
- actions/toolkit: The GitHub ToolKit for developing GitHub Actions.
@actions/*
系のドキュメントなど