ミツモア Tech blog

「ミツモア」を運営する株式会社ミツモアの技術ブログです

CI 環境でのユニットテストの実行時間を2倍速くした話 (Jest + Mongo DB + Circle CI)

こんにちは。ミツモアでテックリードしております白柳(@yanaemon) です。

ミツモアのプロダクトは TypeScript で、クライアントサイドの React とサーバサイドの Node.js で書かれており、ユニットテストは Jest + Mongo DB + Circle CI を利用しています。 しかし、プロダクトが大きくなるにつれて、全ユニットテストを実行するのに 10 分程度かかり、開発効率が悪くなっていました。 今回は、それを 2 倍以上速度改善した取り組みについて紹介します。

実施したこと

いきなりですが、具体的に実施したことと、改善結果になります。

実施内容 改善結果
1. ファイル分割をし、並列度をあげる これ単体では速度改善は期待できない
2. テスト対象を修正されたファイルのみにする x1 ~ x10 速度 UP ※ただし最終的にはやめました
3. DB 生成などは、Worker 単位で作成し再利用性を高める x1.5 速度 UP
4. CI で Jest cache の保存・リストア 約 10 ~ 30 秒短縮
5. CI 自体の仕組みで並列実行 x1.5 速度 UP

では、詳細を書いていきます。

前提: Jest の並列実行の仕組み

速度を改善していくにあたり、Jest がどう並列実行をしているか把握しておく必要があります。 手っ取り早く理解するために下記のようなテストを実行してみます。

動作が分かりやすくなるように、テスト全て下記を呼び出します。 実行には 1 秒かかるようにし、また、Process ID を出力しておきます。

const runTest = async (str) => {
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log(`${str}: ${process.pid}`)
  expect(1).toBe(1)
};

test1 は下記です。

// __tests__/test1.js
test('1', async () => await runTest('test 1-1'))
test('2', async () => await runTest('test 1-2'))

describe('describe', () => {
  test('describe 1', async () => await runTest('describe 1-1'))
  test('describe 2', async () => await runTest('describe 1-2'))
})

test2 は下記です。

// __tests__/test2.js
test('1', async () => await runTest('test 2-1'))
test('2', async () => await runTest('test 2-2'))

実行してみましょう。

$ jest
 PASS  __tests__/test2.js
      test 2-1: 98752
      test 2-2: 98752

 PASS  __tests__/test1.js
      test 1-1: 98751
      test 1-2: 98751
      describe 1-1: 98751
      describe 1-2: 98751

Test Suites: 2 passed, 2 total
Tests:       6 passed, 6 total
Snapshots:   0 total
Time:        4.764s, estimated 5s
Ran all test suites.

結果を見てわかることは 全体の実行に約 4 秒かかっていて、これは test1 の 4 テスト分(1テスト1秒かけているため) に等しいため、下記のように実行されていることが分かります。 * 同一ファイルのテストは直列に実行される(同一ファイル内のテストは全て同じプロセス ID が表示されているため) * 異なるファイルのテストは並列に実行される(異なるファイルのテストは別プロセス ID が表示されているため)

f:id:meetsmore:20210121124833p:plain

デフォルトの並列数 (Worker数) は CPU 数 - 1 となっており、--maxWorkers オプションで変更できます。

これを踏まえて、テストの実行時間を早くする方法を考えていきました。

1. ファイル分割をし、並列度をあげる

例えば、並列度は上げているにも関わらず、ある1つのファイルのテストケースの数がとても多いなどの理由で、そのテストの実行時間に引きずられて遅くなってしまっている場合に有効です。

例として、下図のようなテストが実行されていたとします。

f:id:meetsmore:20210121124838p:plain

Test1 を 2 つに分割すると下記のように実行させることができ時間を短縮できます。

f:id:meetsmore:20210121124842p:plain

ただし、ファイル分割はあくまでも並列実行を効率よく行うことはできるようになりますが、テストの実行時間が減ったわけではなく、この対応単体では速度改善は見込めません。 後述の 5. CI 自体の仕組みで並列実行 と組み合わせることで効果を発揮します。

2. テスト対象を修正されたファイルのみにする

Jest には、修正があったファイルのみをテストする 2 種類のオプションがあります。

例えば branchA と差分があるファイルのみを対象にする場合

$ jest --changedSince=branchA

とすると差分実行してくれます。この時、差分があるファイルに依存しているファイルまで対象にして実行してくれます。便利です。

ただし、この方法だと修正数に応じてテスト時間にばらつきがでたり、 多くのファイルに依存しているファイルを編集するとほとんどのファイルが実行されたり、 そしてごく稀に直接依存しないテストで不具合が見つかったりしたこともあった(例: node の version up でうまく動かなくなったファイルなど)ため、 現在ではこのオプションは利用せず全実行させるようにしています。

PR でのテストでは差分実行。master merge 後は全実行と使い分けてもいいかもしれません。

3. DB 生成などは、Worker 単位で作成し再利用性を高める

改善前は DB を利用した並列実行のために、テストごとに DB サーバを起動し、Database や Table もテストごとに作成していました。

f:id:meetsmore:20210121124845p:plain

これだと、DB に依存するテスト全てで DB の初期化処理が走るため、これを DB サーバの起動はテスト実行時に1度のみ、Database および Table 作成は Worker ごとに作成(直列実行で同時接続することはないため)、connect はテストごとに実施として、最小限に変更しました。

イメージとしては、下記です。

f:id:meetsmore:20210121124850p:plain

