mizdra's blog

ぽよぐらみんぐ

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 はどういうバージョン制約で依存されているのかまで出て便利

qwik の発明、及びマイクロフロントエンドへの活用について

最近調べた qwik というライブラリが結構面白かったので、実際どういうものなのかとか紹介してみます。

qwik とは

qwik は Web 向けの View ライブラリです (React や Vue.js の仲間)。パフォーマンスオタクがパフォーマンスの最適化 (Web Vitals の改善) にこだわって作ったライブラリです *1

すでにいくつも良い紹介資料があるので、まずはこれらをいくつか読んでみると良いと思います。

qwik の詳しい使い方などは先人の記事に譲ることにして、以降は id:mizdra が個人的に面白いと思ったことを書いていきます。

Hydration の問題点

最近は SEO であったりページの初期表示に掛かる時間 (FP/FCP/LCP など) の改善のために、SSR をするのが一般的になっていると思います。SSR では、ユーザからリクエストを受け取った時、サーバー上で React コンポーネントツリーをレンダリングして HTML へとシリアライズし、それがレスポンスとして返されます。HTML 中に最初からコンテンツが描画されているため、CSR と比較すると、コンテンツを早いタイミングでユーザに表示できます。

しかし、@aiji42 さんの Qwikの基本概念である Resumable を理解する で語られているように、SSR には Hydration という処理をどうしても挟む必要があります。ページ中に存在する全てのコンポーネントのソースコードを DL して、それを実行して、(onClick プロパティなどで渡された) イベントリスナーを DOM に登録していきます。コンポーネントのソースコードの DL や評価、などなど様々な計算が発生し、ユーザの貴重な計算リソースを奪ってしまいます。特にこうした計算は、計算リソースがシビアなページの表示の初期に発生するため、FP/FCP/LCP といった Web Vitals に強い影響を与えます。

イベントリスナーが設定されないと、ユーザがクリックしてページに変化を起こすことはできないですし、コンポーネントのソースコードを DL しないと、イベントリスナーの実装や、コンポーネントを再レンダリングした時に、どんな DOM へと書き換えるべきかの情報も得られません。そのため SSR では Hydration がどうしても必要な操作になってきます。

qwik の発明

...のですが、qwik はこれをうまい手法で解決します。qwik はユーザがイベントを発生させるまで、コンポーネントのソースコードを DL しません。逆に、ユーザがイベントを発生させたら、コンポーネントのソースコードを DL して、再レンダリングをします。

ただ、それだけだと DOM にイベントリスナーは設定されていないので、ユーザのイベントをキャッチできません。そこで qwik はランタイム *2 側で一括してイベントリスナーを仕掛けます。

イベントリスナーが呼び出されたら、DOM に書いてある on:click のような属性を読みに行きます。ここにはコンポーネントのイベントリスナーのコードが書かれているファイル・ファイル内での位置が書かれているので、これを元に、真のイベントリスナーのコードを DL してきて、それを実行します。

つまり、コンポーネントのソースコードの DL 無しにイベントリスナーを登録し、イベントリスナーが発火した瞬間にコンポーネントのソースコードを DL して、再レンダリングをする仕組みになってます。

コンポーネントのソースコードは、ユーザが何か操作をするまでは DL されません。ページの初期の表示で DL される JS は、qwik のランタイムくらいです。どれだけページが肥大化してバンドルサイズが増えても、初期表示には qwik ランタイム程度の JS しか必要ありません。qwikが「アプリケーションの規模によって初期ロードの JS のサイズが変わらない O(1) フレームワーク」とうたっているのは、これが所以です。

qwik の特徴。Resumable な JavaScript フレームワーク Qwik を学ぶ (p. 10) より引用。

マイクロフロントエンドへの活用

先日 Cloudflare 社から Cloudflare Workers と qwik を組み合わせて、マイクロフロントエンドを実装する PoC の紹介記事が公開されました。

こちらも日本語で解説記事がいくつか出ているので、読んでみると面白いと思います。

マイクロフロントエンドは、巨大なフロントエンドを複数の独立したチームで開発できるようにする開発パターンのことです。とにかく雑に説明すると、チームごとにコンポーネントを作り (動画プレイヤーチームなら動画プレイヤーコンポーネントを、ヘッダーチームならヘッダーコンポーネントを、レコメンドチームなら関連動画一覧コンポーネントを)、チームごとにそれをデプロイして、デプロイしたものを組み合わせて 1 つのページ (動画閲覧ページなど) を作ります。これにより他のチームの手を借りずにチームごとに単独でデプロイしたり、チームごとに好きな技術を選択したり、チームごとにコードの書き直しができたりと、チームごとに独立して開発できる体制を実現できます。

