mizdra's blog

ぽよぐらみんぐ

最近書いた風変わりなテストコード 7 連発

テスト書く腕力鍛えるため & 個人開発のメンテナンスを楽にするために、最近なんでもテストコードを書きまくってます。あらゆるものをテストするぞという気概を持って手を動かした結果、ちょっと変わったテストコードを書いたりしてました。というわけで、この記事ではそれらを紹介していきます。

CLIツールの E2Eテスト

「eslint-interactive」という ESLint の error/warning を高速修正する CLI ツールがあります。

www.mizdra.net

このツールには、E2Eテストを導入しています。

E2E テストと聞くと、「GUI アプリケーションに対してタップ/クリックイベントをエミュレートして、アプリが期待通りの挙動をするかを検証するアレ」を思い浮かべることが多いと思います。しかし元の意味から考えると「E2E テスト」は「End-to-End テスト」のことで、つまりは「全部のレイヤーを結合した上でユーザが実際に利用するインターフェイスからソフトウェアを操作し、その振る舞いを検証するテスト」を意味しています。E2E テストは、GUI であっても CUI であっても適用できるテスト手法なのです。

実際に eslint-interactive ではどんな E2E テストを書いているかといいますと...

  1. npm install -g packed.zipnpm packしたパッケージをグローバルにインストール
  2. eslint-interactive ./fixtureでインストールした eslint-interactive を起動
  3. 標準入力から適当な入力を与えて eslint-interactive を操作
  4. 標準出力が期待通りかを assertion

spawnでプロセスを起動すると、stdin/stdout への binding が取れるので、それ経由で入出力のハンドリングをしてます。

eslint-interactive は諸事情あってローカルインストールされた時は上手く動きますが、グローバルインストールされた時は動かない、ということが起きやすい作りになっています。そのため、手間を掛けてグローバルインストールした時の E2E テストを整備しています。実際にこのテストがあったことで、グローバルインストールした時にだけ再現するバグなどが検出できたりしていて、あって良かったなと思ってます。

dotfiles のインストール可能性のテスト

バニラな macOS/Ubuntu に dotfiles がインストールできることを GitHub Actions でテストしてます。 sh -c "$(curl -fsLS chezmoi.io/get)" -- init --apply -S .がミソです。

github.com

id:mizdra は dotfiles は書くために、chezmoi というツールを利用してます。template という機能でマシンごとの設定の差分を上手く表現できて、簡単にマルチプラットフォームな dotfiles を作れます。

www.mizdra.net

また chezmoi はバイナリが配布されているため、インストールの待ち時間もほとんど掛かりません。CI 上でも高速にインストールでき、快適にテストできます。

ブラウザ拡張機能の content script の E2E テスト

now-playing-for-google-play-music という YouTube Music から NowPlaying ツイートできるブラウザ拡張機能があります *1

www.mizdra.net

content script で右下にボタンを挿入していて、これを押すと https://twitter.com/intent/tweet?text=ギターと孤独と蒼い惑星%20%2F%20結束バンド&hashtags=NowPlaying という URL が新しいタブで開かれてツイートできます (Twitter Web Intent という仕組みに乗っかっています)。

右下の矢印ボタンを押すと、NowPlaying ツイートができる。

この content script に以下のような E2E テスト を用意しています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. YouTube Music の表示言語が日本語になるよう、それっぽい cookie をセットしておく
  3. YouTube Music の任意の曲の再生ページを chrome で開く
  4. page.waitForSelectorを使い、右下に拡張機能のボタンが挿入されるまで待機
  5. ボタンをクリックして、新しいタブで Twitter Intent が開かれることを assertion

--load-extensionで拡張機能を読み込み、context.waitForEvent('page')で新しく開かれたタブの binding を取ってきているところがミソです。

Scrapbox の UserScript の E2E テスト

風変わりな E2E テスト第3弾。Scrapbox というドキュメンテーションサービスには UserScript という仕組みがあり、ユーザが書いた JavaScript をページ表示完了時に実行できます。

scrapbox.io scrapbox.io

id:mizdra が作ったアイコン記法高速挿入ツール「icon-suggestion」も、UserScript として実装してます。

scrapbox.io

これにも E2E テスト があって、以下のようなことをやっています。

  1. playwright を使い、chrome をヘッドレスモードで起動
  2. Content Security Policy (CSP) を無効化する
  3. Scrapbox のページにアクセス
  4. page.waitForSelectorを使い、scrapbox 側のDOMの組み立てが終わるまで待機
  5. page.addScriptTagでUserScriptの<script>タグを挿入
    • 挿入した瞬間にブラウザが UserScript を実行してくれる
  6. page.keyboardで icon-suggestion を操作
  7. 結果が期待通りか assertion

Scrapbox では、許可されたドメイン以外で配信されている JavaScript が読み込めないよう、CSP で制限をしてます。そのため、素朴に <script>タグでローカルにあるビルド済みの.jsを挿入しただけでは上手く行きません (file:///.jsは CSP 違反によりブロックされてしまう)。そこで CSP を無効化するべくbypassCSP: trueを渡してます。

CSP 環境下で E2E テストするためにアレコレしてるのが面白いと思います。

VSCode のコードジャンプの振る舞いのテスト

happy-css-modules という CSS Modules で.tsx => .module.cssへのコードジャンプを実現するツールがあります。

www.mizdra.net

.module.css.d.ts.module.css.d.ts.mapという2つのファイルをツールでコード生成しておくと、VSCode がそれをヒントに.tsx => .module.cssへとコードジャンプできるという仕組みです。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

.module.css.d.ts.module.css.d.ts.map を駆使してコードジャンプする様子。

このツールには様々な面白テストがありますが、特に面白いのは「VSCode で.tsx上のstyles.buttonを押した時に .module.css上の.button { ... }にコードジャンプすること」のテストです。

VSCode では TypeScript の Language Server に「tsserver)」が使われているのですが、それを使ってコードジャンプの挙動のテストをしています。

github.com

tsserver とのやり取りは標準入出力を介して行います。しかし、自前で書こうとすると中々骨が折れます。そこで id:mizdra@typescript/server-harness という wrapper ライブラリを使ってます。

以下のようにserver.message(...)で tsserver に渡したい入力を渡すと、tsserver からの応答がその返り値で得られます。

import serverHarness from '@typescript/server-harness';
import url from 'node:url'; 
import path from 'node:path';
import _glob from 'glob';
import util from 'node:util';
import fs from 'node:fs';
import assert from 'node:assert/strict';
import type { UpdateOpenRequest, DefinitionResponse, DefinitionRequest } from 'typescript/lib/protocol.js';

const glob = util.promisify(_glob);

const server = serverHarness.launchServer(
  url.fileURLToPath(await path.resolve('typescript/lib/tsserver.js', import.meta.url)),
  // よくわからないけど、おまじないっぽい
  ['--disableAutomaticTypingAcquisition'],
);

