date-fns をアップデートしたらテストが3倍遅くなったので調査した話

こんにちは、三菱UFJフィナンシャル・グループの戦略子会社である Japan Digital Design で Technology & Development Div. に所属する西村です。
Japan Digital Design Advent Calendar 2024 の3日目 の記事になります。よろしくお願いいたします。
はじめに
ある Node.js バックエンドのプロジェクトにて、日付操作ライブラリである date-fns をアップデートしたところ、jest で実行しているテストの実行時間が3分ほどから9分ほどへと大幅に伸びてしまいました。
これはまずい・・! と原因を調査して、暫定的に対策を検討した経緯をまとめてみます。
サマリ
-
date-fnsの3.0.0は、TypeScript で書き直されており、出力されるコードの構成も大きく変っていました。 -
変更後の構成では、TypeScript の型チェックやJavascriptへのトランスパイルに使っている
ts-jestの処理が大幅に遅くなってしまうようでした。 -
ts-jestを@swc/jestなどに置き換えることで、実行速度の改善が期待できますが、特定のプロパティがundefinedになってしまう現象が取り除けなかったため断念しました。 -
テストの実行を並列化することでテスト時間の短縮を試みています。
背景
-
jestのテストは GitHub Actions の GitHub Hosted Runner(スタンダード)で実行 - おおよそ 3分程度で完了していたものが9分程度かかるようになっていた
- テスト数はおおよそ450件
原因調査
どこで遅くなった? 原因特定まで
テストが遅くなったタイミングのコミットでは、date-fns を2系から4系へのアップデート、さらに date-fns-tz を1系から3系へアップデートしていたため、このどちらかが影響してそうです。
どちらが原因なのか突き止めるために、date-fns-tz のパッケージを利用しないようにソースコードを修正し、date-fns のみアップデート前後で比較してみた結果、テスト時間の遅くなり方に変化がなかったため、date-fns のみが影響していると判断しました。
さらに date-fns のどのバージョンで遅くなっているのか順番にアップデートしていき確認し、 2系から 3.0.0 へのアップデートで遅くなっているのを特定した形です。
なぜ date-fns のアップデートで遅くなるのか?
date-fns に 3.0.0 でどんな変更が入ったのか確認したところ、テスト時間に影響しそうな変更は以下でした。
- TypeScript による書き換え
- ES Module と CommonJS の両方へ対応する
dual-package化 - その他、様々な実行時処理の変更
1. TypeScript による書き換え(影響あり)
node_modules/date-fns 以下に出力されるコードの構成が2系と3系で大きく変わっており、特に新たに追加された index.d.ts や locale.d.ts の export 数が多いことが気になりました。
試しにプロジェクトで使っていない、不要な export を削ったところ、テスト速度が大幅に改善しました。(ほぼ元の時間戻りました)
node_modules/date-fns/index.d.ts の不要なexportのコメントアウト
// export * from "./add.js";
// ..
export * from "./addDays.js";
export * from "./addHours.js";
// ..
export * from "./format.js";
// ..
node_modules/date-fns/locale.d.ts の不要なexportのコメントアウト
// export * from "./locale/af.js";
// ...
export * from "./locale/ja.js";
// ...
このことより、jest が行っている TypeScript の型チェックやトランスパイル処理がボトルネックになっているのではないかと想像しています。
ただどういった理由で遅くなっているのかは力不足で不明です。内部処理を読み解けばわかるのかも・・
2. ES Module と CommonJS の両方へ対応する dual-package 化(影響なし)
jest はそもそも CommonJS のコードを期待しているため、date-fns が ES Module に対応したといっても CommonJS の方のコードを読み取っていると考えられるため、この影響はないだろうと考えました。
試しに node_modules/date-fns 以下の ES Module 関連のコードを消してみましたが、速度改善はみられませんでした。
3. その他、様々な実行時処理の変更(影響なし)
以下のような、プロジェクトで頻繁に行われている処理を含んだテストコードを書いて、実行時間を比較したところ、むしろ速度がわずかに改善していました。
import { addDays, format } from 'date-fns'
import { toZonedTime } from 'date-fns-tz'
describe('test', () => {
it('test', async () => {
const num = 10000
const start = new Date()
for (let i = 0; i < num; i++) {
const date = new Date()
const jst = toZonedTime(date, 'Asia/Tokyo')
const oneDayAfter = addDays(new Date(jst.getFullYear(), jst.getMonth(), jst.getDate()), 1)
const result = format(oneDayAfter, 'yyyy-MM-dd HH:mm:ss')
}
})
})
対策
transformer の入れ替え(断念)
jest のテスト時間短縮でよく取られる方法として、jest の transformer を ts-jest から @swc/jest に入れ替える方法がよく提案されています。(参考)
実際に @swc/jest に入れ替えたところ、大幅に時間が短縮しました。ただし一部、特定のプロパティが undefined になってしまう現象が発生し、テストが全ては通らなかったため断念しています。
export abstract class A<TKey> {
readonly b: B
constructor(b: B) {
this.b = b
}
someMethod() {
// なぜかこの時点で this.b が undefined になる場合がある
}
原因は突き止められませんでしたが、プロジェクト固有のコードが悪さをしているのかもしれません。
CIでのテスト並列化
上記の他に transformer を速くする方法も見つからないため、テストを並列で実行する方法を検討しています。イメージですが、以下のように GitHub Actions の strategy.matrix と jest の --shard オプションを指定することで、テストを分割して並列で実行予定です。(参考)
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
...
- run: jest --runInBand --shard ${{ matrix.shard }}
まだまだ確認中ですが、CI全体の実行時間は12分ほどから6分ほどに短縮できそうです。
date-fns のバージョンを2系に固定する(この方法は取らない)
他の方法としては、date-fns を2系で固定しておく方法もあります。
ただし、date-fns が原因でテストが遅くなったのと同じ現象が、他のパッケージでも発生する可能性はありますので、date-fns だけを固定しておけば良いものではないと考えています。
おわりに
CIの待ち時間は開発者体験に直結するので、こういったテスト時間の悪化を見つけたら今後も改善していきたいなと思ってます!
以上、西村でした。
最後までご覧いただきありがとうございました。
Japan Digital Design株式会社では、一緒に働いてくださる仲間を募集中です。カジュアル面談も実施しておりますので下記リンク先からお気軽にお問合せください。
この記事に関するお問い合わせはこちら
Technology & Development Div.
Shinichi Nishimura
Discussion