Rollup + Mocha/Chai で ESM で書かれた TypeScript ファイルをブラウザでテストする

## この記事は何？

この記事は「ESM で書かれた TypeScript モジュールをブラウザでテストする」ことを実現するためには何をしなくてはならないのか、自身が経験したことを踏まえてまとめたものであります。

誰に向けたものであるかといえば、

- ブラウザで ESM + TypeScript モジュールのテストを実現する方法を探している人
- web api をモックしないでテストで使いたい人（この記事では IndexeDB api を扱います）

という方にとって多少役に立つ記事になると思います。

テストフレームワークは Mocha, chai を、ブラウザで実行可能なファイルを出力するために Rollup.js を、ESM + TypeScript をテスト可能にするために ts-node 等を利用します。

## ブラウザでテストするのと疑似ブラウザ環境でテストすることの違い

両者の違いの一つとして web api が完全にサポートされているか否かという違いがあります。

疑似ブラウザとして例えば`jest-environment-jsdom`を使うと、jest でテストするときに疑似ブラウザ環境でテストすることが出来ますが、一部の web api がサポートされていないため、場合によってはほとんどの web api をモッキングしないといけなくなります。

筆者は IndexedDB api をどうしても使う必要がありましたが、jsdom ではサポートしていないためブラウザでのテストが可能な Mocha を採用することにしました。

Mocha はブラウザでテストが可能ということで IndexedDB api 含む web api を利用することが可能です。

## 最終的に作ったもの

いきなりですがこれをそろえれば ESM + TypeScript モジュールをブラウザでテストできます。

ディレクトリ構成：

```bash
# ブラウザでのテストファイルと設定関連ファイルなど
+-- browser/
|     # ブラウザテスト用のtsconfig
|     +-- tsconfig.mocha-browser.json
|     # ブラウザテストファイル
|     `-- index.test.ts
|
+-- src/
|   `-- # ...
|
+-- .mocharc.json
+-- package.json
+-- rollup.config.js
+-- tsconfig.json

```

`package.json`：

```JSON
{
  "devDependencies": {
    "@rollup/plugin-commonjs": "^28.0.0",
    "@rollup/plugin-html": "^1.0.4",
    "@rollup/plugin-node-resolve": "^15.3.0",
    "@rollup/plugin-typescript": "^12.1.0",
    "@surma/rollup-plugin-off-main-thread": "^2.2.3",
    "@types/chai": "^5.0.0",
    "@types/mocha": "^10.0.8",
    "chai": "^5.1.1",
    "comlink": "^4.4.1",
    "http-server": "^14.1.1",
    "mocha": "^10.7.3",
    "rollup": "^4.24.0",
    "rollup-plugin-node-polyfills": "^0.2.1",
    "ts-node": "^10.9.2",
    "tslib": "^2.7.0",
    "typescript": "^5.6.2"
  },
  "dependencies": {
    "idb-keyval": "^6.2.1"
  },
  "scripts": {
    "test-browser": "rollup --config rollup.config.js --input browser/index.test.ts && http-server ./ --port=8080 -c-1 -o ./output/index.html",
    "test-mocha-ts": "TS_NODE_PROJECT=\"mocha-chai-basic/tsconfig.mocha.json\" mocha --loader=ts-node/esm"
  },
  "type": "module"
}
```

`.mocharc.json`:

```JSON
{
  "extension": ["ts"],
  "spec": "src/**/*.spec.ts"
}
```

`rollup.config.js`:

```JavaScript
import ts from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';


const generateHtmlPlugin = () => {
  return {
   generateBundle(options, bundle) {
    const scriptTags = [];
    for(const filename in bundle) {
      const file = bundle[filename];
      if(file.isAsset || file.fileName.endsWith('.js')) {
        scriptTags.push(`<script src="${file.fileName}" type="module"></script>`)
      }
    }
    this.emitFile({
      type: 'asset',
      fileName: 'index.html',
      source: `
      <!DOCTYPE html>
      <html>
      <head>
        <meta charset="UTF-8">
        <link href="../../node_modules/mocha/mocha.css" rel="stylesheet" />
        <title>Title</title>
       </head>
      <body>
        <div id="mocha"></div>
        ${scriptTags.join('\n')}
      </body>
      </html>`
    });
  }}
};


export default {
  input: '-',
  cache: false,
  output: {
    dir: 'output',
    format: 'es'
  },
  plugins: [
    ts({
      tsconfig: './browser/tsconfig.mocha-browser.json'
    }),
    commonjs(),
    resolve({
      browser: true, preferBuiltins: false
    }),
    generateHtmlPlugin()
  ]

};
```