// tsserver に読み込ませたい .ts ファイル一覧を準備。
// fixtures ディレクトリの中に `.ts` / `.module.css.d.ts` / `.module.css.d.ts.map` が存在するという設定。
const fixtureFilePaths = await glob(path.resolve('./fixtures/**/*.ts'), { dot: true });
const openFiles: UpdateOpenRequest['arguments']['openFiles'] =
  fixtureFilePaths.map((filePath) => ({
    file: filePath,
    fileContent: fs.readFileSync(filePath, 'utf-8'),
    projectRootPath: path.resolve('./fixtures'),
    scriptKindName: 'TS',
  }));

// tsserver に .ts ファイルを読み込ませる
await server.message({
  seq: 0,
  type: 'request',
  command: 'updateOpen',
  arguments: {
    changedFiles: [],
    closedFiles: [],
    openFiles,
  },
} as UpdateOpenRequest);

// index.ts の 2 行目 8列目の定義元 (ジャンプ先) の情報を取得
const response: DefinitionResponse = await server.message({
  seq: 1,
  type: 'request',
  command: 'definition',
  arguments: {
    file: path.resolve('./fixtures/index.ts'),
    line: 2,
    offset: 8,
  },
} as DefinitionRequest);

assert.deepEqual(response, ...); // コードジャンプ先が期待通りかを検証

本当は本物の VSCode を使ってテストしたかったのですが、CI 上でネイティブアプリを動かすのが大変そうなので諦めました。その点、Language Server を使うくらいであれば、標準入出力でプロセス間通信するだけなので、まあなんとか…みたいな感じです。

レンダリングパフォーマンスのリグレッションテスト

昔 rocketimer という 60fps で動作する高パフォーマンスなカウントダウンタイマーを作ってました (訳あって未完成のまま放置しちゃってますが)。

github.com

カウントダウンがどれくらいの速度で行えるかを検証するためのベンチマークがあって、これを CI で継続して走らせて、パフォーマンスのデグレが起きていないか検証してます。

github.com

ヘッドレス chrome を使って検証をしているのですが、いくつか面白いポイントがあって...

  • Chrome を起動する時に--disable-frame-rate-limitオプションを渡してる
    • requestAnimationFrameのコールバックは通常最大 60fps の間隔で呼ばれる
      • CPUにどんなに余裕があっても、最短で約 16ms に 1 回にスロットリングされる
    • --disable-frame-rate-limitを渡すとこの上限がなくなり、CPUリソースが許す限りrequestAnimationFrameのコールバックを次々呼び出す
      • つまり 60 fps 以上出せる
    • ベンチマークスクリプトからrequestAnimationFrameのコールバックを都度登録して、それが呼び出された回数を記録する
      • 1回レンダリングが走ったら、requestAnimationFrameのコールバックが1回呼び出されるはず
      • これでアプリケーションコードに手を加えることなく、ベンチマークスクリプトだけで fps を測定できる

60 fps で頭打ちになってしまうとパフォーマンスの変動が上手く測れないので、その制限を無効化し、真の fps でパフォーマンスを計測する、というのがミソです。

メモリリークのリグレッションテスト

rocketimer には長期間カウントダウンタイマーを稼働させた時に、メモリ使用量が増え続けていないかを検証するリグレッションテストもあります。

github.com

メモリの集計にはperformance.measureUserAgentSpecificMemoryという API を使ってます。performance.measureUserAgentSpecificMemoryは (ゴミがメモリ使用量に含まれないように) GC が実行された後にメモリ使用量を集計するようになっていますが、そのせいで GC が実行されるタイミングまで集計が遅延されてしまいます。それだと好きなタイミングで集計できなくて不便なので、chrome を起動する際に--enable-blink-features=ForceEagerMeasureMemoryオプションを渡しています。こうすると、performance.measureUserAgentSpecificMemoryを呼び出した瞬間に GC とメモリ使用量が集計されます。

ところで最近 Meta がメモリリークを検出する専用のテスティングライブラリをリリースしています。今だったらこれ使って作るのが簡単だと思います。

engineering.fb.com

おわりに

これらのテストコードを書くのは大変でした…がやってみて良かったなと思っています。実際色々なテストコードを書いたことで自信と腕力が付きましたし、多くのバグの検出にも役立っていて、「テスト書く腕力鍛えるため」「個人開発のメンテナンスを楽にするため」という当初の目的は達成できたと考えてます。

また、テストタブルなインターフェースを設計するためのモデリング力・リファクタリング力が身についたり、"ミソ"を考えるための発想力がついたり、今後テストコードを書く上での手札が増えたりと、色々な副産物もありました。

一方で、メンテナンスしやすいテストコードを書く難しさをひしひしと感じました。テストコードはぐちゃぐちゃになったり、実行結果が安定しなかったり (flaky test)... また、テストに使っているツールや手法に馴染みがないと、キャッチアップが大変です。チーム開発で今回紹介したようなテストを導入するなら、こうした問題と向き合っていかなければなりません。

少しずつ趣味で学んだことを業務でも活用していきたいですね。

*1:Google Play Music はとうの昔に終了したサービスで、YouTube Music はその後継です。拡張機能の名前はつまりそういうことです...

個人開発祭り #1 で LT しました

個人開発をテーマにしたこちらのイベントで、5 分の LT をしてきました。

nota.connpass.com

今回は Scrapbox で議事録を高速に取るための UserScript「icon-suggestion」を作った話をしました。

youtu.be

scrapbox.io

個人開発というと、限られた時間と人手で開発しなければならないため、ついつい動けば良い状態になってしまいがちです。テストコードがなかったり、リファクタリングされずにコードが継ぎ足しされた結果、インターフェイスがめちゃめちゃになっていたり... 個人開発では開発者が1人なので、自分がコードを理解できていればよく、コードの品質の優先度が相対的に低くなるので、ある意味当然と言えます。id:mizdra もよく動けば良いやと書き散らしてます。

icon-suggestion の開発はあえてその逆をいってて、拘ってテストコードを書いたり、内部インターフェイスを露出させてユーザがプラグインを書けるための仕組みを丁寧に提供しています。ここに情熱を注ぐ人はそう居ないので興味深く思ってもらえるのでは...そう考えてこの話をしたのですが、狙い通り良い反応をいただけたようでした。


イベント後半の質問タイムでは、カンバンの運用の仕方や、個人開発のルーティン、テストコードどれくらい書くか、熱量を高める工夫、個人開発の成果を発表するタイミングなどについて、登壇者同士であれこれ話してました。毎日個人開発する人がいれば、熱量が出た時にガッとやる人もいる / 最後まで成果を隠す人もいれば、途中で小出しにしていく人もいる...などなど、人によってやり方や考えがまちまちで面白かったです。こちらも是非動画でご覧ください。

youtu.be

