お知らせ

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

依存ライブラリの関係でESLint v9への移行はできていないが、取り合えずFlat configに移行した。ESLintは7.9.0から8.57.0に更新している。

確認環境

Flat configになってからeslint:recommendedは@eslint/jsに移った模様。

Env Ver
@eslint/js 9.2.0
@lycolia/eslint-config 0.9.1
@swc/jest 0.2.31
@types/jest 29.5.11
@types/node 20.11.5
eslint 8.57.0
eslint-plugin-jest 28.5.0
jest 29.7.0
prettier 3.2.5
typescript 5.4.5
typescript-eslint 7.8.0

元の設定

{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:jest/style",
    "plugin:jest/recommended",
    "@lycolia/eslint-config",
    "prettier"
  ],
  "plugins": [
    "@typescript-eslint"
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json"
  }
}

やったこと

ESLintなど必要ライブラリのアップデート

npm i -D eslint @eslint/js eslint-plugin-jest jest prettier typescript typescript-eslint @lycolia/eslint-config

新設定の作成

eslint.config.jsを作成

extendsの設定

今までextendsに書いていたものはrequireでモジュールを参照してmodule.exports = []の配列の中に書いた。typescript-eslintとeslint-plugin-jestの対応が何とも言えない。なお、この状態だとtypescript-eslintが正常に動かない。

const eslint = require("@eslint/js");
const eslintTypeScript = require("typescript-eslint");
const eslintJest = require("eslint-plugin-jest");
const eslintLycolia = require("@lycolia/eslint-config");

module.exports = [
  eslint.configs.recommended,
  eslintTypeScript.configs.recommended[0],
  eslintJest.configs["flat/style"],
  eslintJest.configs["flat/recommended"],
  eslintLycolia,
];

設定が競合して都合が悪かったので、この時にPrettierの設定を削除しているが、Flat config化とは特に関係ないので無視してよい。

@lycolia/eslint-configは私が作っているカスタム設定だが、これは特に何もせずとも使えた。

.eslintignore対応

extendsしたオブジェクトの次にオブジェクトを記述し、そこにignores: []を追加し、その中に同じことを書いた。恐らく根っこの配列はESLintの設定オブジェクトの羅列で、最終的に後勝ちでマージされる様に出来ているのだろう。

const eslint = require("@eslint/js");
const eslintTypeScript = require("typescript-eslint");
const eslintJest = require("eslint-plugin-jest");
const eslintLycolia = require("@lycolia/eslint-config");

module.exports = [
  eslint.configs.recommended,
  eslintTypeScript.configs.recommended[0],
  eslintJest.configs["flat/style"],
  eslintJest.configs["flat/recommended"],
  eslintLycolia,
  {
    ignores: [
      'dist/*',
      'coverage/*',
      '**/*.d.ts',
      '/src/public/',
      '/src/type',
      '*.config.js'
    ],
  }
];

TypeScript対応

eslintTypeScript.config()でESLintの設定配列全体を引数としてラップし、languageOptionsの中身を移植した。旧設定にあったplugins公式のドキュメントを見る限り不要になっている模様

const eslint = require('@eslint/js');
const eslintTypeScript = require('typescript-eslint');
const eslintJest = require('eslint-plugin-jest');
const eslintLycolia = require('@lycolia/eslint-config');

module.exports = eslintTypeScript.config(
  eslint.configs.recommended,
  ...eslintTypeScript.configs.recommended,
  eslintJest.configs['flat/style'],
  eslintJest.configs['flat/recommended'],
  eslintLycolia,
  {
    ignores: [
      'dist/*',
      'coverage/*',
      '**/*.d.ts',
      '/src/public/',
      '/src/type',
      '*.config.js'
    ],
    languageOptions: {
      parser: eslintTypeScript.parser,
      parserOptions: {
        project: './tsconfig.json'
      }
    }
  }
);

旧設定の削除

.eslintrc.eslintignoreを消した。

ESLint v9対応

typescript-eslintが対応していないため、現時点ではできなかった。

変更差分まとめ

-{
-  "extends": [
-    "eslint:recommended",
-    "plugin:@typescript-eslint/recommended",
-    "plugin:jest/style",
-    "plugin:jest/recommended",
-    "@lycolia/eslint-config",
-    "prettier"
-  ],
-  "plugins": [
-    "@typescript-eslint"
-  ],
-  "parser": "@typescript-eslint/parser",
-  "parserOptions": {
-    "project": "./tsconfig.json"
+const eslint = require('@eslint/js');
+const eslintTypeScript = require('typescript-eslint');
+const eslintJest = require('eslint-plugin-jest');
+const eslintLycolia = require('@lycolia/eslint-config');
+
+module.exports = eslintTypeScript.config(
+  eslint.configs.recommended,
+  ...eslintTypeScript.configs.recommended,
+  eslintJest.configs['flat/style'],
+  eslintJest.configs['flat/recommended'],
+  eslintLycolia,
+  {
+    ignores: [
+      'dist/*',
+      'coverage/*',
+      '**/*.d.ts',
+      '/src/public/',
+      '/src/type',
+      '*.config.js'
+    ],
+    rules: {
+      semi: ['error', 'always']
+    },
+    languageOptions: {
+      parser: eslintTypeScript.parser,
+      parserOptions: {
+        project: './tsconfig.json'
+      }
+    }
   }
-}
+);
投稿日:
言語::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');
});