`browser/tsconfig.mocha-browser.json`:

```JSON
{
    "compilerOptions": {
      "target": "ESNext",
      "lib": ["esnext", "dom", "WebWorker"],
      "jsx": "preserve",
      "module": "ESNext",
      "noEmit": true,
      "esModuleInterop": true,
      "moduleResolution": "Bundler",
      "strict": true,
    }
  }
```

## ローカルで ESM + TypeScript モジュールをテストできるようにする

最終目標はブラウザでのテストですが、まずはローカルでテストできるようにします。

Mocha の公式サンプルを参考にします。

https://github.com/mochajs/mocha-examples/tree/main/packages/typescript-ts-node-esm-loader

#### Installation

```bash
$ npm i --save-dev mocha chai
$ npm i --save-dev @types/mocha @types/chai
$ npm i --save-dev typescript ts-node tslib
```

#### コンフィグファイル

```bash
$ touch .mocharc.json
$ touch tsconfig.mocha-ts.josn
```

`.mocharc.json`:

```JSON
{
  "extension": ["ts"],
  "spec": "src/**/*.spec.ts"
}
```

`tsconfig.mocha-ts.json`:

```JSON
{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["esnext", "dom"],
    // ESMファイルを出力するよう指定します
    "module": "ESNext",
    // テストするだけなのでトランスパイルしたファイルは出力しません
    "noEmit": true,
    "esModuleInterop": true,
    // import文でファイルを指定するときに拡張子を省略していいという指定
    "moduleResolution": "Bundler",
    "strict": true,
  }
}
```

#### テストファイル

```bash
# dummy.jsはただtrueを返すだけのモジュールであるとします
$ ls src
dummy.js

$ touch src/dummy.spec.ts
```

```TypeScript
import { equal } from "assert";
// NOTE: import対象ファイルはトランスパイル後の拡張子を指定します
import { dummy } from './dummy.js';

// moduleファイルですが特にインポートしなくてもMocha.describe()と解決してくれるみたいです
describe("Test suit for dummy", () => {

  it("should return expected string", () => {
    equal(dummy, true);
  });
});
```

なぜ import するファイルの拡張子を指定してなおかつトランスパイル後の拡張子でないといけないのか

という話はこちらの方の記事がとてもこんがらがっている情報を簡潔にまとめていらっしゃって大変助かりました

https://zenn.dev/qnighy/articles/19603f11d5f264

Node.js の Native ESM 環境では拡張子を明示しなくてはならなくなったのですが、TypeScript ファイルはではコンパイルする前の拡張子にするべきか後の拡張子にするべきかという問題が発生することになりましたが、
TypeScript 側はそれを読み替えてくれる、かつ ts-node は呼び出し時に`--loader=ts-node/esm`を付けることで`.js`と拡張子を指定しておけばコンパイル時に解決してくれるのだそうです。

そのためコンパイル後の拡張子を指定することで問題なく TypeScript コンパイルとコンパイル後のテストの実行が可能になります

`package.json`で`"type": "module"`をつけると、Node.js の Native ESM が有効になるので上記の対応が必要になります。

#### package.json

やることは２つで、ESM の有効化とテストコマンドの登録です。

まず ESM ファイル対応の一環として`"type": "module"`を追加します。

サンプルファイルの README にも書いてありますが、これをするか、すべての TypeScript ファイルの拡張子を`.mts`というように m を付けるかを選ぶことになります

`package.json`:

```diff
{
  //...
  + "type": "module",
}
```

詳しくは Node.js の ESM サポートの有効化の項目に従うことになります。

https://nodejs.org/api/esm.html#enabling

今回は`"type": "module"`の追加を採用することにします。

つぎにテストコマンドの登録です。

mocha でテストするときだけ採用したい tsconfig ファイルを使えるようにしておけば、本番用の tsconfig ファイルを区別出来て便利です。

そのためテスト実行時に特定の tsconfig ファイルを指定できるようにします。

