概要
本記事ではタイトルの通り、テストコードの開発時間を短縮するべく、Jest
で書かれた既存のテストコードを爆速で実行するツール、uvu-jest を開発したので、紹介させて頂きたいと思います!
実行速度の改善結果としては下記のとおりです。
-
Node.js + express
で実装したバックエンドの統合テストの実行時間: 1分20秒 => 9~14秒 -
React + Testing Library
で実装したフロントエンドのユニットテスト: 1分20秒 => 20~30秒
立上がりのオーバーヘッドが少ないので、個別のテストの実行ではさらに差も出てくると思われます。
本記事ではツールの作成に当たって検討したことや、実装方法などをまとめたいと思います。
背景
先日 The State of JS 2021 も発表され、フロントエンド界隈のエコシステムはますますリッチになって来たことを実感します。その中でも esbuild
や svelte
などの隆盛から、軽量な開発環境への取り組みが一段と加速している様子が伺えます。
React, Vueなどの成熟でWEB開発の型がある程度定まってきた一方で、VUCAな現代環境においては高速なプロトタイピングやトライエラーの価値がより一層高まってきているのだと感じます。
ところで、テストフレームワークの世界は こちら の通り、Jest
の利用率が圧倒的に高い状態になっています。最近のフロントエンド開発ではとりあえず Jest
みたいな状態で、Testing Library
などの人気ツールも Jest
との併用を想定して実装されています。
しかしながら、このJest
が非常に巨大で鈍足なテストフレームワークであることも既知の通りで、installサイズは約25MBほどあり、実行にもかなり遅さを感じます。立上がりのオーバーヘッドが大きいので、小さなテストを動かしたりwatchモードを利用する際には特にストレスを感じやすかったりします。
ある程度のサービスを開発するのであればテストコードの実装はマストなので、esbuild
や svelte
がある今、開発DX上の最大の課題がテスト開発工数の効率化だと私は勝手に思っています。
uvuに関して
最近 uvu というツールを知りました。超軽量のテストランナーで究極の速度を実現しているとのことです。
実際に試してみましたが、かなり高速です。
Jest
でこんなテストを書くとして、
describe('desc1', () => {
test('test1-1', () => {
expect(1).toBe(1);
});
});
これをuvu
で記述すると以下のようになります。
import { suite } from 'uvu';
import { is } from 'uvu/assert';
const test = suite('desc1');
test('test1', () => is(1, 1));
test.run();
Jest
のような魔法はなく、しっかりimport
して明示的にrun
します。これにより、単体のファイルをNode.js
で実行することもできるし、uvu
のCLIコマンドを利用して実行することもできます。
また、アサートにはJest
のexpect(...).toBe(...)
ではなく、独自のパッケージuvu/assert
のis
などを利用します。
(is
などのアサートがthrowするエラーをキャッチしてエラーレポートするため、uvu/assert
以外を利用することも可能です。)
両者を以下のコマンドで実行してみると
-
Jest
:time node node_modules/.bin/jest test-jest.spec.ts
-
uvu
:time node -r esbuild-register test-uvu.spec.ts
(単体実行)
手元での結果は一例ですがこんな感じです。
-
Jest
: 3.586s -
uvu
: 0.195s
実行時間のオーダーが違うのがわかります。実際にuvu
のコマンドを叩いてみると、一瞬で結果が返ってきます。
uvu
がなぜそこまで早いか?ですが、単純に実装がシンプルで、テストランナー部分 はわずか150行ほどのJavaScritp
なので、簡単にレポジトリの中身を読み解くこともできます。アサート関数を別のパッケージに切り出しているのも特徴的で、必要最低限度の実装がされています。
インストールサイズもわずか490kBと軽量で、2020年の登場から順調にstar数も増加してる様です。
開発の動機
テストコードの開発時は何度も実行を繰り返しながら結果を確認していくので、数秒の実行時間の違いでも大きなメリットになります。上記の通りuvu
を利用すればJest
と比較してかなり実行時間を短縮できることがわかりました。
(postcss
のレポでは、実際にJest => uvuに書き変えをするPR がありました。)
しかしながら、既存でJest
を利用している多くのシステムにとっては、uvu
に載せ替える事にいくつか懸念点があると思います。
-
Jest
=>uvu
書き換えに時間がかかる - 最終的には実績がある
Jest
で結果を確かめられた方が安心する -
uvu
自体が今後もメンテナンスされていくか?メジャーになるのか?が不透明 - 既存のテストの構造を保ちたい
- カバレッジの計測や、レポート作成には
Jest
に馴染んだツールを利用したい
3に関しては、実際に最近のコントリビューション はやや少ないように思います。
4に関しては、uvu
ではsuite
がJest
のdescribe
のような働きですが、describe
と違ってネストする事ができません。テストの実行結果をみやすくするためにdescribe
を積極的に利用する事が望ましかったりするので、困る開発者もいると思われます。
以上の事から、実行時間を比較的気にしなくても良いCI上ではJest
で実行しますが、手元で開発する時は暫定的にuvu
で実行する事ができたら開発効率と安定性を両立できベストだと考えました。つまり、なんらかの方法で既存のJest
で書かれたテストをuvu
で実行できる形に変換できれば良いということになります。
既存では要件に合うツールがなかったのですが、メリットは大きいと思い、自分で開発することにしました。
実装方法
uvuをフォーク
Jest => uvu
の書き変えが実際に可能か?はJest
の機能をuvu
で表現できるか?を考える必要があがあります。前提としてJest
は非常に機能が豊富なので全てをサポートすることは考えていませんが、主要なところではライフサイクル関数であるafterAll
をuvu
で再現することができません。
具体的なユースケースで説明すると、APIの統合テストの例で以下のようなコードを考えます。
describe('desc1', () => {
beforeAll(() => {/* APIサーバの開始 */})
afterAll( () => {/* APIサーバの終了 */})
describe('desc2', () => {
let apiResponse;
beforeAll(() => { /* APIリクエストの結果をapiResponseに代入 */; )
test('test2', () => { /* apiResponseの内容を確認 */});
});
});
第1階層のライフサイクルで、APIサーバの立ち上げ・終了を行い、第二階層のライフサイクルで各APIリクエストを実行します。
ちなみにTestingTrophy を意識すると、上記の様な統合テストを書く機会もそれなりにあると思います。
しかしながら、uvu
にもsuite.after
というライフサイクルはあるのですが、suite
はネストする機能がなく、suite
ごとにrun
していく必要があるため、上記のafterAll
の実行順序を担保することができません。
ネストができないことへの問題点はこちら で議論されていましたが、ライフサイクルフックの実行順序に関しては言及されてなく実装方針は示されていない状況でした。
そこで、やむなく uvu
を uvu-jest
としてフォークし、ネスト機能を補完していくことに決めました。結果的には後述するsnapshotテスト対応などもフォークしたことで実現ができました。
wrapperを作成
コードの変換自体はTypescript
のCompiler API に含まれる、Transform
を利用することを考えました。これはTypeScript
のコードをオブジェクト化したAST
を新規AST
に変換することができます。
しかし、AST
は非常に抽象的で巨大なため、AST
変換で多くのことをするのは得策ではありません。また、変換後のコードが元のコードから見る影もなく変貌してしまうと、デバックや可動性もさがってしまう懸念があります。
そこで、なるべく変換後のコードが元のコードと姿形が似るように、wrapperを作成しました。具体的にはdescribe
関数を下記の様にuvu
で実装します。
export function describe (name: string, handler: Handler) {
const test = suite(name);
handler({
test,
expect, // 別で定義しています
describe,
afterAll: test.after,
beforeAll: test.before,
});
test.run();
}
ちなみに、この wrapper のアイディアは Rich-Harrisさんのコメント を参考にしています。この方はsvelte
のメインコントリビュータの方なのでuvu
の注目度の高さが伺えます。
これにより、元のJest
のコードの書き変え後のイメージはこんな感じで、
import { describe } from 'uvu-jest/jest-wrapper';
describe('desc1', ({ afterAll, test, expect, describe }) => {
afterAll(() => {});
test('test1', () => {
expect(parseInt(0.0000005)).not.toBe(0);
});
describe('desc2', ({ afterAll, test, expect, describe }) => {
afterAll(() => {});
test('test2', () => {
expect(parseInt(0.0000005)).toBe(5);
});
})
});
違いは
- import文の追加
- describeの第二引数に渡す関数に引数を明記する
の二点だけになります。
コードの変換
Typescript
のCompiler API に含まれる、Transform
関数は独立して利用することができますが、やはりtsc
のプラグインとして利用できた方がwatch
モードが使えたり差分でビルドできたりと何かと便利です。あいにく、tsc
はTransform
をプラグインとして設定することができません。
そこで、ttypescript を利用します。ttypescript
はttsc
というtsc
をラップしたCLIコマンドを提供しますが、tsconfig.json
にTransform
プラグインを指定することができます。
プラグインの実装としては transformer/jest-to-uvu.ts に独立したパッケージとして作成しています。
プラグインを指定したtsconfig.json
のイメージはこんな感じで、
{
"compilerOptions": {
"plugins": [
{
// 変換プラグイン
"transform": "uvu-jest-ts-plugin"
}
],
"outDir": "./dist-uvu",
"target": "ESNext",
"module": "commonjs",
"allowJs": true,
"checkJs": false
}
}
実行コマンドは、npx ttsc -p ttsc.config.json -w
の様な形を想定しています。
Jestの機能サポート
基本機能
Jest
のexpect
関数の戻り値には、toBe
, toEqual
など多くの関数が登録されており、これらはMatcher
と呼ばれます。今回 uvu-jest
で対応しているMatcher
は以下の通りです。
expect().toBe()
expect().toEqual()
expect().not.toBe()
expect().not.toEqual()
expect().rejects.thThrow()
expect().toMatchSnapshot()
原理的に足りてない機能もあるかと思いますが、実装をシンプルに保つため、toBe
で書き換えられる機能は実装しない方針です。たとえば、expect(num1).lessThanOrEqual(num2)
はexpect(num1 <= num2).toBe(true)
に書き換えて利用する想定です。
また、Jest
にはdescribe.each
, test.each
などもありますが、普通に配列処理で代替できるため未対応です。
Snapshotテスト
Jest
のexpect().toMatchSnapshot()
で利用できるスナップショットテストは利用している方も多い機能だと思います。uvu
にもsnapshotテストの機能はありますが、当然Jest
との互換性はないuvu
仕様なので、既存のsnapshotファイルを利用することができません。
そこで、調べたところ jest-snapshot というJest
のスナップショット機能部分をstand-aloneなnpm
パッケージとして切り出したものがあり、これが利用できそうです。
Jest
のtoMatchSnapshot
に準拠する新たなカスタムMatcher
を作成しました。注意した点として、jest-snapshot
も巨大なライブラリで、手元ではimportするだけで400msほど消費していたので、必要な時に一度だけ読み込まれるように工夫しました。
また、uvu-jest
ではttsc
でコンパイルしたJS
コードを実行するのですが、JS
をdist
などgitにコミットしないディレクトリに吐き出している場合は、snapshotファイル自体は元のTSファイルの横に置いておきたくなると思います。そこで、Jest
にはsnapshot resolver
(以前Qiitaに書きました )を設定でき、snapshotの保存先を自由に変更することができます。
そこで、uvu-jest
もこれに準拠し、ルートディレクトリのuvu-jest.config.js
に指定できるようにしました。
module.exports = {
setupFiles: ['dist-uvu/jestSetUp.js'],
snapshotResolver: 'test/snapshotResolver.js',
};
setupFiles
もJest
に準拠し、(環境変数の設定など) テスト前に実行するファイルを指定できるようにしています。
Testing Library
Testing Library
は global-jsdom
を利用することで、 node -r global-jsdom/register node_modules/.bin/uj
の様にして実行することができます。
(uj
はuvu
をフォークしたuvu-jest
のCLIコマンドです。)
ただし、Testing Library
に限ったことではありませんが、独自のMatcherを提供している場合があります。例えば、expect().toBeInTheDocument()
などが、Jest
にはなくTesting Library
が独自に提供しているMatcher
です。Jest
にはexpect.extend()
という関数があり、任意のMatcher
を追加できるようになっており、Jest
で利用する時はjest.config.js
で下記のように読み込んでいます。
module.exports = {
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
};
uvu-jest
でもこれと同様に、任意のMatcher
をRecord<string, MatcherFunction>
の形で渡すことで追加できる機能をuvu-jest.config.js
に実装しています。
module.exports = {
customMatchers: '@testing-library/jest-dom/matchers',
};
uvu-jest の使い方
一例としておすすめの使い方をまとめます。
1.インストールします。
npm i -D uvu-jest uvu-jest-ts-plugin ttypescript
2.既存のtsconfig.json
を参考に、tsconfig.ttsc.json
を作成
{
"compilerOptions": {
// 変換用のプラグインを指定します
"plugins": [{"transform": "uvu-jest-ts-plugin"}],
// JSファイルの出力先を指定
"outDir": "./dist-uvu",
"target": "ESNext"
}
}
JSの変換結果を指定するtarget
はNode.js
で実行するのでESNext
で良いと思います。(ES6
などにすると、async/await
のラッパーなどが混入し少しコードがみにくいかもしれません。)
3.変換を実行する
npx ttsc -p tsconfig.ttsc.json
ここで、変換の対象になるのはファイル名が *.spec.(js|ts|tsx)
のファイルのみになっています。
4.テストを実行する
npx uj dist-uvu spec -i snap
- 第1引数の
dist-uvu
はディレクトリ - 第2引数の
spec
は実行するファイル名のパターン - パラメータ
-i
は ignoreしたいパターンです。(*.spec.js.snap
など)
まとめ
既存のJest
で書かれたテストコードをuvu
をフォークした軽量タスクランナーであるuvu-jest
で実行できるように変換することで、実行時間を大幅に改善することができました。
(一度変換の手間はあるもののttsc
のwatchモードが利用できます。)
改善結果を再掲しますが、私の手元の環境で
-
Node.js + express
で実装したバックエンドの統合テストの実行時間: 1分20秒 => 9~14秒 -
React + Testing Library
で実装したフロントエンドのユニットテスト: 1分20秒 => 20~30秒
また、snapshotテストや、フロントエンドのテストも実行できることが確認できました。
今回ツール作成の過程では多くの紆余曲折がありました。最初はWrapperを作らずにそのままJest
のコードを変換しようとAST変換に挑んだり、原理的にafterAllの順序が再現できないことに気づかなかったりと、非常に多くの躓きや学びがありましたが、そもたびに良い方向に梶を切れたと思います。
今後は自分で活用しながら必要が機能の追加改修を行っていきたいと思いますが、みなさんでも利用して頂ける方がいましたら要望・感想などを含めてissueやPRを頂けると幸甚でございます。
長文になりましたが、ここまで読んで頂きありがとうございました 🤗