logoMiyauchi

JestでTypeScriptを高速化する

はじめに

esbuild の登場により、フロントエンドの世界は、開発環境により速度を求めるようになりました。vite の隆盛はその最たるものといってもいいでしょう。

esbuildswc は高速な GoRust によって書かれ、更に多くの場合、Typescript の型チェックを省略しています。 tsc の型チェックは、大抵 IDE やワークフローで行われているので、これらを削ぎ落とすことで、純粋なコンパイラとして JavaScript への変換に特化しているということですね。

さて、Typescript コードをテストする際、多くの場合ts-jestbabel-jestをトランスフォーマーとして使用していると思います。しかし、これらによってテストの速度が低下することがあります。

今回は jest の実行を高速化し、高速なテストを実現する方法を紹介します。

結論

先に導入方法について書いておきます。

yarn add -D jest @swc/jest
{
  "transform": {
    "^.+\\.(t|j)sx?$": "@swc/jest"
  }
}

トランスフォーマーに swc を jest 向けに調整した @swc/jest を使います。

パフォーマンス比較

高速化によって、どの程度パフォーマンスが改善したか見てみます。

実行環境によってパフォーマンスは異なるので、実測値ではなくそれぞれ結果の相対的な対比を行います。

CommonJS + Javascript

理論上最速となりそうなパターンを試してみます。CommonJS 形式の JavaScript はトランスパイルの必要がないはずなので、最速になるはずです。(間違ってたらごめんなさい 🙏)

関数の中身に興味はないので、適当な関数を用意してテストします。

exports.add = (a, b) => a + b
const { add } = require('../src')

describe('add', () => {
    it('should return 2 when it gives 1,1', () => {
        const result = add(1,1)
        expect(result).toBe(2)
    })
})
module.exports = {
  testEnvironment: "node",
  roots: ["<rootDir>/test/"]
};

これをキャッシュを無効にして 10 回程度の平均を取ります。 あまり厳密な測定ではないですが、今回はそれぞれの速度比較なので、条件を合わせることで相対的な比較はできていると考えます。

for i in {0..9}; do yarn jest --no-cache ; done

結果:

Transformer

平均値(s)

なし(CommonJS + JavaScript)

0.512

これを基準に考えていきます。

ESM + TypeScript

TypeScript を ES module 形式で記述するパターンです。TypeScript を使う場合の多くはこのパターンでしょう。

export const add = (a: number, b: number): number => a + b
import { add } from '../src/'

describe('add', () => {
    it('should return 2 when it gives 1,1', () => {
        const result = add(1,1)
        expect(result).toBe(2)
    })
})

トランスフォーマーに ts-jest を使う

module.exports = {
  ...,
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  }
};

結果:

Transformer

平均値(s)

ts-jest

1.660

なし(CommonJS + JavaScript)

0.512

CommonJS + JavaScript と比較すると3倍程度時間がかかっています。これはなんとかしたいですね。

トランスフォーマーに esbuild を使う

esbuildは Go で記述された高速なバンドラーです。バンドラーと言ってもデフォルトで、TypeScript 構文の解析と型注釈の破棄に対するサポートが組み込まれています。

yarn add -D esbuild-jest esbuild
module.exports = {
  ...,
  transform: {
    '^.+\\.tsx?$': 'esbuild-jest'
  }
};

結果:

Transformer

平均値(s)

esbuild-jest

0.373

ts-jest

1.660

なし(CommonJS + JavaScript)

0.512

驚異的な速度ですね。特に CommonJS + JavaScript よりも早いのは驚きですね。

トランスフォーマーに swc を使う

swc は、rust で記述された超高速コンパイラです。 Denodeno lintdeno docに使っているみたいですね。

yarn add -D @swc/jest
module.exports = {
  ...,
  transform: {
    '^.+\\.tsx?$': ['@swc/jest'],
  }
};

結果:

Transformer

平均値(s)

@swc/jest

0.351

esbuild-jest

0.373

ts-jest

1.660

なし(CommonJS + JavaScript

0.512

esbuildswc も驚異的な速度でトランスパイルできることがわかります。双方の速度面の比較はこの結果だけではできませんが、調べた感じだとswcのほうが若干有利のようでした。ただし、esbuild-jest ではオプションとして次の項目を変更できる利点があります。

interface Options {
  jsxFactory?: string
  jsxFragment?: string
  sourcemap?: boolean | 'inline' | 'external'
  loaders?: {
    [ext: string]: Loader
  }
  target?: string
  format?: string
}

また、VSCode の場合は、jest の拡張機能が提供されています。 これは、テスト対象に変更があった場合などに、バックグラウンドで自動的にテストを実行してくれますが、当然このテストも高速になります。

テストが成功した場合、✅を対象コードにつけてくれます。テストがすぐに終わるため、すぐにマークをつけてくれるのが開発体験としてはかなりいいです。

jest.config.ts をやめる

上の結果は、jestの設定ファイルとしてjest.config.jsを使っていました。 しかし、.ts形式の設定ファイルにすると、トランスフォーマーを変えたとしても、パフォーマンスがあまり改善しません。

// @swc/jest + jest.config.js
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.385 s
Ran all test suites.
✨  Done in 1.24s.

// @swc/jest + jest.config.ts
Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.389 s
Ran all test suites.
✨  Done in 2.93s.

ファイル形式を .ts に変えただけで2倍以上時間がかかるようになってしまいました。

これは jest は jest.config.ts のトランスパイルに ts-node を要求しているからです。 そこで可能であれば json 形式で jest.config.json や諦めて jest.config.js 形式で設定ファイルを書く必要があります。

Cons

冒頭述べている通り、esbuildswc は型チェックを省略し、速度を享受しています。よって、次のコードはテスト時にコンパイルエラーを検出できません。

export const add = (a: number, b: string): number => a + b

この場合は、アノテーションが不適切ですが、JavaScript にコンパイルされたときには、正常のコードとして動くのでテストも通ります。

かといっても、大抵の IDE ではエラーを視覚的に表示しているはずですし、ワークフローに tsc を追加しておけば、未然に防ぐことができます。

以上のことから、速度重視で代替手段や工夫が講じられる環境であれば、積極的に採用できるのではと思います。