```diff
{
  //...
  "type": "module",
  "scripts": {
      //...
+     "test-mocha-ts": "TS_NODE_PROJECT=\"tsconfig.mocha-ts.json\" mocha --loader=ts-node/esm"
  }
}
```

`--loader=ts-node/esm`はサンプルリポジトリに書いてある通り、ts-node の ESM サポートを利用することを指定するコマンドです。

#### テスト実行

準備が整ったのでテストを実行してみます。

```bash
❯ npm run test-mocha-ts

> typescript-ts-node-esm-loader@1.0.0 test
> mocha --loader=ts-node/esm

(node:14) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("ts-node/esm", pathToFileURL("./"));'
(Use `node --trace-warnings ...` to show where the warning was created)


  Test suit for dummy
    ✔ should return expected string

  1 passing (1s)
```

正常に実行できました

## ブラウザでテストを実行できるようにする

Mocha でブラウザテストする際は、ブラウザが取り込む html ファイルを作成して、

ブラウザにその html ファイルを読み取らせ、html ファイルにテストの書かれた script タグを追加するか、

テストの書かれたファイルを src として取り込むかになります。

#### テストさせたいファイル

以下の TypeScript ファイルをこの後の項目を進めるにつれて最終的にテスト可能出来るようにしていきます。

IndexedDB のライブラリである`idb-keyval`のテストファイルを参考にしています

https://github.com/jakearchibald/idb-keyval/blob/main/test/index.ts

`browser/index.test.ts`:

```TypeScript
import 'mocha/mocha';
import chai from 'chai/chai';
import {
  get,
  set,
  del
} from 'idb-keyval';

const { assert } = chai;
mocha.setup('tdd');

(async () => {
  await promisifyRequest(indexedDB.deleteDatabase('keyval-store'));
  const customStore = createStore('custom-db', 'custom-kv');

  suite('The basics', () => {
    test('get & set', async () => {
      await set('foo', 'bar');
      assert.strictEqual(await get('foo'), 'bar', `Value can be get'd`);
      assert.strictEqual(
        await get('food'),
        undefined,
        `Non-existent values are undefined`,
      );
    });
  });

  mocha.run();
})();
```

このテストでは以下の目標を達成していきます

- IndexedDB api が使えることの確認
- カスタム store が作成されることに確認
- 基本的な使い方（get, set）がテストで実現できることの確認

#### 最小構成

ひとまずベースとなるファイルを作成します。

ここでも公式のサンプルを基に開始します

https://github.com/mochajs/mocha-examples/tree/main/packages/browser

html:

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Mocha Tests</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="node_modules/mocha/mocha.css" />
  </head>
  <body>
    <div id="mocha"></div>

    <script src="node_modules/chai/chai.js"></script>
    <script src="node_modules/mocha/mocha.js"></script>

    <script class="mocha-init">
      mocha.setup("bdd");
      mocha.checkLeaks();
    </script>
    <script src="test.example.js"></script>
    <script class="mocha-exec">
      mocha.run();
    </script>
  </body>
</html>
```

些末なことですが、
上記はプロジェクトのルートなので、依存関係である`Mocha`や`Chai`を取り込むために`node_modules/...`を指定していますが、この html ファイの場所によっては上記のようにハードコーディングしているとパスを解決できなくなります。

このことはのちの Rollup を使うところで対処します。

```bash
# Chai.jsの読み取り中に
Uncaught SyntaxError: Unexpected token 'export'
```

そのまま index.html ファイルを読み込ませると上記の通り`export`文って何ですかとなる。

これは Chai.js が v4 から v5 になるにあたって CJS にかわって ESM を採用したことに起因します。

そのため公式サンプルのリポジトリを同様にテストするとあちらは v4 なので問題なく実行できますが、v5 の Chai の環境では上記の通りエラーになります。

こちらも Rollup を使う段階で解決される問題なので問題ありません。

node_modules から取得する方法から CDN 経由で取得する方法ならばテストを動作させることが出来ます。

```diff html
  <body>
    <div id="mocha"></div>

-   <script src="node_modules/chai/chai.js"></script>
-   <script src="node_modules/mocha/mocha.js"></script>
+   <script type="module"> import mocha from https://cdn.jsdelivr.net/npm/mocha@10.7.3/+esm </script>
+   <script type="module"> import chai from https://cdn.jsdelivr.net/npm/chai@5.1.1/+esm </script>
```

