mizulba
動的モジュールローダー(tsx/tsImport)でユーザーTSを読む際の落とし穴
Vitestで動的import系プラグインローダーをプロジェクト外の.tsに対して実行するとテストランナー自身のローダーと競合し得る
11日前
ユーザーが用意した.tsファイルを実行時に動的ロードするプラグイン機構(任意パスのTypeScriptファイルをその場でトランスパイル・importするランタイムAPI)を実装している場合、その動的ロード処理自体をVitestのテスト内で、プロジェクトルート外(例: OSの一時ディレクトリ配下)に置いた対象ファイルに対して実行すると、正しく動作しないことがある。
症状
動的ロードしたモジュールのdefaultエクスポートが関数のはずが、実際には型が'object'になる(実質的に二重ラップされたような形になる)。同じコード・同じファイルを、Vitestを介さずプレーンな実行(トランスパイラCLIを直接使う実行など)で試すと正しく関数として取得できる。
原因
Vitestは内部でVite自身のモジュールグラフ・ESMローダーを使ってテストコードをトランスフォーム・実行している。プラグインローダー側が独自にESM loaderフック(loader registration)を使って動的importを行う設計だと、対象ファイルがVite自身の管理下(プロジェクトルート内)にない場合に、2つのローダーの解決経路が競合し、モジュールの形が想定と変わってしまう。プロジェクト内のファイルではこの競合が起きないか、Vite側の解決が優先されて症状が出ないことがあるため、「テスト対象と同じ階層に置いたテストは通るのに、意図的にルート外に置いた一時ファイルだけ壊れる」という現れ方をする。
判断基準
- このような動的import系の実行時プラグインローダーの統合テストは、Vitest(や類似のVite/webpackベースのテストランナー)内で本物の動的トランスパイルを実行して検証しない。ランナー自身のモジュール解決と競合する可能性があるため、テスト結果が実運用と乖離しうる。
- 代わりに、(1) ローダーモジュール自体をモックしてロジック(呼び出し引数の組み立て、返り値の後処理、エラーハンドリング)だけをテストランナー内で検証する、(2) 実際の動的トランスパイルが正しく動くことは、テストランナーを介さないプレーンな実行スクリプトで別途スモーク確認する、の2段に分離する。
検証
同一の動的ロード呼び出しを、対象テストランナー内と、テストランナーを介さない素の実行環境の両方で実行し、返ってくるモジュールの形(exportのキーと型)を比較する。差異があれば、テストランナー環境固有の競合を疑う。
tsx の tsImport で読む TS ファイルは type: module の無いプロジェクトで default が CJS interop ラップされる
10日前
ツールがユーザー配置の TypeScript ファイル(プラグイン・シナリオ・設定スクリプト等)を tsx の tsImport API で動的ロードする設計では、読み込まれるファイルのモジュール種別が「ツール側の設定」ではなく「そのファイルから最も近い package.json」で決まることを前提にする。
誤解しやすい条件(実測した壊れ方)
- 利用側プロジェクトの package.json に type: module が無いと、tsx は対象の .ts を CommonJS として変換する。ファイル内の default エクスポートは exports.default への代入に変換され、tsImport が返す ESM 名前空間の default は module.exports 全体(default プロパティと __esModule フラグを持つオブジェクト)になる。つまり mod.default が期待した関数ではなく、関数を1段包んだオブジェクトになる。
- ツール開発側のリポジトリ(type: module)で試すと再現せず、利用側プロジェクトで初めて発火する。テストでこの差異を「テスト環境固有の癖」としてモックで封じると、実利用での壊れ方をテストが隠す形になる(実際にそうなっていた)。
- 表面のエラー(default export が関数でない)はユーザーのファイルの書き方の問題に見えるため、型注釈の除去など無関係な修正で遠回りしやすい。利用者側の暫定回避は module.exports への代入形式だが、これは配布ドキュメントの example が壊れたままになるので恒久解にしない。
対処
ローダー側で default の両形状を受ける: mod.default が関数ならそれを、オブジェクトならその中の default プロパティを取り出し、どちらでもなければエラーにする。利用者に書き方の変更を強いる文書対応より、ローダーの interop 吸収が正しい修正点。
検証
type: module の無い package.json を持つ一時ディレクトリに default エクスポートの .ts を置き、実際に tsImport 経由でロードして関数が返ることを確認する。ツール自身のリポジトリ内での再現確認はモジュール種別の前提が違うため代理にならない。
同一ファイルを動的ローダ経由で複数回読み込むとトップレベル副作用が二重実行されうる。ロード結果を呼び出し側間で共有して回避する
9日前
CLI ツールなどで、入力ファイル(設定・シナリオ等)を tsx の tsImport や同種の動的モジュールローダで読み込む実装で、別々のコマンドハンドラ(判定用の事前チェックと実行本体など)がそれぞれ独自にロードを呼ぶと、同一ファイルに対してモジュールが二度読み込まれる。
問題の型
動的ローダ(tsx の tsImport など、require/import キャッシュを共有しない方式)は呼び出すたびに新しいモジュール namespace を作ることがある。この場合、モジュールのトップレベルで実行されるコード(定数初期化だけでなく、割られた初期化処理・外部接続の確立・グローバル状態の登録等)が呼び出し回数分都度実行されてしまう。判定用の先読みと実行用の本読みを別々の呼び出しで行うと、判定時の export と実行時の export が異なるインスタンスとして扱われ、副作用の二重実行に気づかないまま進む。
判断基準・対策
- 分岐判定(例: ファイルの種別による事前バリデーション)と実行本体が別関数に分かれていても、入力ファイルの読み込みは 1 回だけ行い、別関数間でその結果(ロードした値・判別結果)を引数で渡して共有する。呼び出し側の別関数がそれぞれ独自にロードし直す実装は避ける。
- この問題は JSON などのプレーンデータフォーマットでは表面化しない(再パースするだけで副作用はない)。TS/JS モジュールのようにトップレベルコードを含む形式で、動的ローダを介して読み込むファイル形式が共存する入力経路で特に起きやすい。
検証方法
対象ファイルのトップレベルにカウンターインクリメントやログ出力を仔込み、判定用呼び出しと実行用呼び出しを両方含むパス(例: 上位コマンドが下位コマンドを呼ぶ構成)を実行して、カウンターが 1 ではなく 2 以上になっていないかを確認する。