既存のJestで書かれたテストを爆速実行するツール、uvu-jest を作りました

TopPage

概要

本記事ではタイトルの通り、テストコードの開発時間を短縮するべく、Jestで書かれた既存のテストコードを爆速で実行するツール、uvu-jest を開発したので、紹介させて頂きたいと思います!

実行速度の改善結果としては下記のとおりです。

  • Node.js + express で実装したバックエンドの統合テストの実行時間: 1分20秒 => 9~14秒
  • React + Testing Libraryで実装したフロントエンドのユニットテスト: 1分20秒 => 20~30秒

立上がりのオーバーヘッドが少ないので、個別のテストの実行ではさらに差も出てくると思われます。

本記事ではツールの作成に当たって検討したことや、実装方法などをまとめたいと思います。

背景

先日 The State of JS 2021 も発表され、フロントエンド界隈のエコシステムはますますリッチになって来たことを実感します。その中でも esbuildsvelte などの隆盛から、軽量な開発環境への取り組みが一段と加速している様子が伺えます。
React, Vueなどの成熟でWEB開発の型がある程度定まってきた一方で、VUCAな現代環境においては高速なプロトタイピングやトライエラーの価値がより一層高まってきているのだと感じます。
ところで、テストフレームワークの世界は こちら の通り、Jest の利用率が圧倒的に高い状態になっています。最近のフロントエンド開発ではとりあえず Jest みたいな状態で、Testing Library などの人気ツールも Jest との併用を想定して実装されています。
しかしながら、このJestが非常に巨大で鈍足なテストフレームワークであることも既知の通りで、installサイズは約25MBほどあり、実行にもかなり遅さを感じます。立上がりのオーバーヘッドが大きいので、小さなテストを動かしたりwatchモードを利用する際には特にストレスを感じやすかったりします。

バンドルサイズ比較

ある程度のサービスを開発するのであればテストコードの実装はマストなので、esbuildsvelte がある今、開発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コマンドを利用して実行することもできます。 また、アサートにはJestexpect(...).toBe(...)ではなく、独自のパッケージuvu/assertisなどを利用します。

(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に載せ替える事にいくつか懸念点があると思います。

  1. Jest => uvu 書き換えに時間がかかる
  2. 最終的には実績があるJestで結果を確かめられた方が安心する
  3. uvu自体が今後もメンテナンスされていくか?メジャーになるのか?が不透明
  4. 既存のテストの構造を保ちたい
  5. カバレッジの計測や、レポート作成にはJestに馴染んだツールを利用したい

3に関しては、実際に最近のコントリビューション はやや少ないように思います。
4に関しては、uvuではsuiteJestdescribeのような働きですが、describeと違ってネストする事ができません。テストの実行結果をみやすくするためにdescribeを積極的に利用する事が望ましかったりするので、困る開発者もいると思われます。

以上の事から、実行時間を比較的気にしなくても良いCI上ではJestで実行しますが、手元で開発する時は暫定的にuvuで実行する事ができたら開発効率と安定性を両立できベストだと考えました。つまり、なんらかの方法で既存のJestで書かれたテストをuvuで実行できる形に変換できれば良いということになります。

既存では要件に合うツールがなかったのですが、メリットは大きいと思い、自分で開発することにしました。

実装方法

uvuをフォーク

Jest => uvuの書き変えが実際に可能か?はJestの機能をuvuで表現できるか?を考える必要があがあります。前提としてJestは非常に機能が豊富なので全てをサポートすることは考えていませんが、主要なところではライフサイクル関数であるafterAlluvuで再現することができません。

具体的なユースケースで説明すると、APIの統合テストの例で以下のようなコードを考えます。

describe('desc1', () => {
  beforeAll(() => {/* APIサーバの開始 */})
  afterAll( () => {/* APIサーバの終了 */})

  describe('desc2', () => {
    let apiResponse;
    beforeAll(() => { /* APIリクエストの結果をapiResponseに代入 */; )
    test('test2', () => { /* apiResponseの内容を確認 */});
  });
});

第1階層のライフサイクルで、APIサーバの立ち上げ・終了を行い、第二階層のライフサイクルで各APIリクエストを実行します。

ちなみにTestingTrophy を意識すると、上記の様な統合テストを書く機会もそれなりにあると思います。

https://user-images.githubusercontent.com/20538481/156463430-0c72e420-bea6-439c-b837-45efb3b21117.png

しかしながら、uvuにもsuite.afterというライフサイクルはあるのですが、suiteはネストする機能がなく、suiteごとにrunしていく必要があるため、上記のafterAllの実行順序を担保することができません。 ネストができないことへの問題点はこちら で議論されていましたが、ライフサイクルフックの実行順序に関しては言及されてなく実装方針は示されていない状況でした。

そこで、やむなく uvuuvu-jest としてフォークし、ネスト機能を補完していくことに決めました。結果的には後述するsnapshotテスト対応などもフォークしたことで実現ができました。

wrapperを作成

コードの変換自体はTypescriptCompiler 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);
    });
  })
});

違いは

  1. import文の追加
  2. describeの第二引数に渡す関数に引数を明記する

の二点だけになります。

コードの変換

TypescriptCompiler API に含まれる、Transform関数は独立して利用することができますが、やはりtscのプラグインとして利用できた方がwatchモードが使えたり差分でビルドできたりと何かと便利です。あいにく、tscTransformをプラグインとして設定することができません。

そこで、ttypescript を利用します。ttypescriptttscというtscをラップしたCLIコマンドを提供しますが、tsconfig.jsonTransformプラグインを指定することができます。

プラグインの実装としては transformer/jest-to-uvu.ts に独立したパッケージとして作成しています。

プラグインを指定したtsconfig.jsonのイメージはこんな感じで、

tsconfig.ttsc.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の機能サポート

基本機能

Jestexpect関数の戻り値には、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テスト

Jestexpect().toMatchSnapshot()で利用できるスナップショットテストは利用している方も多い機能だと思います。uvuにもsnapshotテストの機能はありますが、当然Jestとの互換性はないuvu仕様なので、既存のsnapshotファイルを利用することができません。 そこで、調べたところ jest-snapshot というJestのスナップショット機能部分をstand-aloneなnpmパッケージとして切り出したものがあり、これが利用できそうです。

JesttoMatchSnapshotに準拠する新たなカスタムMatcherを作成しました。注意した点として、jest-snapshotも巨大なライブラリで、手元ではimportするだけで400msほど消費していたので、必要な時に一度だけ読み込まれるように工夫しました。 また、uvu-jestではttscでコンパイルしたJSコードを実行するのですが、JSdistなど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',
};

setupFilesJestに準拠し、(環境変数の設定など) テスト前に実行するファイルを指定できるようにしています。

Testing Library

Testing Libraryglobal-jsdom を利用することで、 node -r global-jsdom/register node_modules/.bin/uj の様にして実行することができます。 (ujuvuをフォークした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でもこれと同様に、任意のMatcherRecord<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の変換結果を指定するtargetNode.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を頂けると幸甚でございます。

長文になりましたが、ここまで読んで頂きありがとうございました 🤗