#### やること

先ほど示した TypeScript で書かれたテストファイルをトランスパイルしてから上記のような html に script タグで取り込んで、その html ファイルをブラウザで実行します。

また、テストファイルも ESM + TypeScript で書きたいし、テスト対象のファイルも ESM + TypeScript で書いてあります。

ということで以下の課題をクリアする必要があります

- html ファイルへ取り込む前に TypeScript ファイルを JavaScript ファイルへトランスパイルさせる
- テストファイル含め依存関係をバンドルする
- バンドルしたファイルをすべて html ファイルへ script タグで登録する
- 必要なすべての JavaScript ファイルが script で取り込まれるようにする

また利便性のためにコマンドラインからテストファイルを指定してブラウザで実行されるようにしていきます。

#### Rollup でバンドリング

https://rollupjs.org/tutorial/#installing-rollup-locally

手動で毎度テスト毎に必要な依存関係やファイルを html ファイルへ追加していくという作業は気が遠くなります。

バンドラを利用することでこの手間を解決します。

バンドラを使えば、適切な設定をしておけばコマンドを実行するだけでまるっとすべて解決した html ファイルを生成してくれます。
w
ということで Rollup を使ってテストファイルとテストファイルの依存関係をバンドルし、それらをよしなに script タグで取り込んでくれた html ファイルを生成してもらいます。

```bash
$ npm i --save-dev rollup
$ touch rollup.config.js
```

まずは公式の簡単な例を模倣してみます

`rollup.config.js`;

```JavaScript
export default {
  // エントリーポイントにブラウザでのテストファイルを指定します
	input: 'browser/index.test.ts',
	output: {
      dir: 'output',
      // 出力ファイルをESMに指定します
		  format: 'es'
	}
};
```

このまま rollup コマンドを実行すると`output/index.test.js`が出来上がります

```bash
$ npx rollup --config=./rollup.config.js

browser/index.test.ts → output...
(!) Unresolved dependencies
https://rollupjs.org/troubleshooting/#warning-treating-module-as-external-dependency
mocha/mocha (imported by "browser/index.test.ts")
chai (imported by "browser/index.test.ts")
idb-keyval (imported by "browser/index.test.ts")
created output in 168ms
```

トランスパイルされたファイルは出来上がりましたが依存関係が解決されていない模様です。

#### 依存関係の解決 `@rollup/plugin-node-resolve`

https://rollupjs.org/troubleshooting/#warning-treating-module-as-external-dependency

Rollup では対象ファイルが呼び出す相対パスで指定したファイル以外のファイル（対象ファイルが import などで取り込む依存関係など）を解決しません（バンドルに含めない）

代わりに実行時に追加される対象という扱いになるようです。

そこで解決してくれるようにしてくれるプラグインを導入します。

```bash
$ npm i --save-dev @rollup/plugin-node-resolve
```

```diff
+ import { nodeResolve } from '@rollup/plugin-node-resolve';

export default {
	input: 'browser/index.test.ts',
	output: {
      dir: 'output',
		  format: 'es'
	},
+   plugins: [nodeResolve()]
};
```

実行結果

```bash
$ npx rollup --config=./rollup.config.js

browser/index.test.ts → output...
(!) "this" has been rewritten to "undefined"
https://rollupjs.org/troubleshooting/#error-this-is-undefined
node_modules/mocha/mocha.js
4:   typeof define === 'function' && define.amd ? define(factory) :
5:   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.mocha = factory());
6: })(this, (function () { 'use strict';
      ^
7:
8:   var global$2 = (typeof global !== "undefined" ? global :
created output in 903ms
```

`"this"`がうんたらの警告は今回問題にしないので無視します。

これで依存関係もバンドルされた巨大ファイルが生成されたのが確認できました。

#### 都合のいい html ファイルの出力　`@rollup/plugin-html`

次にこの生成されたバンドルファイルをよしなに取り込んでくれる都合のいい html ファイルを出力してくれるプラグインを導入します。

https://github.com/rollup/plugins/tree/master/packages/html#readme

```bash
$ npm i --save-dev @rollup/plugin-html
```

