mizdra's blog

ぽよぐらみんぐ

試行錯誤を邪魔しない開発環境

  • ある機能を実装する際、完成形のコードになるまでには、プログラムとして不正確な状態や、プロダクト品質ではない状態を経る
    • 静的型検査や lint rule に違反したコードが途中に挟まる
  • 型エラーや lint エラーは望ましくないので、できるだけ早くこうした情報を開発者に伝え、気付けるようにすると良い
    • CI でこうしたエラーを検知して、Pull Request をマージする前に気づけるようにするとか
    • エディタ上にエラーの情報を表示して、コーディング中に気づけるようにするとか
  • エラーを積極的に通知してくれるのはありがたいけど、やりすぎには注意するべき

なんとなくでも動いてくれたほうが嬉しい

  • 例えば lint エラーがあった際に、watch モードで起動しているビルドやテストの実行を止めて、lint エラー見つけたよーと教えてくれる開発環境がたまにあるけど...
    • 別にビルドやテストの実行は止める必要ないと思う
    • なんとなくでも動いて結果を教えてくれるほうが、試行錯誤しやすくて嬉しい
  • TypeScript なら(静的)型エラーを無視してトランスパイル...なんてこともできる
    • ts-loader に transpileOnly: true オプションを渡したり、swc のような (型検査なしで) トランスパイルだけするツールを使えばできる
      • 多少型のミスマッチがあっても実行できるところまでは動くし、問題があったら実行時エラー (TypeError など) が出るだけ
    • このアプローチはここ数年で当たり前になってきた気がする
      • 試行錯誤のしやすさのため...というよりは watch ビルドに掛かる時間を短縮するためというのが主目的
      • あと swc や esbuild のようなツールが普及したというのもありそう
    • 何だかんだで背後が動的型検査の JavaScript だからこそできる荒業だと思う

create-react-app のおせっかい機能

  • 「create-react-app」という React アプリケーションを作成するためのフレームワークがある
  • 主に React 初学者が React を使った開発方法を学ぶためにある
    • しかし面倒な環境構築が不要かつ、難しいビルド周りの設定を上手く隠蔽してくれるので、業務でも十分有効なフレームワークだと思う
    • id:mizdra も業務で使ってる
  • ただ、watch ビルド中に lint エラー or (静的)型エラーが検出された時の挙動が id:mizdra が期待しているものからズレててイマイチ
    • 以下のように lint エラーをブラウザの画面いっぱいにオーバーレイで表示してくる
    • ブラウザで開いているページ上にオーバーレイで lint エラーが表示されている様子
    • 一応ビルド自体はされていて、右上のバツボタンからオーバーレイを閉じればページを操作できる
      • とはいえビルドされ直されるたびにオーバーレイが出るのでわずらわしい
    • おそらくReact 初学者がメインユーザーなフレームワークゆえに、エラーを目立たせる作りになっているのだろう
      • しかし初学者でない人からすると、おせっかい機能だと思う
  • そういう感じなので、最近は TSC_COMPILE_ON_ERROR=true DISABLE_ESLINT_PLUGIN=true react-scripts start で起動してる

更に一歩踏み込んで考えてみる

  • 空の body を持つ関数を禁止する lint rule とかあるけど、ああいうのもわずらわしく感じる
    • () => {} から書き始めるので、大体空になって怒られる
    • 書いている途中で赤線が引かれて、「あれ何か間違った?」とびっくりすることが多い
    • こういうのも警告レベルを落としたり、無効にすると良い?
      • しかしキリがなくて大変そう
      • こういう細かいチューニングをするよりは他のことに時間を掛けたい
    • 発想は面白いと思う!
  • エディタ上で未 format のコードに赤線を引いてくる開発環境もあるけど、あれもわずらわしい
    • インデントの深さが間違ってたら真っ赤になる
      • スタイルガイドに合ってないだけで、コードの挙動は正しいのに…
    • エディタ上では警告せずに formatOnSave で勝手に format する & CI 上では警告する、くらいが丁度よい

書いてみて思ったけど、「静的型エラーを無視してトランスパイルする」とか「ブラウザにエラー内容をオーバーレイで表示しない」とか、Web フロントエンドならではの話題ばかりだった。

ネイティブモジュールに依存しない node-canvas 代替ライブラリを使う

Web フロントエンドにて、Canvas を使った View のテストを書きたいことがたまにあります。ブラウザであれば以下のようにして Canvas を利用できますが、テストが実行される Node.js ではそのような API は生えていません。

const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

そこで Node.js では、node-canvas という npm パッケージがよく使われます。これを使うと、Web Canvas API 互換な API を用いて、Node.js でも Canvas を利用できます。

import { createCanvas } from 'canvas';

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

余談ですが、Node.js に DOM API の互換実装を提供するライブラリ「jsdom」では、HTMLCanvasElementHTMLImageElement の実装に、この node-canvas を利用しています。

ネイティブモジュールの依存問題