マイクロフロントエンドではチームごとにビルドして、チームごとにその成果物をデプロイします。デプロイがチームごとにされるため、何もしないとコードの共通化が一切されません。例えば複数のチームで、同じバージョンの React のようなライブラリを使っていたとしても、別々に bundle されるので、どちらの成果物にも React のランタイムのコードが bundle されてしまいます。

一応 webpack の Module Federation にこうしたコードを共有する仕組みがありますが、共有するライブラリを手動で選択する必要があり、中々難しいです。

その点 qwik はこの問題を解決するのにうってつけです。というのも、qwik は「プリケーションの規模によって初期ロードの JS のサイズが変わらない O(1) フレームワーク」です。どれだけページが肥大化してバンドルサイズが増えても、初期表示には qwik ランタイム程度の JS しか必要ありません。これはマイクロフロントエンドであっても同じです。ユーザが操作をするまでコンポーネントの JS は DL されないので、どれだけそれぞれのコンポーネントのコードが大きくても、ページの初期表示へのパフォーマンス影響はありません。

qwik 以外の View ライブラリでは、それぞれのチームが初期表示へパフォーマンス影響に注意を払う必要がありましたが、qwik を使うと (一切気にしなくて良いわけでないにせよ) ほとんど気にしなくて良くなります。チーム間で bundle size の上限を巡っておしくらまんじゅうすることなく、よりそれぞれのチームが自律的に動けるようになります。

トレードオフ

夢みたいな技術には多くの場合、それ相応の代償が付いてきます。

イベントリスナー非同期問題

イベントリスナーが呼び出されたら fetch API を叩いて、真のイベントリスナーのソースコードを DL し始めます。そのため、真のイベントリスナーは非同期に呼び出されます。Web 標準のイベントシステムはイベントリスナーが同期的に処理されることを前提に作られているので、非同期にやると色々とほころびが出てきます。qwik 側で独自のキャンセル機構が用意されてますが、標準の方法とは違うのでちょっとびっくりしますね。

一応同期的にイベントリスナーを呼び出す脱出ハッチのようなオプションは用意されているようです。

qwik.builder.io

あとはクリックイベントリスナーの実行と CSS のアニメーションのタイミングがユーザからズレて見えたりとか… コンポーネントごとにソースコードを DL する仕組みなので、1回の操作で DL される JS のサイズは小さいはずで、あまり問題にならないような気もする...? 低速な回線を使っているユーザだと違和感を感じられるかもしれない...?

このあたりは Qwik City という Qwik を使ったフレームワークの prefetch で軽減できるようです。ちゃんと用意されているんですねー。

XSS/コンポーネントの DL タイミング問題

on:click="悪意のあるファイル#悪意のあるイベントリスナーの名前" みたいな属性を埋め込まれたらどうなるのかとか… 流石に対策してそうではあるけどどうやってるんでしょうね (Issue を探した限りでは言及されてなさそう)。

...まあそういう感じで他にも色々な代償があると思います。

さいごに

@KawamataRyo さんの発表の中であった「アプリケーションの規模によって初期ロードの JS のサイズが変わらない初の O(1) フレームワーク」という言葉に惹かれて調べてみたのですが、面白い発見がいくつもあって良かったです。id:mizdra はマイクロフロントエンドもやらないし *3、React Server Component のようなアプローチのほうが好きなので Not for me かなという感じですが、刺さる人には刺さるライブラリかなと思っています。皆さんも興味があれば触ってみてください。

*1:実際に公式サイトに「Built by Performance Nerds」と書いてある

*2:ランタイムというのは仮想 DOM の計算などをするライブラリのコアのこと

*3:というか日本でマイクロフロントエンドやってる人果たしてどれくらい居るんだろう…

msw で handler の mock や spy をする

元々「ServiceWorker をベースにした技術をわざわざテストに持ち込む意味とは?」と思って、msw をテスト環境で使う意義について懐疑的だったのですが、いざ使ってみるとすごく便利ですね。ServiceWorker 云々以前に、ネットワークリクエストの mock ライブラリとして、インターフェイスがとても使いやすいです。