`rollup.config.js`:

```diff
import { nodeResolve } from '@rollup/plugin-node-resolve';
+ import html from '@rollup/plugin-html';

export default {
	input: 'browser/index.test.ts',
	output: {
      dir: 'output',
		  format: 'es'
	},
    plugins: [
        nodeResolve(),
+       html()
    ]
};
```

これでまた`rollup`を実行したところ期待通り`index.test.js`が script タグで取り込み済の html ファイルが`output`ディレクトリに出力されました

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Rollup Bundle</title>

  </head>
  <body>
    <script src="index.test.js" type="module"></script>
  </body>
</html>
```

Mocha でテストするには`<div id="mocha"></div>`が必要なのでそれを追記します。

#### ローカルサーバで実行する

このまま html ファイルをブラウザで実行するとセキュリティ関連でエラーになるので、`http-server`を使ってローカルサーバで実行することにします。

```bash
$ npm i --save-dev http-server
$ npx http-server ./ --port=8080 -c-1 -o ./output/index.html
Starting up http-server, serving ./

http-server version: 14.1.1

http-server settings:
CORS: disabled
Cache: -1 seconds
Connection Timeout: 120 seconds
Directory Listings: visible
AutoIndex: visible
Serve GZIP Files: false
Serve Brotli Files: false
Default File Extension: none

Available on:
  http://172.18.28.150:8080
  http://127.0.0.1:8080
Hit CTRL-C to stop the server
Open: http://127.0.0.1:8080/./output/index.html

[2024-10-14T15:46:53.397Z]  "GET /output/index.html" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
(node:18624) [DEP0066] DeprecationWarning: OutgoingMessage.prototype._headers is deprecated
(Use `node --trace-deprecation ...` to show where the warning was created)
[2024-10-14T15:46:54.339Z]  "GET /output/index.test.js" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
```

これで初めてブラウザテストを実行することが出来ました。

`-c-1`でキャッシュを無効にしています。

`http-server`はそのままだとキャッシュを使うのでコード修正してやり直しとかしてもキャッシュされたファイルを使ったりします。

現状残る課題は、

- `index.test.ts`が厳密には型付けをコード中に盛り込んでいないので TypeScript トランスパイルを本当はしていないことの対応
- 出力される html ファイルのカスタマイズ（もととなる html ファイルを用意したり）
- コマンドラインから rollup の input ファイルを指定できるようにする
- 依存関係が CJS でも出力されるバンドルは ESM に統一して tree shaking 機能を有効にする

#### TypeScript のトランスパイル `@rollup/plugin-typescript`

https://github.com/rollup/plugins/tree/master/packages/typescript#rollupplugin-typescript

```bash
$ npm i --save-dev @rollup/plugin-typescript
# ブラウザテスト用のtsconfigファイルを用意します
$ cp tsconfig.mocha-ts.json browser/tsconfig.mocha-browser.json
```

`rollup.config.js`:

```diff
import { nodeResolve } from '@rollup/plugin-node-resolve';
import html from '@rollup/plugin-html';
+ import typescript from '@rollup/plugin-typescript';

export default {
	input: 'browser/index.test.ts',
	output: {
        dir: 'output',
		format: 'es'
	},
    plugins: [
+        typescript({
+           tsconfig: './browser/tsconfig.mocha-browser.json'
+       }),
        nodeResolve(),
        html()
    ]
};
```

上記の通り tsconfig ファイルを指定できます。

ということでブラウザテスト用の tsconfig ファイルはここで指定するということになります。

tsconfig の内容は先の`tsconfig.mocha-ts.json`と同じ内容で問題ありません

`index.test.ts`が一切 typeescript の構文使っていないのでどこかしら型付けしてみて、プラグインでトランスパイル出来ているか確認します。

```diff
import 'mocha/mocha';
import * as chai from 'chai';
import { get, set, createStore, promisifyRequest } from 'idb-keyval';

const { assert } = chai;
mocha.setup('tdd');