setup で DB サーバを起動し、teardown で stop させます。 この例では、mongodb-memory-server を利用してテストをし、DBサーバの URI を受け渡すために環境変数を利用します。

// __tests__/setup.ts
import { MongoMemoryReplSet } from 'mongodb-memory-server'

export default function setup() {
  const replSet = new MongoMemoryReplSet({
    replSet: { storageEngine: 'wiredTiger' },
  })
  ;(global as any).__MONGOD__ = replSet
  return replSet.waitUntilRunning().then(async () => {
    process.env.__MONGO_URI__ = await replSet.getUri()
    process.env.__MONGO_DB_NAME__ = await replSet.getDbName()
  })
}

// __tests__/teardown.ts
export default async function teardown() {
  await (global as any).__MONGOD__.stop()
}

// __tests__/jest.config.js
module.exports = {
  globalSetup: '<rootDir>/__tests__/setup.ts',
  globalTeardown: '<rootDir>/__tests__/teardown.ts',
  testPathIgnorePatterns: [
    '/setup.ts',
    '/teardown.ts',
    'helpers',
  ],
}

利用時、JEST_WORKER_ID で worker の番号が取得できるため、これを利用して worker 分のみ database を作るようにします。 また、Mongo DB の ORM として、mongoose を利用しています。

// __tests__/helpers/mongo.ts
import * as mongoose from 'mongoose'

const mongoDbOptions = {
  useCreateIndex: true,
  useFindAndModify: false,
  useNewUrlParser: true,
  useUnifiedTopology: true,
  // other options
}

function connect() {
  const mongoDbName = process.env.__MONGO_DB_NAME__
  const mongoUri = process.env.__MONGO_URI__
  const workerId = process.env.JEST_WORKER_ID || 0
  const newMongoUri = mongoUri.replace(mongoDbName, `${mongoDbName}-${workerId}`)
  return mongoose.connect(newMongoUri, mongoDbOptions)
}

function disconnect() {
  return mongoose.disconnect()
}

export { connect, disconnect }

あとは、この helper を使ってテストを書くだけです。

import * as mongo from '__tests__/helpers/mongo'

beforeAll(async () => {
  await mongo.connect()
})

afterAll(async () => {
  await mongo.disconnect()
})

test('test case with mongodb', () => {
  ...
})

この対応で約 1.5 倍ほど実行時間を改善することができました。

4. CI で Jest cache の保存・リストア

ここからは Circle CI と組み合わせての改善を行っていきます。

Jest では、高速化のためにキャッシュをしています。

このキャッシュディレクトリは config で変更できます。 https://jestjs.io/docs/en/configuration#cachedirectory-string

// jest.config.js
module.exports = {
  cacheDirectory: '/tmp/jest',
  ...
}

このキャッシュディレクトリを CI でも使えるように設定します。 ミツモアでは現在 Circle CI を利用しているため、Circle CI では下記のようになります。

// .circleci/config.yml
  save_jest_cache:
    description: "save jest cache"
    steps:
      - save_cache:
          paths:
            - /tmp/jest
          key: jest-{{ .Environment.CACHE_VERSION }}

  restore_jest_cache:
    description: "restore jest cache"
    steps:
      - restore_cache:
          keys:
            - jest-{{ .Environment.CACHE_VERSION }}

  jest:
    ...
    steps:
      - restore_jest_cache
      - run: yarn jest
      - save_jest_cache

npm, yarn でインストールした module もキャッシュできるので、こちらも忘れずにキャッシュさせておきましょう https://circleci.com/docs/2.0/caching/

cache restore & save の時間は逆に増えるので、劇的な改善とまではいきませんが、約 10 ~ 30 秒程度ですが短縮できます。

5. CI 自体の仕組みで並列実行

こちらは Jest の仕組みは関係ないですが、CI 自体の並列実行をすることで、さらなる並列化が実現できます。 Circle CI では Parallelism という仕組みがあり、これにより Jest の実行自体を並列実行できます。 https://circleci.com/docs/2.0/parallelism-faster-jobs/

    jest:
      ...
      parallelism: 4
      steps:
        - restore_jest_cache
-       - run: yarn jest
+       - run:
+           command: |
+             circleci tests glob "__tests__/**/*.ts" | circleci tests split --split-by=timings --timings-type=classname > /tmp/tests-to-run
+             yarn jest $(cat /tmp/tests-to-run)
        - save_jest_cache

cache restore などは並列実行してもそれぞれの Executor で行われるため、さすがに単純に 4 倍とまではいきませんが、約 1.5 倍の改善ができました。 あまり分割しすぎると逆に遅くなりますので、最適な並列数を探してみてください。

以上の改善を実施したことで、テスト全実行の時間が 10 分ほどかかっていたものが、約 4 分ほどと今までの半分以下にまで改善することができました。

まとめ

この記事ではミツモアの Jest + Mongo DB + Circle CI を用いたユニットテストの実行時間を改善した話について書きました。 ユニットテストの実行時間は開発効率に直結するためとても重要であるものの、プロダクトが大きくなるにつれてテストも増え、何も対策しないと実行時間だけが増えていきます。 プロダクトが大きくなっても実行時間は今まで通りを保てるように、日々速度改善に取り組んでいるので、また機会があれば紹介したいと思います。

また、現在事業拡大を進めておりエンジニア・デザイナー・PdMを積極採用中です。ぜひWantedlyのリンクからカジュアル面談をしましょう。TwitterのDMでもお待ちしております。