ミツモア 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でもお待ちしております。

ミツモア開発チームのこれまでとこれから

ミツモア 朝会の様子

 

こんにちはミツモアCTOの柄澤(@fmy)です。

ミツモアという「カメラマンから税理士まで、かんたんにぴったりなプロに出会えるプラットフォーム」のプロダクトを担当しております。

「確定申告めんどくさい・・・」「子供の卒業式の記念に写真を撮りたいな」といった生活のあらゆるシーンであなたにぴったりの専門家を無料で探せるサービスですので、ぜひ気軽に使ってみてください。

meetsmore.com

このたび、ミツモアのエンジニアリングブログを開設しました。初回といたしましてミツモアの開発チームがどのようにサービス開発をしているか、チームとしてのパフォーマンスを上げるためにどのようなことをしているか、かいつんで話していきたいと思います。

 

技術スタックの変遷

ミツモア創業時、JavaScriptでクライアントサイドのReactとサーバサイドのNodeを全て書いていくという方針でサービス開発が始まりました。創業時メンバーがJavaScript特にNodeが得意だったからという理由と、とにかくスピード感を持って開発をしたいという理由でクライアントサイド・サーバサイド共にJavaScriptとなっています。ReactはReduxを用いたre-ducksパターンでNodeはExpressでシンプルな実装を行っておりました。

それから3年半以上の時間を経て少しずつマイナーチェンジを行ったり、新しいソフトウェアなどの導入をしてます。

  • データ分析基盤としてBigQueryの利用開始
  • SSRの実装とmonorepoスタイルへの移行
  • ElasticSearchの導入
  • JavaScriptからTypeScriptに移行
  • テストフレームワークをavaからjestに移行
  • class componentsから、hooksを用いたfunctional componentsへの移行
  • イベントQueuingシステムの導入
  • Nest.jsの導入

各項目の細かな話は長くなってしまうので別の記事に譲ることにします。

メンバーが新たな技術を導入したい場合、その長所・短所、既存との比較、競合技術との比較を当人にまとめてもらい、チームMTGで議論します。そこである程度の合意形成を経て導入をしていくというプロセスをとっております。

TypeScriptとなった今も「1つの言語でスピード感を持って開発」というのは継続されており、共通の設定項目などが記載されたprivate module @meetsmore/config は、サーバサイド、クライアントサイド両側でimportされています。

 

開発優先度とスプリント

まず「何を開発するか」についてですが、主に4つに分類されます。

  • 経営ミーティングで議論されるような長期目線の開発方針
  • プロジェクトごとで議論される短中期的な開発
  • カスタマーサクセスチームから提議されるバグ修正や改善要望
  • 開発チームで議論される保守開発や技術的負債の解消

それぞれで多くのタスクが積み重なっていくので全てを完璧にこなしていくのはほぼ不可能です。適切な優先度と緊急度とコストの見積もりを行い、取捨選択と開発順序の決定をしていかなければなりません。また現在の開発チームのパフォーマンス、つまりスプリントごとのアウトプット量を把握する必要もあります。

ミツモアでは開発チーム全体で1週間のスプリントを行っております。1週間は短いと思われるかもしれませんが、スタートアップのスピード感を持ち続けるために、ある程度変化に柔軟である必要があるため1週間となっています。

スプリントを始めてから徐々に、現在のチームベロシティを把握し自分たちを客観視できるようになってきて、各プロジェクトのスケジューリングの精度が向上していると感じています。一方チームベロシティの向上に関しては、課題感は把握できてきていますが、ボトルネックの改善や効率化の向上は進みが良くない状況となっており、今後の課題となっています。

開発の進め方、スプリントの手法は随時アップデートしており、現在はこの形式になっておりますが今後も変化していくと思われます。

 

開発の流れ

ミツモア最初の1,2年はとにかくPDCAでいうとPlan→Do!→Plan→Do!→...というイメージでひたすら高速にぶん回していました。ベストかは分かりませんがスタートアップ初期のタイミングでの意思決定としてはそう間違っていないと思っています。朝出たアイディアが夕方にリリースされることもしょっちゅうでした。

しかし次第に人が増えプロダクトの複雑性も増すにつれて、この開発スタイルでは破綻が見えていたので徐々に各ステップを踏むようなスタイルに移行しております。

具体的にいうと、以下のような流れとなります。

  1. プロジェクトキックオフ、インセプションデッキ作成
  2. ビジネスコンセプトやアイディアを議論し仕様に落とし込む
  3. デザイナーが全体や各ページのデザインを作成、レビュー
  4. エンジニアが開発設計、レビュー
  5. 開発、コードレビュー
  6. 受け入れテスト(QA)
  7. リリース
  8. 効果検証、追加開発など

もちろんプロジェクトの粒度や性質によって変化しますが、ベースとなる手順はこのようになっております。特段特殊なことはしてないかなと感じています。

インセプションデッキに関しては、開発メンバーとビジネスメンバーの期待値のすれ違いや、狭間でボールが落ちるのを防ぐのに非常に有用な手段だと感じております。

 

組織

ミツモアの開発チームは、フルタイムのエンジニアが8人、インターンや業務委託なども含めるとエンジニアは15人、デザイナーは2名となっており、まだまだ小さな組織となっています。専門性の高いメンバーはいるものの、まだ少人数ということもありドメイン単位や職種単位でのチーム分けはしていません。フロントエンドのReact、サーバサイドのNode、DevOpsのterraformコード、データ基盤整備のコードそれぞれに対して、メンバーごとの重み付けはあるものの皆が取り組めるような体制となっております。

メインとなる開発は、1-3ヶ月ごとのプロジェクトに1人ないし2人アサインされる形式で役割分担を行なっております。またサブとなる開発は、ミツモアに必要でかつ、各メンバーが取り組みたいものを決定し、各々取り組んでおります。例えばあるメンバーはTypeScriptの移行を主導していたり、別のメンバーはfat controllerとなっている処理のリファクタリングをしたりしております。

徐々にメンバーも増えていっているので、チームの体制も見直しの時期がきたのかなと感じているところです。

 

これから 

ここまで読んでいただきありがとうございます。

以上を読んでも分かるとおり、ミツモアの開発チームは日々プロダクトを改善していくと共に、チーム自体のアップデートも進めていっております。尊敬する起業家の言葉の中で "Don't Sattle" というフレーズがありますが、常により良い理想のために、もがき続けるチームでありたいと思っており、またそれがスタートアップとして必須だとも思っております。

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

www.wantedly.com