お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
言語::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);
};

test

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');
});
投稿日:
言語::TypeScript開発::設計

TypeScriptで関数を書いているときに戻り値の型を書くケースがあるが、個人的にはあれは基本書かないほうがいいと思っているので、その理由を書いていく。

コード記述が冗長になる

まず型を書くと記述が冗長になる。以下のコードを見ると戻り値型が長く読みづらく、書くのも面倒だ。まずこんな長い命名をやめたほうが…というのはあれど、現実問題として長い命名は存在するので仕方ない。

export const getCampanyAndDepartmentAndEmployeeFromPrefectureCode = (
  param: VeryveryLoooongestParamTypeNaming,
): VeryveryLoooongestReturnTypeNaming => {
  return param.db.con.execQuery(
    "SELECT * FROM foo WHERE id = :?",
    param.dbParam.foo.id,
  );
};

しかし以下であれば戻り値型がない分すっきりしていて見やすいし、書く手間も掛からない。更に型推論によって正しい型が返るので理想的だ。

export const getCampanyAndDepartmentAndEmployeeFromPrefectureCode = (
  param: VeryveryLoooongestParamTypeNaming,
) => {
  return param.db.con.execQuery(
    "SELECT * FROM foo WHERE id = :?",
    param.dbParam.foo.id,
  );
};

実装と異なる戻り値型を暗黙的に記述できる

例えば以下のように書けば戻り値の型は'foo' | 'not foo'となり、常に正しい型が返る。

export const foo = (isFoo: boolean) => {
  return isFoo ? 'foo' : 'not foo';
};

しかし以下のように戻り値の型を定義すると実装上存在しない'bar'が返る。これは実装時に無用な混乱を生む。一般的にこのようなケースは仕様削除やリファクタなどで生まれることがあると思うが、そういうメンテ漏れにもなるので書かないほうが安全だといえる。

export const foo2 = (isFoo: boolean): 'foo' | 'not foo' | 'bar' => {
  return isFoo ? 'foo' : 'not foo';
};

他にも次のケースでは戻り値がstringとなり、何が返ってくるのかが実装を見ないと解らなくなる。特に理由がないなら書かないほうがよい。

// 'foo' | 'not foo'になるはずだがstring扱いになる
export const foo3 = (isFoo: boolean): string => {
  return isFoo ? 'foo' : 'not foo';
};

型記述が混乱する

ここは基本的に前項の内容と重複する内容となる。

例えば次の二つの実装は同じだが、戻り値の型だけが異なる。こういう実装が混在すると実装の一貫性が失われ無用な混乱を生むので、指定しないほうが望ましい。

const IndexPage = (): JSX.Element => {
  return (
    <Layout title={'Hello Next.js'}>
      <>
        <h1>Hello Next.js 👋</h1>
      </>
    </Layout>
  );
};

const IndexPage2 = (): ReactElement => {
  return (
    <Layout title={'Hello Next.js'}>
      <>
        <h1>Hello Next.js 👋</h1>
      </>
    </Layout>
  );
};

書いてもよいと思うケース

例えば依存関係を持たせたい時など、インターフェースとして型を共通化したい場合は書いてもよいと思う。これはどこでそれを使うのかが自明になるからだ。改修時にも型によって関連処理が見出しやすくなるので意識しやすくなる。

export const createPostMessage = (
  channel: string,
  username: string,
  message: string,
): PostMessage => {
  return {
    channel,
    username,
    message,
  };
};

export const postMessage = async (param: PostMessage) => {
  try {
    return await fetch('https://example.com/api/postMessage', param);
  } catch (err) {
    return err;
  }
};

但しこのようなケースではUnit Testを書いて、実装された戻り値型を満たす値が返ることを確認するのが望ましい。

戻り値型を書かないことによるデメリット

TypeScriptの公式リポジトリによると、型推論の速度に悪影響を及ぼすとあるので、型推論の速度が落ちるという点だ。

もし型推論の速度が非常に遅いと感じた場合は書いてみてもよいと思うが、公式でも以下のように案内があり、和訳すると「型推論は非常に便利なので、これを普遍的に行う必要はありませんが、コードの遅いセクションを特定した場合に試してみると便利です。」とあるので、余程複雑なことをしていない限り不要だとは思うし、そんな複雑な型を返すような処理は必要がなければ書かないほうがいいだろう。

Type inference is very convenient, so there's no need to do this universally - however, it can be a useful thing to try if you've identified a slow section of your code.

少なくとも私は実務上、型推論の速度に困ったことがないのと、tscでビルドすることも稀であるため、ビルドに影響することもない。よって基本書いていない。

投稿日:
言語::TypeScriptNode.js::npmNode.js::JestNode.js::ESLint

ローカルで動作する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出来るように)するときに使う手法です。

importimport { 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が一番単純で参考になると思います。

実装はビルド成果物とビルド前のコード、package.jsontsconfig.json辺りがどうなっているのかを参考にしました。