(async () => {
    await promisifyRequest(indexedDB.deleteDatabase('keyval-store'));
    const customStore = createStore('custom-db', 'custom-kv');

    suite('The basics', () => {
        test('get & set', async () => {
            await set('foo', 'bar', customStore);
-           assert.strictEqual(await get('foo', customStore), 'bar', `Value can be get'd`);
+           assert.strictEqual(await get<string>('foo', customStore), 'bar', `Value can be get'd`);
            assert.strictEqual(
-               await get('food', customStore),
+               await get<string>('food', customStore),
                undefined,
                `Non-existent values are undefined`
            );
        });
    });

    mocha.run();
})();
```

これで`rollup`後に出力されたファイルは TypeScript 構文は期待通り消されて純粋な JavaScript ファイルが出力されたのが確認できました

`output/index.test.js`:

```JavaScript
// ...
(async () => {
    await promisifyRequest(indexedDB.deleteDatabase('keyval-store'));
    const customStore = createStore('custom-db', 'custom-kv');
    suite('The basics', () => {
        test('get & set', async () => {
            await set('foo', 'bar', customStore);
            // 先ほど付けたジェネリクスが消えている
            assert.strictEqual(await get('foo', customStore), 'bar', `Value can be get'd`);
            assert.strictEqual(await get('food', customStore), undefined, `Non-existent values are undefined`);
        });
    });
    mocha.run();
})();
```

#### HTML ファイルのカスタマイズ

Rollup から出力される HTML ファイルをカスタマイズします。

現状少なくとも次を用意しなくてはなりません

- `<div id="mocha"></div>`: mocha でテストするのに必須
- `<link href="/node_modules/mocha/mocha.css" rel="stylesheet" />`: ブラウザ上の表示に適用させるため

また、バンドル上依存関係を別の JavaScript ファイルに出力するような場合もあるので、それの対応もします。

出力する html ファイルはテンプレートに従うようにカスタムプラグインを導入します。

`rollup.config.js`:

```JavaScript
import { nodeResolve } from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
// import html from '@rollup/plugin-html';

// カスタムプラグイン
const generateHtmlPlugin = () => {
    let ref;
    return {
        // htmlプラグインでは自動的に追加されるinputファイルは
        // 自作プラグインなので以下のように手動で追加します
        buildStart() {
            ref = this.emitFile({
                type: 'chunk',
                id: 'browser/index.test.ts',
            });
        },
        generateBundle() {
            this.emitFile({
                type: 'asset',
                fileName: 'index.html',
                source: `
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="UTF-8">
          <link href="../../node_modules/mocha/mocha.css" rel="stylesheet" />
          <title>Title</title>
         </head>
        <body>
          <div id="mocha"></div>
          <script src="${this.getFileName(ref)}" type="module"></script>
        </body>
        </html>`,
            });
        },
    };
};

export default {
    input: 'browser/index.test.ts',
    output: {
        dir: 'output',
        format: 'es',
    },
    plugins: [
        typescript({
            tsconfig: './browser/tsconfig.mocha-browser.json',
        }),
        nodeResolve(),
        // html(),
        generateHtmlPlugin(),
    ],
};

```

参考

https://rollupjs.org/plugin-development/#this-emitfile

#### コマンドラインからエントリファイルを指定する

現状 rollup バンドリングのエントリポイントのファイルは `rolup.config.js` にハードコーディングしています。

テストファイルを変えるごとに書き直す面倒を負いたくないので、エントリポイントはコマンドラインから受け取れるようにします。

次のことをする必要があります。

- コンフィグファイルの input フィールドに`-`を渡す
- カスタムプラグインでコマンドラインオプションの値を取得できるようにする
- コマンドラインには`--input`オプションにエントリポイントを渡します

`rollup.config.js`:

```JavaScript
// ...

const generateHtmlPlugin = () => {
    return {
        // 最終的にscriptタグに追加されるバンドル結果は引数bundleから取得できます
        // なのでその情報をJavaScriptで解決します
        generateBundle(options, bundle) {
            const scriptTags = [];
            for (const filename in bundle) {
                const file = bundle[filename];
                if (file.isAsset || file.fileName.endsWith('.js')) {
                    scriptTags.push(
                        `<script src="${file.fileName}" type="module"></script>`
                    );
                }
            }
            this.emitFile({
                type: 'asset',
                fileName: 'index.html',
                source: `
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="UTF-8">
          <link href="../../node_modules/mocha/mocha.css" rel="stylesheet" />
          <title>Title</title>
         </head>
        <body>
          <div id="mocha"></div>
          ${scriptTags.join('\n')}
        </body>
        </html>`,
            });
        },
    };
};