そんな node-canvas ですが、実はネイティブモジュールに依存しています。そのため、いくつかのモジュールがマシンにインストールされていなければ実行できません。

一度マシンにインストールしてしまえば良いものではありますが、複数人で開発するようなプロジェクトで node-canvas に依存していると、新しくチームメンバーがやってくる度にインストール方法を案内する必要があります。加えて、CI 上でテストを走らせる際も CI 環境にインストールが必要になってきます。

ちょっとした手間ではあるのですが、面倒なのでなんとかして解消できると嬉しいです。

@napi-rs/canvas を使う

@napi-rs/canvas という、同じく Web Canvas API 互換な API を実装したライブラリがあります。このパッケージはネイティブモジュールに依存しない作りになっていて、ネイティブモジュールをインストールせずとも node-canvas と同等のことができます。

$ npm i -D @napi-rs/canvas
import { createCanvas } from '@napi-rs/canvas';
//                            ^^^^^^^^^^^^^^^
//                            ここが canvas から変わっただけ

const canvas = createCanvas(200, 200);
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 150, 100);

jsdom と組み合わせるにはもう一手間必要

先程ちょっと触れましたが、jsdom からは const Canvas = require("canvas"); で node-canvas が参照されています。従って、jsdom の HTMLCanvasElement を呼び出すと node-canvas が使われてしまう…ということになってしまいます。

そこで id:mizdra@napi-rs/canvas を以下のようなコマンドでインストールしています。

$ npm i -D canvas@npm:@napi-rs/canvas

このコマンドを実行すると、package.json 上では以下のような依存関係の情報が書き込まれます。

{
  // ...
  "devDependencies": {
    // ...
    "canvas": "npm:@napi-rs/canvas@^0.1.34",
  }
}

これで require('canvas') というコードが require('@napi-rs/canvas') へと解決され、node-canvas の代わりに @napi-rs/canvas が利用されるようになります。jsdom の HTMLCanvasElement からも @napi-rs/canvas が使われるようになり、ネイティブモジュール無しでテストを実行できるようになります。

こことかここを見るとわかるのですが、実際には @napi-rs/canvasnode-canvas の API を完全に模倣している訳ではないので、もしかしたらうまく動かないこともあるかもしれません… とりあえず手元の HTMLCanvasElement を使ったテストケースは正常に動作していますが、凝ったテストを書いてると動かないとかあるかもです。

ある Web ページに関するリソースのうち、ブラウザにリークしているものを調べる

最近の Web フロントエンドの開発では、JavaScript/CSS ファイルを bundler (webpack, vite, ...) でバンドルして、それをブラウザに配信することが多いと思います。

例えば以下のようなコードを bundler でバンドルすると、react + react-dom/client + ./locales/ja-JP.json が結合された bundle.js というファイルが生成されます (出力されるファイル名は bundler によって異なるのでイメージです)。

import React from 'react';
import { createRoot } from 'react-dom/client';
import locale from './locales/ja-JP.json';

function App() {
  return <div>{locale['Hello World!']}</div>; // => 'ようこそ、世界へ!'
}

const root = createRoot(document.getElementById('app')!);
root.render(<App />);

一方で、 bundler はモジュールから辿れるものを片っ端からバンドルするので、時として「本当はバンドルされて欲しくないもの」までバンドルされてしまうことがあります。例えば ./locales/ja-JP.json に入っている文言に未解禁情報が含まれていて、それが bundle.js に含まれてしまい、ユーザにリークしてしまう… そんな状況が考えられます。

ブラウザにリークしているリソースを調べる方法

どうコードを書き換えたらリークを防げるのか、リークを未然に防ぐ仕組みはどうやったら作れるか、などこのテーマについて話せることは色々ありますが、今回は「ブラウザにリークしているリソースを調べる方法」について紹介します。

bundler の成果物を grep する

一番シンプルです。./dist 配下などを未公開情報をキーワードにして grep するだけです。

./dist 配下が全てブラウザに配信される」ことが分かっているのならこの手法で良いですが、場合によっては「./dist 配下にサーバーサイドで使われるものとブラウザに配信されるものが混在していて、単純に grep できない」こともあるかもしれません。bundler の出力先のディレクトリ構成を見直して grep しやすい形に整理したり、あるいは他の手法で調べたりすると良いと思います。

ちなみに Next.js は .next 配下 にサーバーサイドで使われるリソースとブラウザに配信されるリソースが混在している典型的な例ですが、このうちブラウザに配信されるリソースは .next/static 配下に集められています (ただし undocumented な挙動なので今後変わりうるかも)。Next.js なら .next/static 配下に対して grep すれば良さそうです。

この記事ではあまり深入りしませんが、CI 上で next build 後に .next/static 配下を grep すれば、デプロイ前にリークに気づけるような仕組みを作れます。興味があれば試してみてください。

Chrome devtools の Network パネルの検索機能を使う

