TypeScript5.2 で追加された using Declarations and Explicit Resource Management をテストで活用する

こんにちは。N予備校 Webフロントエンド開発チームの堀です。 先日 TypeScript@5.2 が正式リリースされました。 TypeScript@5.2 で新しく追加された using Declarations and Explicit Resource Management (using 構文と明示的リソース管理)によって、N予備校のフロントエンドのテストでの本質的でない処理をカプセル化し、コード上のノイズを削減することができました。

この記事では、using 構文の活用方法や利用するまでの手順、調査した using 構文の挙動について紹介します。

N予備校のPCブラウザ向けフロントエンドの紹介

N予備校は2016年にサービスインしました。サービスイン当時のフロントエンドは Ruby on Rails view の中で React を動作させていましたが、徐々に SPA へと進化させていき、現在では Ruby on Rails のページが僅かに残ってはいるものの、ほとんどが SPA として動作しています。また SPA としての歴も長く、SPA 部分は2017年に最初のリリースが行われてから現在に至るまでメンテナンスされ続けています。

SPA 部分は主に以下のライブラリを使用して開発しています。

  • React
  • TypeScript
  • Styled Components
  • Webpack
  • ESLint, Prettier
  • Danger JS
  • Storybook
  • Jest, Testing Library

ライブラリは Renovate によってアップデートし続けているのに加え、チームで技術のトレンドを追う勉強会を設け、より良い状態を目指し、保ち続けています。もちろん、最新バージョンへの追従が間に合っていないものもありますが、鋭意対応中であり今後もアップデートし続けていきます。

N予備校のPCブラウザ向けUIのテストについて

サービスを長く継続するには、CIでの自動テストが不可欠です。本項では、JestMSW を使用した、UIコンポーネントのテストについて掘り下げていきます。React Testing Library ももちろん使用していますが、本記事では詳しく触れません。

テストの種類

N予備校のPCWebフロントエンドの開発では、各種テストに以下のツールを使用しています。

UIコンポーネントの単体テストについて

プレゼンテーショナルコンポーネントは Storybook で確認するにとどめ Jest による単体テストはしていません。何か(特にテストしないと不安な)機能を持っているコンポーネントを Jest でテストしています。

また、通信するコンポーネントのテストは、以下のいずれかの方法で通信相手や通信処理をモック化して行います。

  • API Client(独自に定義したクラス)のモック化
  • axios-mock-adapterAxios をモック化
  • MSW を使った通信先のモック化

コンポーネントは、簡略化しますが Component → API Client → axios というフローで通信処理を実行します。実際にブラウザ上で動作するものは極力テスト対象としたいため、MSW を使って通信相手をモック化するのが好ましいと考えています。以降記述される「テスト」という言葉は、特に言及がなければ MSW を使ったテストを指しています。

リクエスト内容のテスト

通信処理のテストでは、主に以下を見ています。

  • 不要なリクエストが行われていないか
  • リクエストのパスやメソッドやパラメータが正しいか
  • 各種ステータスのハンドリング

フロントエンドアプリケーションがリクエストを行う契機は様々ですが、例えばユーザーが UI を操作した際にコンポーネントやその周辺の処理が入力値を加工などしてリクエストを行う場合があり、その工程の結果行われるリクエストが正しいかどうかをテストする必要があります。そのためには、リクエストのパスやメソッドやパラメータを観測可能としなくてはなりません。

N予備校PCWebアプリケーションでは、MSW でリクエストを観測するために spyMswRequest という関数を作成し使用しています。

import type { DefaultRequestBody } from 'msw';
import type { SetupServerApi } from 'msw/node';

/**
 * リクエストパラメータをオブジェクトの形で取得する。
 * 同一パラメータ名が複数指定されているケースは一旦保留。
 */
export function parseURLSearchParams(urlSearchParams: URLSearchParams): Record<string, string | number | boolean> {
  const obj = {} as Record<string, string | number | boolean>;
  urlSearchParams.forEach((v, k) => {
    if (v === 'true' || v === 'false') {
      obj[k] = v === 'true';
    } else if (!Number.isNaN(Number(v))) {
      obj[k] = Number(v);
    } else {
      obj[k] = v;
    }
  });
  return obj;
}

/**
 * # MSW に spy を仕込む
 *
 * MSW を起動している場合は MSW に spy を仕込むことでリクエスト内容を検証します。
 */
export function spyMswRequest(server: SetupServerApi): ReturnType<typeof jest.fn<void, [MswRequestInfo]>> {
  const mswSpy = jest.fn<void, [MswRequestInfo]>();
  server.events.on('request:start', ({ url: { searchParams, pathname }, method, body }) =>
    mswSpy({
      searchParams: parseURLSearchParams(searchParams),
      pathname,
      method: method.toUpperCase() as Method,
      body,
    })
  );
  return mswSpy;
}