Web フロントエンドにおけるコロケーション (co-location) という考え方について

Webフロントエンド界隈の文献などにあたっていると、「コロケーション (co-location)」という考え方が時々登場します。

コロケーションを簡単に説明すると、関連するリソース同士を近くに置いておく、という考え方です。

  • FooComponent.tsx と同じディレクトリに FooComponent.test.tsx を置く
  • GraphQL fragment は、クエリを発行するコンポーネントファイル (pages/user.tsx) ではなく、fragment を利用するコンポーネントファイル (components/UserInfo.tsx) の中で定義する
    • pages/user.tsx からはサブコンポーネントのファイルで定義されている fragment を import してきて、クエリを組み立てて発行する
  • API ドキュメントは API.md に書くのではなく、コードの中にドキュメンテーションコメントとして書く
    • API ドキュメントはドキュメンテーションコメントから自動生成する

関連するもの同士を近くに置いておくことで、以下のようなメリットが得られます。

  • テストファイルの場合
    • src/in/deep/directory/FooComponent.tsxtest/in/deep/directory/FooComponent.tsx を行ったり来たりする手間から開放されます
    • テストファイルが目につきやすくなります
      • 「テストコードを書く参考にするために、他のテストファイルの書き方を見に行く」といったことがやりやすくなります
      • 実装に対応するテストがないことが、src/in/deep/directory を見るだけでわかります
  • GraphQL fragment の場合
    • コンポーネントで新たな GraphQL field が必要になったら、そのファイル内にある fragment の定義を編集するだけで、field を取得できます
    • pages/user.tsxcomponents/UserInfo.tsx を行ったり来たりする手間から開放されます
  • API ドキュメントの場合
    • API ドキュメントが API.md に書かれていると、実装を進めている間に存在を忘れてしまい、実装とドキュメントが乖離しがちです
    • 実装とドキュメントをそばに置いておけば、乖離が発生しにくくなります

色々な記事でこの考え方が紹介・参照されています。

コロケーションは保守性や可読性を向上させるため、長期的に見れば良いメリットが多く得られます。また考え方を知っておけば、「このファイルをどこに置くか」などといった開発中の悩みも軽減されるため、開発速度の向上にも繋がります。

フロントエンド界隈で注目されている考えではありますが、その他の界隈でも適用できる普遍的な考え方だと思います。

コロケーションのさじ加減

とはいえコロケーションもノーコストでできる訳ではありません。本来ページコンポーネントにベタ書きで良かったものを GraphQL fragment に切り出して持ってきたり、ドキュメンテーションコメントからドキュメントファイルを生成するツールを導入したりと、ちょっとした手間が発生します。コロケーションにすることで長期的に得られるメリットと比較すれば大したコストではないですが、短期的に見ると無視できないコストです。書きなぐるようにサクサクコーディングしたい、というケースではかえってわずらわしく感じるかもしれません。

コロケーションはあくまでリソースの配置方法に関する1つの考え方であり、プロジェクトや状況によって適切な解決策を取り入れていくのが望ましいと思います。

コロケーション (co-location) の初出

この呼び方の初出がどこなのか、探してみたのですがよく分かりませんでした。少なくとも 2015/2 に React の公式ブログにて "Co-location" という語が登場していました。

github.com

それらしき初出が見つからないので、何となく人々の間でそう呼ばれてきたものなのかもしれません。少なくとも、"co-location" の意味が整理されたのは、Kent C. Dodds *1 氏が 2019/6 に自身のブログでそれを紹介した時のようです。

kentcdodds.com

*1:testing-library 開発者、Testing Trophy 提唱者

React コンポーネントの定義の仕方によって VSCode の定義元ジャンプの挙動が変わる

追記 (2024-05-13)

この不具合は #57969 で修正されました。まだ Stable 版には入ってませんが、Nightly 版には既に取り込まれています。実際に TypeScript Playground で Nightly 版を使ってみたところ、コンポーネントを定義している箇所へワンクリックでジャンプできるようになっていました。


この記事は「はてなエンジニア Advent Calendar 2022」の3日目の記事です。2日目は id:pokutuna さんの「Slack チャンネルのロボット帝国化を防ぐ feed-pruning-proxy」でした。

blog.pokutuna.com


さて、TypeScript で React コンポーネントを定義する時、皆さんはどういう書き方をしてますか? 関数宣言/アロー関数どちらを使って書くか、React.FC を使うかどうか、など微妙に人によって書き方が異なると思います。

その中でも、よく使われるのは以下の 3 つのスタイルでしょうか。

import React from "react";

type ButtonProps = {
  children: React.ReactNode;
};

// 関数宣言。
function Button1({ children }: ButtonProps) {
  return <button>{children}</button>;
}

// アロー関数 + 引数リストに型注釈。
const Button2 = ({ children }: ButtonProps) => {
  return <button>{children}</button>;
}

// アロー関数 + 変数宣言に型注釈。
// 返り値の型が `JSX.Element | null` に制限されるという点で、
// `0` などを返される心配がなくて安全。
const Button3: React.FC<ButtonProps> = ({ children }) => {
  return <button>{children}</button>;
};

型の付け方や厳密さ、使っている構文が違うくらいで、どれも普通に Button コンポーネントとして使えます。最後の「アロー関数 + 変数宣言に型注釈」スタイルだと返り値の型が厳密になるので、このスタイルで書いている方は結構いらっしゃるのではないかと思います。

...しかしこの最後の「アロー関数 + 変数宣言に型注釈」スタイルは、VSCode の定義元へジャンプする機能の挙動が他と違います 。具体的には、他のスタイルと比べてジャンプ先を選択するステップが挟まり、ワンクリックでジャンプできないようになってます。

youtu.be

皆さんご存知でしたか?

お試し会場

(利用するエディタによって再現できるかどうか変わってくるのかもしれないですが) この問題は VSCode だけでなく、Codesandbox でも再現する問題です。

という訳で以下に Codesandbox で動く環境を用意してみました。こちらから皆さんもお試しください:

お手元の VSCode で試したい方は、以下のリポジトリから git clone してみてください。

github.com

一体何が起きているのか

定義元ジャンプをしようとした時の画面をよく見てみると、我々が期待しているジャンプ先 (const Button3 = ... の行) に加えて、 node_modules/@types/react にある FunctionComponent (React.FC の別名) の型定義の行も候補として出ています。

3番目の書き方で定義元が 2 つ表示されている様子

原因はよくわからないのですが、どうやら const Button3 = ...node_modules/@types/react 側の React.FC の型定義の両方が<Button3> の定義元として認識されてしまっているようです。定義元が複数あるので、どちらにジャンプするかを開発者に選択させる必要があり、あのようなステップが挟まってしまっているようです。

正直これがバグなのか、仕様なのか id:mizdra には判断つかなかったのですが、公式 Issue では Bug ラベルが付いていたので、一応バグとして扱われているようです。

github.com