export default {
    input: '-',
    // ...
};
```

上記のカスタムプラグインの方法で、たとえばバンドルした結果複数 JavaScript ファイルが出力される場合でも対応できます。

```bash
$ npx rollup --config=./rollup.config.js --input=./browser/index.test.ts
```

これでコマンドラインから自由にエントリファイルを指定できるようになりました。

#### `@rollup/plulgin-commonjs`

課題「依存関係が CJS でも出力されるバンドルは ESM に統一して tree shaking 機能を有効にする」の解決です。

このプラグインを使う理由は以下の通りです

- バンドルして出力するファイルを ESM に統一するため
- CJS である依存関係を ESM モジュールと一緒に使えるようにするため
- ESM によって tree shaking 機能が有効になるので余計なファイルがバンドルされないようになるため

これをいれないと追加した依存関係が CJS であるときにエラーが発生します。
上記の通り tree shaking の恩恵もあるのでひとまず入れておくのがいいでしょう。

たとえば CJS のライブラリである`lodash.debounce`を導入してこれまで通りの設定でバンドルしてみます。

```bash
$ npm i --save-dev lodash.debounce @types/lodash.debounce
```

`index.test.ts`:

```diff
import 'mocha/mocha';
import * as chai from 'chai';
import { get, set, createStore, promisifyRequest } from 'idb-keyval';
+ import debounce from 'lodash.debounce';

const { assert } = chai;
mocha.setup('tdd');
mocha.checkLeaks();

(async () => {
    await promisifyRequest(indexedDB.deleteDatabase('keyval-store'));
    const customStore = createStore('custom-db', 'custom-kv');

+   // 検証用でありテスト的には関係のないコードです...
+   const debounced = debounce(() => { console.log('debounced'); });
+   debounced();

    suite('The basics', () => {
        test('get & set', async () => {
            await set('foo', 'bar', customStore);
            assert.strictEqual(await get<string>('foo', customStore), 'bar', `Value can be get'd`);
            assert.strictEqual(
                await get<string>('food', customStore),
                undefined,
                `Non-existent values are undefined`
            );
        });
    });

    mocha.run();
})();
```

以下の通りのエラーになります。
要は`lodash.debounce`は ESM の export 分を使っていないからあなたの呼び出し方法は間違っているんだがという指摘です。

```bash
# 前略