export type RequestInfo = {
  searchParams: Record<string, string | number | boolean>;
  pathname: string;
  method: Method;
  body: DefaultRequestBody;
};

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

💭 parseURLSearchParams は何かライブラリがありそうな気がしますが、現状では自前実装しています。
spyMswRequest 関数は server.eventsrequest:start のイベントの購読することで、MSWへの全てのリクエストを観測します。

そして以下は Jest で spyMswRequest を使用して検証する例です。

const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => {
  server.resetHandlers();
  server.events.removeAllListeners();
});

test('...', async () => {
  const spy = spyMswRequest(server);
  // arrange...
  // act...
  // assert
  expect(spy).toHaveBeenCalledWith(
    expect.objectContaining(
      {
        pathname: '/v1/questions',
        searchParams: params,
      } satisfies MswRequestInfo
    )
  );
})

課題: 本質的でない処理が afterEach に漏れ出ている

お気づきいただけただろうか?

server.eventsrequest:start のイベント購読を開始したのは spyMswRequest 関数でしたが、イベント購読を停止する処理は Jest のテストファイルの afterEach にあります。

// 略
afterEach(() => {
  server.resetHandlers();
  // ここで購読停止している!
  server.events.removeAllListeners();
});
test('...', async () => {
  // spyMswRequest の内部で購読開始しているのに 😇 
  const spy = spyMswRequest(server);
  // 略
})

これは嫌ですね!spyMswRequest はイベント購読の開始をしておきながら「停止なんか知らん」と、なんとも無責任な実装です。また、テストコードからは「なぜ afterEach で server.events.removeAllListeners(); をするのか」が直感的にわかりません。

上述のように、カプセル化がうまくできず様々な箇所に漏れ出ている本質的でない関心事は、本質的な関心事(ここではテスト)に対するノイズとなります。

解決策: using Declarations and Explicit Resource Management によって本質的でない関心事をカプセル化する

それぞれの関心事をカプセル化し本質的な関心事に対するノイズを減らすことで、コードの認知負荷軽減や実装漏れを減らすことができます。

Jest の afterEach 等や、ECMAScript の stage3 のプロポーザルである decorator も本質的でない処理のノイズ削減に寄与します。そして TypeScript@5.2 で追加された using Declarations and Explicit Resource Management も同様です。afterEach と比較すると using 構文を使った実装のほうはカプセル化ができるためノイズ削減により寄与します。

using Declarations and Explicit Resource Management を使った書き方

using Declarations and Explicit Resource Management を使うことで、 server.events のイベント購読の停止処理を spyMswRequest に記載し、関心事をカプセル化できるようになります。

Disposable の実装

まずは spyMswRequest が Disposable なオブジェクトを返却するよう変更します。 Symbol.dispose 関数では、イベント購読停止処理を実行します。

export function spyMswRequest(
  server: SetupServerApi
): Disposable & { mswSpy: ReturnType<typeof jest.fn<void, [MswRequestInfo]>> } {
  const mswSpy = jest.fn<void, [MswRequestInfo]>();
  server.events.on('request:start', ({ url: { searchParams, pathname }, method, body }) =>
    mswSpy({
      searchParams: parseURLSearchParams(searchParams),
      pathname,
      method: method.toUpperCase() as Method,
      body,
    })
  );
  // Symbol.dispose メソッドを持つオブジェクトを返却する
  return {
    mswSpy,
    [Symbol.dispose]: () => {
      console.log('dispose');
      server.events.removeAllListeners();
    },
  };
}

Disposable を実装するには Symbol.dispose メソッドを持つオブジェクトを返却する必要があります。

ちなみに、JavaScript の関数はオブジェクトであるため、関数に直接 fn[Symbol.dispose] = () => console.log('dispose') のようにして Symbol.dispose メソッドを生やせるように見えます。しかし、その場合は実行時に以下のようなエラーが出ますのでご留意を。

TypeError: using declarations can only be used with objects, null, or undefined.

スコープ内で using 構文を使用する

続いて、test 関数のスコープ内で using 構文で変数を定義し spyMswRequest で得た Disposable なオブジェクト(spyObj)を使用するよう変更します。これにより、test 関数のスコープを抜ける際に spyObj の Symbol.dispose の処理が実行されるようになります。

const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

test('...', async () => {
  using spyObj = spyMswRequest(server);
  // arrange...
  // act...
  // assert
  expect(spyObj.mswSpy).toHaveBeenCalledWith(
    expect.objectContaining(
      {
        pathname: '/v1/questions',
        searchParams: params,
      } satisfies MswRequestInfo
    )
  );
})