回避策

実際のところ、node_modules/@types/react 側の React.FC の型定義を開発者が見たいことはないですし、この挙動は煩わしいでしょう。そこでこの問題の回避策をいくつか紹介したいと思います。

複数の定義元を選択させる UI が出たら、Enter を押す

回避策...というより対症療法的な話ですが、複数の定義元を選択させる UI が出た際に Enter を押せば、すぐに期待している定義元へとジャンプできます。

Enter を押すと即座に期待している定義元へとジャンプできる。

VSCode の Editor > Goto Location: Multiple Definitionsgoto にする

Editor > Goto Location: Multiple Definitions という設定を弄ると、複数の定義元があった時の VSCode の定義元ジャンプの挙動を変更できます。

デフォルトの設定値は peek となっていて、gotogotoAndPeek に変更すると、ワンクリックでジャンプできるようになります。

  • peek: どの定義元にジャンプするか選択する UI を表示 => クリックでジャンプ
  • goto: 最も優先度の高い定義元にジャンプする
    • const Button3 = ... が最も優先度の高い定義元なようで、ワンクリックでジャンプできる
  • gotoAndPeek: 最も優先度の高い定義元にジャンプし、ジャンプ先で peek と同じ UI を表示

この設定は React コンポーネント以外の定義元ジャンプでも使われる、影響範囲の広い設定です。もしかしたら goto にしてしまうことで、peek のように候補を選んでジャンプしたかったのに、それができない…というケースがあるかもしれません。

一応「左クリックメニュー > ピーク > 定義をここに表示」から、従来のように定義元を複数表示することができます。もし定義元を複数をたければ、これを使うと良いと思います。

「左クリックメニュー > ピーク > 定義をここに表示」から定義元を複数表示できる

vscode-tsx-arrow-definition を使う

node_modules/@types/react を候補先から除外して、ワンクリックでジャンプできるようにする VSCode 拡張機能があるようです。

marketplace.visualstudio.com

これだと手軽ですし、影響範囲も React コンポーネントの定義元ジャンプに絞れるので良さそうですね。

他のスタイルで React コンポーネントを定義する

VSCode の設定や VSCode の拡張機能に頼る形だと、特定のエディタでだけ対応する形になって好きじゃないな…という理由で id:mizdra は「関数宣言」スタイルで書くようにしています。「アロー関数 + 変数宣言に型注釈」スタイルと比較すると、返り値の型は厳密に扱えませんが...多くの場合コンポーネントを利用する側から型違反として検出できるので、問題ないと判断してます。

import React from 'react';

// 誤って number 型を返しているコンポーネント
function Button() {
    return 0;
}

function App() {
    return (
        <div>
            <Button />
//           ^^^^^^
// 'Button' cannot be used as a JSX component.
// Its return type 'number' is not a valid JSX element.(2786)
        </div>
    )
}

関数の返り値の型を明示的に書く規約を取り入れているプロジェクトでもこのスタイルで書いてますが、VSCode の Infer function return type で返り値の型を補完できるので、それほどストレスは感じてないです。

VSCode の Infer function return type で返り値の型を補完できる

おまけ: satisifies を使って React コンポーネントを定義する

TypeScript 4.9 で追加された satisifies を使うと、React.FC 型と互換性を持たせつつ、より厳密な型の値を定義できます (id:gfx さんより情報提供いただきました。ありがとうございます!)。

// アロー関数 + satisfiesで制約を加えつつ正確な型を表現する。
// 安全だが関数本体をカッコで囲む必要がある。また TypeScript 4.9 時点では定義元ジャンプもワンクッションあり。
// Button3 だと返り値の型が `JSX.Element | null` になったが、こちらの書き方だと返り値の型の推論結果が優先されて `JSX.Element` になる。
const Button4 = (({ children }) => {
  return <button>{children}</button>;
}) satisfies React.FC<ButtonProps>;

一見するとワンクリックで定義元へとジャンプできるように見えますが…実はできません。

satisfies を使っても、ワンクリックで定義元へとジャンプできない。

こちらは 「アロー関数 + 変数宣言に型注釈」 と違って、「Button4({ children }) => { ... } が定義元である」と認識されているようです。node_modules/@types/react の型定義は出てこなくなったものの、satisfies が新たに定義元を作り出してしまい、ワンクリック挟まる形になってしまいます。上手くいかないものですね。

まとめ

  • 「アロー関数 + 変数宣言に型注釈」スタイルだとワンクリックで定義元にジャンプできない
  • VSCode の設定を変える/VSCode 拡張機能を使う/他のスタイルでコンポーネントを定義する、などで回避可能

はてなエンジニア Advent Calendar 2022 の明日の担当は id:happy_siro さんです!

Prisma で本物のDBMSを使って自動テストを書く

DBMS に依存するロジックのテストを書く時、主に2つの手法があると思います。

  1. Repository 層などを mock する
    • Service 層のテストをする時は、その下位の Repository 層を mock して、DBMS に依存しない形にしてからテストする
    • レイヤードなアプリケーションで適用できる手法
  2. テスト実行時も DBMS を裏で動かして、それを使う
    • 本番と同じスキーマを持つ DBMS に対して、実際に insert したり select してテストする
    • DBMS は docker-compose upとかで事前に立ち上げておく

双方にそれぞれ良さがあって、プロダクトによってどっちでやるか変わってくると思います。

この記事では 2 の手法を Prisma でどうやるかについて紹介します。

前提

  • ORM には Prisma を使う
  • 言語は TypeScript
  • テスティングフレームワークは Jest
  • DBMS は MySQL

実際のテストコードの例

何はともあれ、まず実際のテストコードから。

// src/post/post.service.ts
import { UserHelper } from '../user/user.helper';
import { PostHelper } from './post.helper';
import { PostService } from './post.service';
import { prisma } from '../prisma/prisma.helper';

const postService = new PostService(prisma);

describe('getDraftPostsByUser', () => {

  it('ドラフト記事のみを返す', async () => {
    // User、Post レコードを helper を使って作成する
    const user = await UserHelper.create();
    const posts = [
      await PostHelper.create({ authorId: user.id, published: true }),
      await PostHelper.create({ authorId: user.id, published: false }),
      await PostHelper.create({ authorId: user.id, published: false }),
    ];
    // テスト対象のコードを呼び出す
    const actual = await postService.getDraftPostsByUser({ user });
    // 事前に作ったレコードと、引いてきたレコードが一致すれば OK
    expect(actual).toStrictEqual([posts[1], posts[2]]);
  });
  it('指定されたユーザの記事のみ返す', async () => {
    const user1 = await UserHelper.create();
    const user2 = await UserHelper.create();

    const post1 = await PostHelper.create({ authorId: user1.id, published: false });
    await PostHelper.create({ authorId: user2.id, published: false });

    const actual = await postService.getDraftPostsByUser({ user: user1 });
    expect(actual).toStrictEqual([post1]);
  });
});