zenn.dev

特に handler の mock や spy が簡単にできるのが嬉しいです。Jest と組み合わせた時の例だと、以下のようなイメージ。

// jest.setup.ts
import { server } from './src/test-utils/msw';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// src/test-utils/msw.ts
import { setupServer } from 'msw/node';
import { graphql, rest } from 'msw';

export type TodoAddRequest = { title: string };
export type TodoAddResponse =
  { type: 'success' }
  | { type: 'error', error: string };

const handlers = [
  // デフォルトの handler をここで定義しておく。
  rest.post<TodoAddRequest>('/todo/add', (req, res, ctx) => {
    return res(
      ctx.json<TodoAddResponse>({ type: 'success' }),
    );
  }),
  // ...
];

export const server = setupServer(...handlers);
// src/components/TodoList.test.ts

import { server, TodoAddResponse } from '../test-utils/msw';

// mock の例

test('todo 追加時にエラーになったら、エラーメッセージが表示される', async () => {
  server.use(rest.post('/todo/add', (req, res, ctx) => {
    // 既に `/todo/add` 向けに登録されている handler を無視して、
    // この handler で上書きする (mock)
    return res(
      ctx.status(500),
      ctx.json<TodoAddResponse>({
        type: 'error',
        error: 'TODO は 10 個までしか登録できません。',
      }),
    );
  }));

  render(<TodoList />);
  
  await userEvent.type(screen.getByLabelText('title'), '買い物に行く');
  await userEvent.click(screen.getByRole('button', { name: '追加' }));

  expect(screen.getByRole('alert')).toHaveTextContent(
    'TODO は 10 個までしか登録できません。',
  );
});

// spy の例