ちなみに、 using { mswSpy } = spyMswRequest(server); と書けそうに見えるかも知れませんが、 using 構文で宣言する変数は分割代入できないようで、以下のエラーとなります。

'using' declarations may not have binding patterns.ts(1492)

さらにちなみに、using で宣言する変数は定数であるため再代入不可です。

さて、ここまでで using Declarations and Explicit Resource Management のメリットと、その使い心地を紹介してきました。しかし、これらを使い始めるには既存のプロジェクトの設定の変更が必要でしたので、次の項で紹介します。

using Declarations and Explicit Resource Management を使い始めるまで

既存の TypeScript プロジェクトの Jest によるテストで using Declarations and Explicit Resource Management を使えるように設定していきます。(この設定は 2023/8 のものであり、近い将来に必須でなくなる可能性が高いことをご留意ください)

typescript@5.2 を使用可能にする

まずは以下の手順で typescript@5.2 を使用可能にします。

  1. yarn add -D typescript@5.2
  2. VSCode で TypeScript のバージョンを指定する
    1. cmd+shift+p して typescript を入力し TypeScript のバージョンを選択 を選択する
      cmd+shift+p して typescript を入力し TypeScript のバージョンを選択 を選択する
      cmd+shift+p して typescript を入力し TypeScript のバージョンを選択 を選択する
    2. ワークスペースのバージョンを使用 5.2 を選択する
      ワークスペースのバージョンを使用 5.2 を選択する
      ワークスペースのバージョンを使用 5.2 を選択する
  3. tsconfig の compilerOptions.lib で esnext を指定する
    • esnext を全体に適用したくない場合は esnext.disposable を掻い摘んで指定してもOKです。以下例です。 json { "compilerOptions": { ..., "lib": ["es2021", "esnext.disposable", "dom"], }, ... }

babelの設定