参考までに、テスト対象のコードは以下です:

// src/post/post.service.ts
import { PrismaService } from '../../prisma.service';
import { Post, Prisma, User } from '@prisma/client';

export class PostService {
  constructor(private prisma: PrismaService) {}

  /** あるユーザのドラフト記事一覧を取得する */
  async getDraftPostsByUser({ user }: { user: User }): Promise<Post[]> {
    return this.prisma.post.findMany({
      where: {
        published: false,
        authorId: user.id,
      },
    });
  }
}

以下いくつかのポイントについて解説します。

テストヘルパーを作る

毎回全 field を指定してレコードを作成するのは大変なので、ある程度自動で設定してくれるヘルパーを作ると良いです。

// 大変
const post = await prisma.post.create({
  data: { title: '記事1', content: '本文1', published: true },
});
// ヘルパー経由でサクッと作れると良い。title や content などは自動でランダムに埋める。
const post = await PostHelper.create();
// 一部 field を固定したければ、呼び出し側から渡せるように。
const user = await UserHelper.create({ title: '面白い記事', published: false });

これは faker.js を使うと簡単に作れます。

// src/post/post.helper.ts
import { Post } from '@prisma/client';
import { faker } from '@faker-js/faker';
import { UserHelper } from '../user/user.helper';
import { prisma } from '../prisma/prisma.helper';

/**
* @file Post feature のテストヘルパー。
*/

export const PostHelper = {
  /** 記事を作成する。 */
  async create(args?: Partial<Post>): Promise<Post> {
    // authorId がなかったら、userHelper でその場で作る。
    // いちいち author を作らなくても記事を作れるようにする配慮です。
    const authorId = args?.authorId ?? (await UserHelper.create()).id;

    return await prisma.post.create({
      data: {
        title: faker.random.words(),
        content: faker.random.words(),
        authorId,
        ...args,
      },
    });
  },
};

createPostではなく、PostHelper.createのように namespace の下に定義しておくと、他のモジュールから補完をバチバチに効かせて利用できて便利です。VSCode でPostH まで type すると、PostHelperが補完候補に出てきて、選択すると import { PostHelper } from './post.helper'; を自動挿入してくれます。

あとはいちいちconst postHelper = new PostHelper(prisma);しなくてもヘルパーが使えるように、prisma.helper.ts側でシングルトンな PrismaClient を用意して、それを使い回すようにしてます。シングルトン使うのやんちゃな感じはしますが…まあテストなので良いかなと思ってやってます。

別解: ヘルパーを自動生成する

最近登場した @quramy/prisma-fabbrica を使うと、schema.prismaからヘルパーを自動生成してくれます。default field の生成方法も手動で決められますし、relation もちゃんと扱えます。これ使えば事足りそうな気がします。

github.com

他にも node_modules/.prisma/client/index.d.ts を元に TypeScript Compiler API を使って自動生成するアプローチもあるようです。

zenn.dev

jest worker ごとに database を分ける

Jest はデフォルトで 複数のプロセス (Jest の用語では worker) を立ち上げて、並列でテストを実行します。便利な機能ですが、並列に DB アクセスがあると競合して、テストが落ちてしまう恐れがあります。

そこで id:mizdra は worker ごとに database を分けることで、並列でテストが走っても競合が起きないようにしています。JEST_WORKER_ID で、そのテストを実行している jest worker の ID が取れるので、これを database の名前に利用します。

// jest.setup.ts
// Jest の設定の setupFilesAfterEnv から参照されているファイル

import { execSync } from 'child_process';
import { TEST_DATABASE_URL } from './src/prisma.helper';

// TEST_DATABASE_URL には 'mysql://user:password@localhost:3306/db' のような文字列が入っている想定。
process.env.DATABASE_URL = `${TEST_DATABASE_URL}-test-${process.env.JEST_WORKER_ID}`;

beforeAll(() => {
  // テストを実行する前に、前のテストで insert されたレコードを削除しつつ、スキーマも最新のものに更新する。
  execSync('npx prisma migrate reset --force --skip-seed', {
    // 公式ドキュメントでは process.env を継承することになってるけど、
    // 何故か実行時に process.env を書き換えて追加した環境変数は継承してくれないっぽい (おそらく Node.js のバグ)。
    // - https://nodejs.org/api/child_process.html#child_processexecsynccommand-options
    // 仕方がないので、明示的に上書きしたものを渡してる
    env: {
      ...process.env,
    },
  });
});

また、サンプルコードにも書きましたが、テストの実行前には毎回 DB リセットを挟むと良いです。child_process.execSyns('npx prisma migrate reset --skip-seed')が楽…なのでそうやってますが、プロセスの起動や Node.js のランタイムの初期化分のコストが掛かり、ちょっと遅くて微妙だなあと思っています。@prisma/clientからprisma.migrateReset()みたいな API が生えてればよいのですが… 他の方法があったら教えて下さい。

seed を固定する方法を用意しておく

faker.js を使ってテストデータをランダムにするのは、テストの網羅性という観点では良いですが、確率的に失敗するテストの原因になるという視点ではイマイチです。CI でテストが落ちても、ローカルでその状況を再現できず、デバッグが困難になりがちです。

そこで faker.js の seed を固定する方法を用意しておいて、同じ seed でテストを走らせれば、同じ結果になるようにすると良いです。

// jest.setup.ts
import { faker } from '@faker-js/faker';

// faker.js の seed を表示・固定する
const seed = process.env.FAKER_SEED ? faker.seed(+process.env.FAKER_SEED) : faker.seed();
console.log(`faker's seed: ${seed}`);
$ npm run test
PASS  src/user.service.spec.ts
  ● Console

    console.log
      faker's seed: 4574908056596453

      at Object.<anonymous> (jest.setup.ts:7:9)

PASS  src/post.service.spec.ts
  ● Console

    console.log
      faker's seed: 8259967698580163

      at Object.<anonymous> (jest.setup.ts:7:9)

FAKER_SEED=xxx npm run test で seed を固定できる:

$ FAKER_SEED=4122141554878194 npm run test
PASS  src/user.service.spec.ts
  ● Console

    console.log
      faker's seed: 4122141554878194

      at Object.<anonymous> (jest.setup.ts:7:9)

PASS  src/post.service.spec.ts
  ● Console

    console.log
      faker's seed: 4122141554878194

      at Object.<anonymous> (jest.setup.ts:7:9)

DB を truncate する方法を用意しておく

