お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
言語::TypeScriptNode.js::Jest開発::テスト

関数の仮引数に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の部分だけは一応取れる。

理由としてはfireEventelement.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.jsontypemoduleにする
  • jest.config.jstransformIgnorePatternsにESMモジュールのパスだけ除外する設定を書く
  • 上記に加えてtransform.jsc.pathpkg-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 }}

参考資料