我々の環境では babel-jest を使用しているため、Jest の環境で using Declarations and Explicit Resource Management が使えるよう babel のプラグインを設定します。

  1. 以下のコマンドで babel プラグインをインストール bash yarn add -D @babel/plugin-proposal-explicit-resource-management
  2. .babelrc.js でプラグインを設定 javascript const plugins = [ // ... ['@babel/plugin-proposal-explicit-resource-management', { loose: true }], ]
  3. (必要であれば)@babel/plugin-proposal-explicit-resource-management@babel/core@7.22.0 以上が必要のようです。しかし、諸事情で我々のプロジェクトは 7.22.0 未満の @babel/core への依存があったため package.json の resolutions に "@babel/core": "^7.22.0" を追加して 7.22.0 未満の @babel/core への依存を回避しました。 json { ... "resolutions": { ... "@babel/core": "^7.22.0" }, ... }

Jest で Symbol.dispose を使用可能にする

Symbole.dispose が存在しない環境の場合、 TypeError: Property [Symbol.dispose] is not a function. などのエラーが発生する場合があります。その場合は以下のようにして実行環境に Symbol.dispose と、必要であれば Symbole.asyncDispose を定義すればエラーは解消されます。

Symbol.dispose ??= Symbol("Symbol.dispose");
Symbol.asyncDispose ??= Symbol("Symbol.asyncDispose");

我々の場合はひとまず Jest のテスト内でのみ使用したかったため、 setup ファイル内で上記を実行し解決しました。

その他ライブラリをTS5.2対応のものにする

その他に以下のライブラリをアップデートしました。

  • prettier@3.0.3
  • @typescript-eslint/eslint-plugin@6.5.0
  • @typescript-eslint/parser@6.5.0

using 挙動深掘り

Announcing TypeScript 5.2 - TypeScript にエラー時の挙動について記載があったため確認していきます。また、Symbol.asyncDispose についても触れていきます。

using 後の処理で例外が発生した場合は dispose 後に再スローする

「せやろな」というところですね。 Jest のテストにおいてはテストが fail するとエラーがスローされます。その際にも Symbol.dispose の処理が呼ばれていることが確認できました。

using 後の処理と dispose 関数の両方でエラーが発生した場合は両方がSuppressedErrorに含まれてスロー

2つのエラーを1つにまとめてスローしてくれるようです。 Jest のテストではどのような挙動になるのかを確認するため、テスト失敗時に dispose も失敗したケースをシミュレートしてみました。

it('should be true.', () => {
  using disposable = getDisposable()
  expect(false).toBeTruthy();
})

function getDisposable() {
  return {
    [Symbol.dispose]: ()=> {
      console.log('dispose');
      throw new Error('dispose error');
    }
  }
}

本来であれば、 toBeTruthy() の部分で期待と異なり false であったため失敗した旨のログが出力されます。

 FAIL  src/disposable.test.ts
  ✕ should be true. (13 ms)

  ● should be true.

    expect(received).toBeTruthy()

    Received: false

      1 | it('should be true.', () => {
      2 |   using disposable = getDisposable()
    > 3 |   expect(false).toBeTruthy();
        |                 ^
      4 | })
      5 |
      6 | function getDisposable() {

      at Object.toBeTruthy (src/disposable.test.ts:3:17)

しかし、Symbol.dispose でもエラーが発生した場合にはテスト失敗の具体的な内容が出力されなくなってしまいました。

 FAIL  src/disposable.test.ts
  ✕ should be true. (21 ms)

  ● should be true.



      1 | it('should be true.', () => {
      2 |   using disposable = getDisposable()
    > 3 |   expect(false).toBeTruthy();
        |                                                            ^
      4 | })
      5 |
      6 | function getDisposable() {

      at new dispose_SuppressedError (src/disposable.test.ts:3:228)
      at new dispose_SuppressedError (src/disposable.test.ts:3:404)
      at err (src/disposable.test.ts:4:246)
      at next (src/disposable.test.ts:4:185)
      at _dispose (src/disposable.test.ts:4:310)
      at Object._dispose (src/disposable.test.ts:3:30)

これはテストでしたが、プロダクトコードにおいては SuppressedError がスローされることを想定するのはコストが高いように思います。dispose のみならず、finally 句でのエラーにおいても同じことが言えますが、エラーが発生しないようチェックしたりその場でエラーハンドリングをするなど、極力スローを避けるのが良さそうです。

Symbol.asyncDisposeawait using

後処理に非同期処理が含まれていてその完了を待つ必要がある場合があります。これは Symbol.asyncDisposeawait using を使用することで実現できるとのことです。

ひとまず Symbol.asyncDispose の挙動を理解したいと思います。ログを各所に仕込んで確認しました。わかりやすいよう、以下のコードにはすでにログの出力順序を付与しています。

it('should be true.', async () => {
  console.log('1. start:test');
  {
    console.log('2. start:scope');
    await using disposable = createAsyncDisposable();
    console.log('3. end:scope');
  }
  console.log('6. end:test');
  expect(true).toBeTruthy();
})

function createAsyncDisposable() {
  return {
    [Symbol.asyncDispose]: async ()=> {
      console.log('4. start:dispose');
      await new Promise<void>((resolve) => setTimeout(resolve, 1000));
      console.log('5. end:dispose');
    }
  }
}

disposable な変数のスコープを抜けたタイミングで Symbol.asyncDispose の処理が実行され、その完了を待って以降の処理が実行されました。

ちなみに、Symbol.dispose に指定する関数を非同期関数にし、それに対して await using した場合でも上記と同様の挙動となりました。Symbol.asyncDispose を利用している場合は using をエラーとし await using を型で強要できますが、Symbol.dispose を利用している場合は using を強制されず await using を使用できるようです。わかりやすいよう、以下で表にまとめます。

Disposable と using 構文の組み合わせ表

Symbol.dispose or Symbol.asyncDispose、同期関数 or 非同期関数、 using or await using の組み合わせによる挙動を表にしてみました。

using await using
Symbol.dispose
同期関数を指定
⭕ 同期的に後処理を実行する ⭕ 同期的に後処理を実行する
Symbol.asyncDispose
同期関数を指定
❌ 型エラー ❌ 型エラー
Symbol.dispose
非同期関数を指定
⚠️
⭕ 非同期的な後処理の完了を待たずに処理を進める ⭕ 非同期的な後処理の完了を待ってから処理を進める
Symbol.asyncDispose
非同期関数を指定
❌ 型エラー ⭕ 非同期的な後処理の完了を待ってから処理を進める

⚠️「Symbol.dispose に非同期関数を指定」と using の組み合わせについては、場合によっては後処理される前にプロセスが終了するようなケースがあるかと思います。ご利用は自己責任でお願いします🙏🏻

まとめ

N予備校のWebフロントエンドのテストにおける課題を Disposable と using を使用して解決する例を紹介し、 TypeScript@5.2 で追加された using Declarations and Explicit Resource Management の挙動について確認しました。

using によってリソースの開放漏れやその処理によるノイズの削減ができそうですし、カプセル化もしやすくなり、あらためて using Declarations and Explicit Resource Management は有用な機能だと認識できました。

また、N予備校のテストで using 構文を使用する上で必要となった設定についてや、 using 構文と Disposable などの組み合わせについても紹介しました。これらが本記事を読んだ方の参考になれば幸いです。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com

参考にした記事・ドキュメントなど