サンプルコードでは出てきませんでしたが、あるテーブルのレコードを全部引いてくる系の API のテストをしたくなるような時があると思います (PostService#getAllPostsなど)。その場合、既存のレコードがあるとテストが落ちてしまうので、事前にレコードを全部削除しておく必要があります。

そこで id:mizdraPrismaServiceに truncate するメソッドを実装してます。

// src/prisma/prisma.helper.ts
import { Prisma } from '@prisma/client';
import { PrismaService } from './prisma.service.ts';

export class TestablePrismaService extends PrismaService {
  // ...
  async clearDatabase(): Promise<void> {
    // prisma クライアントに生えているモデル一覧
    const modelNames = Prisma.dmmf.datamodel.models.map((model) => model.name) as Prisma.ModelName[];

    await this.$queryRaw`SET FOREIGN_KEY_CHECKS=0`;

    for (const modelName of modelNames) {
      await this.$queryRawUnsafe(`TRUNCATE TABLE ${modelName}`);
    }

    await this.$queryRaw`SET FOREIGN_KEY_CHECKS=1`;
  }
}

export const prisma = new TestablePrismaService(); // テスト用のシングルトン

外部キー制約により削除ができないことがあるので、一時的に外部キー制約を無効化しているのがポイントです。

別解: そもそも他のテストケースのレコードが残らないようにする

@quramy/jest-prisma を使うと、describeitのスコープ内で作成したレコードが、そのスコープから抜けた時に自動で削除されるようになります (技術的にはスコープに入った時に transaction を貼って、抜けた時に rollback してます)。これで他のテストケースのレコードが残ることはほぼ無くなるので、clearDatabaseの代わりになるかもしれません。

github.com

とはいえどうしてもclearDatabaseしないといけない状況はありそう。

おまけ: 自転車置き場の議論のコーナー

mock を使ったテスト vs mock を使わないテスト

  • どっちが世の中的に主流なんですかね
  • id:mizdra 的には基本 mock を使わない書き方のほうが好き
    • mock すれば DB のことは意識しなくて済む
    • しかしテスト対象の API が他のどんな API に依存しているのか、それらからどんな値を返すのが相応しいかを考えないといけない
    • 例えば PostGraphQLResolver のとあるメソッドのテストを mock を使って書きたければ...
      • まずそのメソッドを叩いた時にどんな Service/Repository 層の API が呼び出されるかを調べる必要がある
        • PostRepository#getPostByID が呼び出されるのでそれを mock しようとか
        • PostService#getPostByUser が呼び出されるのでそれを mock しようとか
        • あるいは prisma.post.findUnique が呼び出されるのでそれを mock しようとか
      • mock するもの決まったので mock しようとなっても、どういう返り値を返すのが相応しいかを考える必要がある
        • PostService#getPostByID だったら Prisma.Post 型を返すので、それに合った値を返そうとか
    • 依存関係を調べるのも手間ですし、(ダミーの値を返すだけで良いとはいえ) mock の実装を書くのも少し手間
    • Service/Repository 層のバグが、上位の層に影響して、上位の層のテストが落ちるということもよくあると思うけど、mock を使うとそうしたバグも検出できなくなってしまう
      • 本当は外部キー制約に違反するけど、mock でそれが覆い隠されてしまうとか
  • 逆にどういう時 id:mizdra は mock を使うかというと...
    • 上位の層から作っていきたい時
      • 下位層はインタフェースだけ用意しておいて、実装は空にしておく
      • テストでは下位層を mock して、上位層のテストをする
      • 何らかの事情でトップダウンで開発したい時に便利な手法
    • テスト環境では上手く動かないものの実装をすり替えたい時
      • 外部 API に接続するところとか、認証とか
      • 無理やり mock してテストできるようにするパターン
    • 下位層でテストしていることを、上位層でテストしたくない時
      • PostService#getNewest10PostsというメソッドがPostRepository#getPosts({ limit: 10, order: 'desc' })を呼び出していると仮定
      • PostService#getNewest10Postsのテストは以下を検証するだけにする
        • PostRepository#getPostsを1回呼び出している
        • PostRepository#getPostsに適切な引数を渡している
      • それ以外の振る舞いはPostRepository#getPostsのユニットテストでテストされているのでやらなくて良い、と割り切る
  • id:mizdra の社内でも mock 使わないテストが主流だった
  • 皆さんがどうしているのか知りたい!

追記 (2024-02-23)

mock などを駆使し、テスト対象を他のコンポーネントから切り離してからテストする手法を Solitary Tests、切り離さずテストする手法を Sociable Tests と呼ぶそうです。

martinfowler.com

テストデータをランダム生成する vs ランダム生成しない

  • 記事で述べたように、テストデータをランダムにするのはメリットとデメリットがある
    • Pros: テストの可読性を保ちながら、効率的に問題を検出できる
    • Cons: flaky tests の温床になる
    • Cons: 乱数次第ではバグが検出されず、そのまま本番にリリースされてしまう可能性がある
      • CI で運悪く pass するケースのテストデータでしか検証されず、バグに気づけないとか
    • Cons: Jest のスナップショットテストと相性が悪い
      • ランダムなデータだと毎回差分ができてしまう
  • id:mizdra としては、個々の問題は個別に対応すれば気にはならないのではと思っている
    • seed を固定する方法を用意しておくとか
    • スナップショットテストする時はテストデータを固定すれば良い
    • 運悪く pass してしまうのはどうしようもないけど…そもそも固定方式でテストデータを網羅的に書くのも難しいので、どっこいどっこいな気もする
  • 皆さんがどうしているのか知りたい!

あわせて読みたい

www.mizdra.net

追記 (2022-12-07)

id:Quramy さんが jest-prisma と prisma-fabbrica についての記事を公開されてました。こちらもあわせて読んでみると良いかと思います。

quramy.medium.com

コードジャンプ可能な CSS Modules を実現する happy-css-modules の紹介

弊社では React で CSS を書くための手法として CSS Modules を全面的に採用しています。そこで CSS Modules を使った開発をより快適にするために、「happy-css-modules」というツールを作りました。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。
happy-css-modules のデモ。

この記事ではこのツールが必要になった背景、導入方法、そしてツールの技術的な仕組みについて紹介します。

CSS Modules の問題点と、typed-css-modules による解決

CSS Modules では、デフォルトでは存在しないクラス名を使用しても、(プロジェクトの設定次第ですが) TypeScript のコンパイルエラーが出ることはありません。

import styles from './Button.module.css';

function Button() {
  return (
    <button
      className={styles.botton}>
  //             ^^^^^^^^^^^^^ (u を o に typo してる)
  // (プロジェクトが用意している型定義ファイルによるが) create-react-app で作られたプロジェクトでは
  // 存在しないクラス名も実在するかのように見せかける緩い型定義ファイルがあるため、
  // styles.undefined_selector は string になる。そのためコンパイルエラーにもならない。
  // ref: https://github.com/facebook/create-react-app/blob/f99167c014a728ec856bda14f87181d90b050813/packages/react-scripts/lib/react-app.d.ts#L58-L61
      Click me!
    </button>
  );
}

そこでこの問題を解決するために、typed-css-modules というツールが存在します (sass/less 向けに、それぞれ typed-scss-modules/typed-less-modules というツールもあります)。このツールを使うと、*.module.cssファイルに定義されているクラス名を検出し、そのクラス名にのみアクセス可能な型定義ファイルを生成できます。これにより未定義なクラス名を参照したとしても、コンパイルエラーとして検出できます。

// Button.module.css
.button { border: 1px solid #333; }
.text { color: red; }
// Button.module.css.d.ts

// typed-css-modules により自動生成される型定義ファイル
declare const styles: {
  readonly button: string;
  readonly text: string;
};
export = styles;
// Button.tsx
import styles from './Button.module.css';

function Button() {
  return (
    <button
      className={styles.botton}>
  //             ^^^^^^^^^^^^^ (u を o に typo してる)
  // styles の型には botton プロパティが生えていないので、styles.botton はコンパイルエラーになる。
      Click me!
    </button>
  );
}

よくあるミスを防げるツールなので、CSS Modules を使用するプロジェクトでは一緒に導入していることが多いと思います。

無意味なコードジャンプ

ただし、typed-css-modules ではクラス名で定義元ジャンプをしようとしても、.module.cssファイルではなく、.module.css.d.tsファイルにジャンプしてしまいます。

typed-css-modules では、TSX ファイルから CSS Modules のクラス名を Command+Click すると.module.css.d.ts にコードジャンプしてしまう様子の動画。
typed-css-modules では .module.css.d.ts にコードジャンプしてしまう。

些細な問題のように感じますが、デザインコーディングをする時は、TSX と.module.cssの間を行ったり来たりすることが多いので、少し不便だなと感じていました。

そもそも.module.css.d.tsにジャンプしても、そこから得られるものはほぼ無いです! せっかくならもう少し有益なものへとジャンプさせて、開発体験の向上を図りたい。id:mizdra はそう考えて、この問題を解決するツールを作ることにしました。

happy-css-modules の紹介

そして作ったものが、冒頭で紹介した「happy-css-modules」です。

TSX ファイルから CSS Modules のクラス名を Command+Click して、.module.css ファイルの定義場所にコードジャンプしている様子の動画です。冒頭に掲載した動画と同じものです。
happy-css-modules のデモ。冒頭に掲載した動画と同じものです。

CSS Modules から import したクラス名を Meta+Click (Mac なら Command+Click、Windows なら Control+Click) で、CSS Modules 側の定義場所へとコードジャンプできます。

導入方法

GitHub の Usage 通りにやってもらえたら導入できると思います。

幅広いユーザのサポートを第一に設計しているため、PostCSS/Sass/Less を使っているプロジェクトにもデフォルトで対応しています。Webpack のresolve.aliasや、Sass の--load-pathオプションを使っているプロジェクトにも、CLI オプションを渡すことで対応できます。

社内では Less/Sass プロジェクトにそれぞれ導入済みで、問題なく動作してます。もし動かないなどあれば https://github.com/mizdra/happy-css-modules/issues から報告してもらえるとありがたいです。


ツールを使いたい方向けの説明はここまでで、以降は happy-css-modules の技術的な解説や、実装の裏側についての話です。

どうやってコードジャンプを実現しているのか

happy-css-modules は .module.css.d.tsを生成するのに加えて、.module.css.d.ts.mapファイルも生成しています。このファイルはgenerated (.module.css.d.ts) <=> source (.module.css) 間のコードの対応を保持している Source Map です。tsserver (VSCode 向けの TypeScript の Language Server) が.module.css.d.ts上のコードにジャンプしようとしたときに、この Source Map を元に.module.css上のコードにマップし直して.module.css側に直接ジャンプできます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

Source Map を駆使してジャンプ先を .module.css に切り替える仕組み。Source Map の内容は分かりやすいよう、実際のものから簡略化してます。

同名のクラスが複数ある場合は、ちょっとしたハックを利用してます。というのも、Source Map の仕様上、.module.css.d.ts 上の1つの位置情報 <=> .module.css 上の複数の位置情報の対応を持たせられません (generated:original = 1:1 や 多:1 はできるけど、1:多 は不可能)。そこで、同名のクラス名の型をオブジェクトリテラル型で複数定義しつつ、Intersection Types (a & b) で合成したものを styles の型とし、それぞれのオブジェクトリテラルごとに別々の .module.css 上のクラス定義へとマッピングします。これで同名のクラスが複数あっても、tsserver が複数のジャンプ先を suggest してくれます。

(generated)
(generated)
(original)
(original)
① button プロパティの
型定義にジャンプ
① button プロパティの...
② 元の位置情報を
Source Mapから計算
② 元の位置情報を...
③ 元の位置へと
ジャンプ
③ 元の位置へと...
Viewer does not support full SVG 1.1

同名のクラスが複数ある場合のジャンプ先切り替えの仕組み。

happy-css-modules の着想・作ろうと思ったキッカケ

実はこの型定義ファイルを generated、型定義を生成する元になったファイルを original とする Source Map を作成するアイデアは、tsc (TypeScript コンパイラ) が --declarationMap として先に実装していて、Declaration Map と呼ばれてます。

id:mizdra が初めてこの技術を知った時、「何か面白いことに使えるのではないか」と思いました。Declaration Map を使うと、本来であれば型定義ファイルにコードジャンプするところを、別の場所にジャンプするよう変更できます。つまり、型定義ファイルが何かしらのファイルから自動生成されるような状況であれば、この技術を応用できるはずーー そう考えて、happy-css-modules の着想を得ました。

というものの、CSS Modules でコードジャンプをする実現するツールは、(一部機能に違いはありますが) すでにいくつかありました。

そのため、作ったところで需要はそこまでなかったのですが...面白い技術が狙い通りに応用できるかどうしても試したかった... つまり技術的な好奇心が作ろうと思ったキッカケです。

やるからには上手くやる

せっかく車輪の再発明をするのだから作るのだから、より良いものを作ろう!*1と考えて、以下の目標を置きました。

  • 任意の AltJS をサポートする
    • どうせやるなら全部サポートできるようにしたい
    • 便利な機能を全部の言語に提供できたら、最高のはず
    • typed-css-modules/typed-scss-modules/typed-less-modules をまとめて置き換えられると良い
  • Webpack 互換の import specifier (@import "....";"..." の部分) の解決アルゴリズムにできるだけ準拠する
    • Webpack で CSS を bundle しているユーザが、簡単に導入できるようにしたい
  • import alias もサポートする
    • @ import "@style-dir/global.css"; のように alias を使っているユーザもサポートしたい
  • とにかく使いやすくする
    • 設定ファイル無し、CLI オプションも極力無しで、可能な限り簡単に使えるものを目指す
  • 拡張性を持たせる
    • プログラマブルな API を提供して、いざとなったら "なんでもできる" ようにしたい
  • テストを充実させる
    • 趣味プロダクトはたまにしか触らないので、新機能の実装やバグの修正をするにしても、プロダクト壊してしまわないか、ついつい不安になってしまう
    • テストが充実していれば、コードの変更も気軽に自信を持ってできるようになり、メンテナンスもしやすくなるはず

そういう訳で happy-css-modules は以下のような実装になってます。

欲張りな目標でしたが、車輪の再発明なりに、上手くできたかなと思ってます。是非興味があればコードを読んでみてください。

まとめ

happy-css-modules を導入することで、typed-css-modules のようにミスを防ぎつつ、かつコードジャンプによって快適に開発できます。二番煎じなツールではありますが、従来のものと比較して様々なプロジェクトに簡単に導入できるようこだわって設計してます。是非導入してみてください!happy-css-modules や stylelint-no-unused-selectors を導入すれば CSS-in-JS と大差のない開発体験になりますし、これを機に CSS Modules を使ってくれる人が増えると良いなと思っています。

あわせて読みたい

developer.hatenastaff.com

ある npm package がどこから依存されているか調べる方法

このパッケージってどこからどう依存されてるんだっけ、と調べる時によく打ってるのでメモ。

npm

npm v7 以降であれば npm explain <package> というサブコマンドでできる。npm v7 以前は npm-whynls を別途インストールしてきて、それを使う必要があった。ちなみに npm why という名前の npm explain の alias も用意されている。

$ npm explain ts-node
ts-node@10.9.1 dev
node_modules/ts-node
  dev ts-node@"^10.9.1" from the root project
  peerOptional ts-node@">=9.0.0" from jest-config@28.1.3
  node_modules/jest-config
    jest-config@"^28.1.3" from @jest/core@28.1.3
    node_modules/@jest/core
      @jest/core@"^28.1.3" from jest@28.1.3
      node_modules/jest
        dev jest@"28.1.3" from the root project
        peer jest@"^28.0.0" from ts-jest@28.0.8
        node_modules/ts-jest
          dev ts-jest@"28.0.8" from the root project
      @jest/core@"^28.1.3" from jest-cli@28.1.3
      node_modules/jest-cli
        jest-cli@"^28.1.3" from jest@28.1.3
        node_modules/jest
          dev jest@"28.1.3" from the root project
          peer jest@"^28.0.0" from ts-jest@28.0.8
          node_modules/ts-jest
            dev ts-jest@"28.0.8" from the root project
    jest-config@"^28.1.3" from jest-cli@28.1.3
    node_modules/jest-cli
      jest-cli@"^28.1.3" from jest@28.1.3
      node_modules/jest
        dev jest@"28.1.3" from the root project
        peer jest@"^28.0.0" from ts-jest@28.0.8
        node_modules/ts-jest
          dev ts-jest@"28.0.8" from the root project

実際にインストールされているバージョンに加えて、@^a.b.c みたいに、どういうバージョン制約で依存されているのかまで出るのが便利 (この制約のせいで major バージョンアップができない…みたいなのがひと目で分かる)。

yarn

yarn why <package> でできる。こちらは昔からある。

$ yarn why minimist
yarn why v1.22.19
[1/4] 🤔  Why do we have the module "minimist"...?
[2/4] 🚚  Initialising dependency graph...
[3/4] 🔍  Finding dependency...
[4/4] 🚡  Calculating file sizes...
=> Found "minimist@1.2.5"
info Has been hoisted to "minimist"
info Reasons this module exists
   - Hoisted from "json5#minimist"
   - Hoisted from "netlify-lambda#babel-loader#mkdirp#minimist"
   - Hoisted from "netlify-lambda#webpack#mkdirp#minimist"
   - Hoisted from "webpack-dev-server#portfinder#mkdirp#minimist"
   - Hoisted from "workbox-webpack-plugin#workbox-build#@surma#rollup-plugin-off-main-thread#json5#minimist"
info Disk size without dependencies: "104KB"
info Disk size with unique dependencies: "104KB"
info Disk size with transitive dependencies: "104KB"
info Number of shared dependencies: 0
=> Found "mkdirp#minimist@0.0.8"
info This module exists because "mkdirp" depends on it.
info Disk size without dependencies: "72KB"
info Disk size with unique dependencies: "72KB"
info Disk size with transitive dependencies: "72KB"
info Number of shared dependencies: 0
=> Found "tsconfig-paths#minimist@1.2.0"
info Reasons this module exists
   - "eslint-plugin-import#tsconfig-paths" depends on it
   - Hoisted from "eslint-plugin-import#tsconfig-paths#json5#minimist"
info Disk size without dependencies: "96KB"
info Disk size with unique dependencies: "96KB"
info Disk size with transitive dependencies: "96KB"
info Number of shared dependencies: 0
=> Found "netlify-lambda#json5#minimist@1.2.0"
info This module exists because "netlify-lambda#webpack#loader-utils#json5" depends on it.
info Disk size without dependencies: "96KB"
info Disk size with unique dependencies: "96KB"
info Disk size with transitive dependencies: "96KB"
info Number of shared dependencies: 0
=> Found "babel-loader#json5#minimist@1.2.0"
info This module exists because "netlify-lambda#babel-loader#loader-utils#json5" depends on it.
✨  Done in 0.23s.

yarn whynpm why と違って、どういうバージョン制約で依存されているのかまでは出ない。バージョン制約を調べたければ、cat node_modules/tsconfig-paths/package.json | jq .dependencies.minimist とかやって頑張るしかないはず (もっと良い方法あれば教えて下さい)。

$ cat node_modules/tsconfig-paths/package.json | jq .dependencies.minimist
"^1.2.0"

pnpm

pnpm why <package> でできる。

$ pnpm why postcss
Legend: production dependency, optional only, dev only

happy-css-modules@0.4.0 /Users/mizdra/src/github.com/mizdra/happy-css-modules

dependencies:
postcss 8.4.17
postcss-modules 4.3.1
├── postcss 8.4.17 peer
├─┬ postcss-modules-extract-imports 3.0.0
│ └── postcss 8.4.17 peer
├─┬ postcss-modules-local-by-default 4.0.0
│ ├─┬ icss-utils 5.1.0
│ │ └── postcss 8.4.17 peer
│ └── postcss 8.4.17 peer
├─┬ postcss-modules-scope 3.0.0
│ └── postcss 8.4.17 peer
└─┬ postcss-modules-values 4.0.0
  ├─┬ icss-utils 5.1.0
  │ └── postcss 8.4.17 peer
  └── postcss 8.4.17 peer

だいたい yarn why と同じだけどこちらのほうがスッキリしてる。あと依存の種類 (dependencies なのか devDependencies なのか peerDependencies なのか) が出ているのが特徴。

ちなみに pnpm why -D <package>devDependencies に絞ったりもできる。

まとめ

  • <npm|yarn|pnpm> why と打てば良い
  • npm why はどういうバージョン制約で依存されているのかまで出て便利

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.