Chrome devtools の Network パネルを使うと、ページ表示中に発生した全てのネットワークリクエストを覗き見ることができます。実はこの Network パネルには検索機能がついており、ページ表示中に発生した全てのネットワークリクエストを対象に、平文検索を実行できます。これを使うと、未公開情報のリークの有無を確認できます。

  1. Network パネルを開いて Command + F キーを押す (macOS の場合 / 他の OS でも検索ショートカット相当のやつでいけるはず)
    • 検索欄が出てくるはず
  2. 検索欄にリークの有無を確認したいキーワードを入力
    • 検索結果に結果が表示されればリークしてる
  3. 検索結果をクリックして、ファイルを開く
    • キーワードが埋め込まれたファイルの中身が表示される
    • minify されてるファイルだと読みづらいかも
      • 左下の {} ボタンを押すと、ファイルがフォーマットされてちょっと読みやすくなる

Network パネルを使ったリークの有無の確認方法の図解

一点注意しておきたいこととして、実際にページ表示中にブラウザで発生したネットワークリクエストのみを対象に検索するため、リクエストされなかったリソースは検索対象に含まれません。つまり、実行時に特定の条件に応じて fetch でリソースを DL してきて、それを表示するようなコンテンツがあった際に、そのコンテンツに非公開情報が含まれていても、ヒットしない可能性があります。ヒットしないものの、bundle.js 中にはそのリソースの URL が埋め込まれているはずで、ユーザその URL を直接アドレスバーに打ち込んでアクセスすれば、非公開情報を閲覧できてしまいます。

クライアントサイドで動的に出し分けているリソースについては、この手法ではリークの有無を完全に確認出来るわけではない、ということに気をつけましょう。


他にもリークの有無を確認する便利な方法があれば教えて下さい!

next lint は lint 対象のディレクトリが制限されている

ということに next lint を触っていて気づきました。公式ドキュメントにもそう書いてありました。

next lint runs ESLint for all files in the pages, components, and lib directories. It also provides a guided setup to install any required dependencies if ESLint is not already configured in your application.

(https://nextjs.org/docs/api-reference/cli#lint より引用)

これによると、next lintpages/, components/, lib/、そして上記ドキュメントでは漏れてますが src/ *1 を加えた 4 つのディレクトリが lint 対象となるようです。

これ以外のディレクトリを lint 対象に含めたい場合は、next lint --dir util/ のようにコマンドラインから渡すか、next.config.jseslint.dirs オプションにディレクトリを指定するよう案内されてます。

暗黙的に lint 対象を制限することの是非

実際のところこの挙動って嬉しいんでしょうかね。本当は util/ も lint されて欲しいのに何故か lint されなくて混乱する、みたいなことが直感的には起きそうな気がします。加えて vscode-eslint (ESLint の VSCode 拡張機能) は「4つのディレクトリ以外は lint 対象から外す」ことを知らないので、util/ 配下のファイルでも ESLint の警告を出してしまいます。エディタ上で見たときと、next lint で見たときで警告の内容に違いが出るので、これも混乱しそうです。

以上のような問題があるので、除外するファイルは .eslintignore に書きつつ、eslint . でカレントディレクトリ配下のファイル全てを lint 対象にするアプローチが id:mizdra は好きです。どのディレクトリが除外されるか明示的になりますし、vscode-eslint が .eslintignore を見て、どのファイルが lint 対象外か認識してくれるので、エディタ上/CLI上での警告に差がなくなります。

zenn.dev

まあでも予想外に lint 対象の範囲が広がるのを避けたいとか、ファイルが多くなりすぎて lint 速度が低下するのを避けたいみたいな事情があったりするのかな...? いやでも速度が問題になってから .eslintignore すれば良いような気も…

next lint の初期の PR のやり取りを見ると、Next.js アプリケーションで一般的なディレクトリに限って lint するという前提で最初から設計されているように見えるので、あまりそのあたりの拘りがないのかもしれません。

という訳で id:mizdra は以下のように、カレントディレクトリ配下のうち .eslintignore に含まれるものを除外したファイルを lint 対象とするアプローチが良いかなと思っています。

// package.json
{
  "scripts": {
    "lint": "next lint -d ."
  }
}

それよりも

デフォルトの lint 対象ディレクトリのうち、pages/, components/, src/ は順当だと思ったのですが、lib/ がそこに入っていたのはちょっとびっくりしました。Next.js 公式的には lib/ も「Next.js アプリケーションで一般的に見られるディレクトリ」ということなんですかね。id:mizdra はあんまりそういう印象なかったので「そうなの...?」となってます。公式ドキュメントでも lib/ が登場するのは next lint 関連での文脈でしかなさそうでした。

しかし Next.js の公式 Example 集を見ると、結構 lib/ が使われてるようでした。

公式ドキュメントでは触れられてないものの、公式 Example 集では使われているディレクトリみたいですね。

追記: 2023-01-14

Next.js v13 からサポートされた app/ ディレクトリも lint 対象に含めようとする PR が出ていました。この PR がマージされれば lint 対象のディレクトリが 5 つに増えることになりそうです。

github.com

最近書いた風変わりなテストコード 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 提唱者

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

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