test('todo を追加できる', async () => {
  const todoAddSpy = jest.fn();
  // これを `server.use` に渡す。 `return res(...)` していないので、
  // デフォルトの handler まで貫通する
  server.use(rest.post('/todo/add', todoAddSpy));

  render(<TodoList />);
  
  await userEvent.type(screen.getByLabelText('title'), '買い物に行く');

  expect(todoAddSpy).not.toBeCalled();
  await userEvent.click(screen.getByRole('button', { name: '追加' }));

  // handler が期待通りの呼ばれ方をしたかをチェック
  expect(todoAddSpy).toBeCalled();
  expect(todoAddSpy).toBeCalledWith(expect.objectContaining(
    body: {
      title: '買い物に行く',
    },
  ));
}

テストケース側で server.usereturn res(...) を含む handler を登録すれば mock が、return res(...) を含まない handler を登録すれば spy ができます*1。便利ですね。

*1:よく考えてみたら、デフォルトの handler の入力を傍受できるだけで出力は傍受できないので、spy と言えるかどうか微妙な気がしてきた...

過去に git clone した OSS リポジトリを一覧する

ということをふと思いついた。定量的に物事を見れると良い振り返りができて嬉しいと思う。とりあえず集計だけでもやってみる。

shell の history から一覧を作る

どうやって一覧しよう、と思ってまず思いついたのが、shell の history を使う方法。ghq list で探しても良いけど、ディスク容量が減ってきたら見なくなったリポジトリを削除したりするので、やっぱり shell history からたどるのが良いだろうというアイデア。

history 1 の出力を grep すれば良いだけで、ちょっと前にも id:onk さんが似たようなことをされていた。

onk.hatenablog.jp

$ history 1 | awk '{ print $2,$3,$4 }' | grep -e 'git clone' | awk '{ print $3 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
...

普段は git clone ではなく ghq get を使っているので、OR 検索する。

$ history 1 | awk '{ print $2,$3,$4 }' | grep -e 'git clone' -e 'ghq get' | awk '{ print $3 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--help
--shallow
...

ghq get --shallow <url> のようなケースが上手く考慮できていないので考慮してやる。ついでに --help は除外する。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' | grep -v '\-\-help' | awk '{ print $3,$4 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git

id:mizdragit sclone という git clone --shallow の alias も使っているので、これも考慮する。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v '\-\-help' | awk '{ print $3,$4 }'
git@github.com:mizdra/typed-less-modules.git
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git
git@github.com:garris/BackstopJS.git
...

自分以外の OSS のリポジトリのみに絞りたいので、org が自分のものは除外する (OSS を自分の org に fork したものも除外されてしまうけど…まあそんな数もないので気にせずで)。こういう時ハンドルネームがユニークだと、雑に除外条件を設定しても期待通りになってうれしい。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v -e '\-\-help' -e 'mizdra' | awk '{ print $3,$4 }'
git@github.com:vercel/next.js.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
--shallow git@github.com:vercel/next.js.git
git@github.com:garris/BackstopJS.git
...

完成系

という訳で、完成したワンライナーとその実行結果がこちら。private リポジトリ見られると良くないものや、mizdra 以外の org で自分が開発しているものなどは手作業で除外した。

$ history 1 | awk '{ print $2,$3,$4,$5 }' | grep -e 'git clone' -e 'ghq get' -e 'git sclone' | grep -v -e '\-\-help' -e 'mizdra' | awk '{ print $3,$4 }'
git@github.com:vnc0/magic-trackpad-switcher.git
git@github.com:mrmckeb/typescript-plugin-css-modules.git
git@github.com:AriPerkkio/eslint-remote-tester.git
git@github.com:zhouzi/graphql-codegen-factories.git
git@github.com:garris/BackstopJS.git
git@github.com:APIs-guru/graphql-faker.git
git@github.com:isucon/isucon11-qualify.git
git@github.com:vercel/next.js.git
--shallow git@github.com:vercel/next.js.git
git@github.com:vercel/next.js.git
git@github.com:jest-community/vscode-jest.git
https://github.com/jest-community/jest-editor-support.git
git@github.com:prettier/prettier.git
https://github.com/royriojas/file-entry-cache
--shallow git@github.com:microsoft/TypeScript.git
--shallow git@github.com:dotansimha/graphql-code-generator.git
git@github.com:madyankin/postcss-modules-example.git
git@github.com:facebook/docusaurus.git
git@github.com:postcss/postcss.git

同じリポジトリが何回も出力されていたり、--shallow が出ていたりするのが気になるけど、とりあえず良さそうなデータが集計できて満足した!

shell history だけだと clone 日時まではわからないので、なんか色々工夫したほうが良いのかもしれない。HISTTIMEFORMAT を変更して shell history に日付が埋め込まれるようにするのが良い気がするけど、これって過去の履歴壊れたりしないかな…どうしようかな…というところで悩み中。

takami-hiroki.hatenablog.com

おまけ

ところで一覧を見て、「なんか最近触ったリポジトリばかりだな…」と思ったのでちょっと調べてみたら…

$ history 1 | wc -l
    1068
$ echo $HISTSIZE
2000
$ cat ~/.zshenv | grep HISTSIZE
export HISTSIZE=1000000

.zshrc で設定した $HISTSIZE が上手く適用されていないのか、1068 件しか保存されてなさそう。というか値 3 つともバラバラになっている。そんな………………

追記 (2022/08/06 02:12)

どうやら shell history の最大件数は HISTSIZE ではなく HISTFILESIZE で調整するものらしい。

oplern.hatenablog.com

$ echo $HISTFILESIZE

$ cat ~/.zshenv | grep HISTFILESIZE
$ echo $?
1

何も設定していなくて、zsh のデフォルト値が使われていそう。そういうことだったのか…

追記 (2022/08/06 12:23)

もうちょっと調べてみたら、zsh で履歴の最大保存件数を表す環境変数は HISTFILESIZE ではなく SAVEHIST のようだった。これ shell ごとに違うんですね。

timesaving.hatenablog.com

$ echo $SAVEHIST
1000
$ cat ~/.zshenv | grep SAVEHIST
export SAVEHIST=1000000

.zshenvSAVEHIST 設定しているはずなのだけど、なぜか無視されていた。.zshenv で設定している他の環境変数 (export EDITOR="code --wait" とか) はちゃんと認識されているのだけどなー。なんでだろう。

追記 (2022/08/06 13:26)

メーリングリストで何か議論されていないかな…と思ってメーリングリストのアーカイブを検索してみる。アーカイブは https://www.zsh.org/mla/ にあるけど、全文検索機能は付いていないので、ここは Google で <キーワード> site:https://www.zsh.org/mla/ というクエリを打ち込んで調べる。Google 便利。

という訳で ghq get git@github.com:zsh-users/zsh.git する。こうして git clone した OSS リポジトリがまた1つ増えていく…

もう追う体力残ってないので、.zshrc に設定を移動して終わりにした。

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

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