[!] RollupError: browser/index.test.ts (4:7): "default" is not exported by "node_modules/lodash.debounce/index.js", imported by "browser/index.test.ts".
5:   (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.mocha = factory());
6: })(this, (function () { 'use strict';
      ^
7:
8:   var global$2 = (typeof global !== "undefined" ? global :
[!] RollupError: browser/index.test.ts (4:7): "default" is not exported by "node_modules/lodash.debounce/index.js", imported by "browser/index.test.ts".
      ^

# 以下略
```

本来であれば`const debounce = require('lodash.debounce')`で呼び出すべきであるのですが ESM 環境なので`import/export`で統一したいです。
commonjs プラグインに解決してもらいます。

```bash
$ npm i --save-dev @rollup/plugin-commonjs
```

`rollup.config.js`:

```diff
+ import commonjs from '@rollup/plugin-commonjs';

//...

export default {
    input: '-',
    output: {
        dir: 'output',
        format: 'es',
    },
    plugins: [
        typescript({
            tsconfig: './browser/tsconfig.mocha-browser.json',
        }),
+       commonjs(),
        nodeResolve(),
        // html(),
        generateHtmlPlugin(),
    ],
};
```

上記を導入したことでエラーが消えてこれまでどおりのテストが可能となりました。

#### ブラウザテストにローカル前提 Node.js api があることで発生するエラーの解消

依存関係を増やしたりしてブラウザテストをすると以下のようなエラーが発生することがあります。

```bash
Uncaught TypeError: Failed to resolve module specifier "fs". Relative references must start with either "/", "./", or "../".
```

要はブラウザ環境はローカル環境とは違うからいくつかの Node.js の api は使えないことに起因するエラーです。

テストファイルまたは追加した依存関係が呼び出していることでこうしたエラーに遭遇することがあるかもしれません。

`@rollup/plugin-node-resove`プラグインのオプションで解決できます。

`rollup.config.js`:

```diff JavaScript
//...

export default {
  input: '-',
  cache: false,
  output: {
    dir: 'output',
    format: 'es'
  },
  plugins: [
    ts({
      tsconfig: './browser/tsconfig.mocha-browser.json'
    }),
-   resolve(),
+   resolve({
+     browser: true, preferBuiltins: false
+   }),
    commonjs(),
    generateHtmlPlugin()
  ],
};
```

https://github.com/rollup/plugins/tree/master/packages/node-resolve#browser

https://github.com/rollup/plugins/tree/master/packages/node-resolve#preferbuiltins

## Mocha テスト あれこれ

上記までやれば概ね簡単なテストは実行できる状態になっていると思います。

#### タイマーの設定

https://mochajs.org/#timeouts

たとえば setTimeout などを使って 3000ms 待つ非同期関数のテストをするとします。

このテストをすると Mocha は 2000ms 経っても終わんないから終わりますという感じにエラーをはいて強制終了します。

なのでタイマーを延長する操作が必要になります。

主に２通りになると思います。

- コマンドラインで`--timout`オプションを追加する
- `this.timeout()`メソッドをテスト内で呼び出す

ブラウザでテストする場合コマンドラインは関係ないので後者で対応することになります。

#### WebWorker のテスト

ブラウザテストの恩恵として web api が使えるので、WebWorker もテストに含めることが出来ます。

これまでの設定で追加する必要があるのはプラグイン一つだけです。

https://github.com/surma/rollup-plugin-off-main-thread?tab=readme-ov-file#rollup-plugin-off-main-thread

```bash
$ npm i --save-dev @surma/rollup-plugin-off-main-thread
```

GitHub リポジトリの説明曰く、rollup の output.format は`amd`にしろとのことですが、

Chrome ver129 では`es`（ESM）でも問題ありませんでした。

WebWorker は`type: "module"`でもちゃんとテストできます。

WebWorker を Comlink で呼び出す場合にも対応しています。

## 実現できなかったこと

#### npm script のオプションに自由な値を渡すこと

利便性の話でテストとは直接関係ない話で bash の使い方の話です。

Rollup のエントリポイントファイルをコマンドラインから登録できるようにしました。

なので package.json へ次の通りに登録すればコマンド一つでバンドルとブラウザでのテストの実行が実現できます。

```diff JSON
{
  //...
  "scripts": {
    //...
+   "test-browser": "rollup --config=./rollup.config.js --input=./browser/index.test.ts && http-server ./ --port=8080 -c-1 -o ./output/index.html"
  }
}
```

とはいえ今度はコマンドラインの`--input`オプションがハードコーディングされているので、このコマンドでは常に`./browser/index.test.ts`しかエントリポイントへ渡せません。

なので以下のようなコマンドを入力したら`--input`オプションの部分が置き換わるような方法を模索しました。

```bash
$ npm run test-browser -- ./browser/index.test.ts
```

模索しましたが力及ばず実現できませんでした。

詳しい方いたら教えてください。

## おわりに

テストフレームワークはそれぞれに使いどころがあってどんな場合も対応できる万能のテストフレームワークはないのだと知りました。

たとえば JavaScript でテストするなら jest だよねみたいな風潮が（ネットをざっとみるだけだと）ありますが jest は ESM を公式サポートしていません。

jest は実験的な機能として ESM をサポートしていますが、ESM サポート下に於いてユーザモジュールのモッキングは不親切なドキュメントしかなく、ESM + React 　コンポーネントのモッキングは（個人的に方法を模索してみたのですが）不可能だと思います。

vitest は ESM サポートなので、jest であれこれ頑張るよりも ESM は vitest、CJS は jest と使い分けるのが正解だと感じています。

そんな両者も web api は完全にサポートしておらず、疑似ブラウザ環境を提供する jsdom も同様です。

そのため、web api はモッキングして web api の動作は無視するか、サポートされている他のライブラリに頼るかの 2 択になります。

こんな感じで最終的にたどり着いたのが Mocha によるブラウザテストでありました。

プロジェクトによってはこのようにあらゆる制約をクリアしていくには複数のテストフレームワークを使うことになると思うのですがどうなんでしょうか。

最後までご覧いただきありがとうございます。