コミックサイト XKCD で公開されている 「my code is compiling(コードコンパイル中)」の今日のコミックは、「my tests are running(テスト実行中)」でした。エンジニアは高価なリソースであり、テストを実行するために長時間待機しなければならない場合は、コードをコンパイルするために長時間待たなければならないのと同じように、リソースの無駄遣いになります。Bolt社では、継続的インテグレーション(CI) ツールとして、CircleCI を使用し、テストスイートをより迅速かつ簡単に、そして便利にするために努力しています。このブログでは、当社(Bolt 社)が CI および統合テストパイプラインで必要となる時間を短縮、コンフィグの複雑さを軽減した方法とその過程で得た教訓についてお伝えします。

Bolt でのテスト方法

Bolt では、次の 2 つのテストワークフローを使用しています。1 つ目は、各プルリクとマスターへの各コミットで実行される単体テストです。2 つ目は、デプロイ毎に各環境で実行される統合テストです。単体テストが不安定になることはほぼなく、失敗した場合には調査が必要です。統合テストは、ブラウザオートメーションテストであり、その性質上、信頼性が低くなる傾向があルため、試行錯誤の連続です。既存のテストを安定させることができても、新しいテストによってさらに状況が混迷することになります。Bolt では、2 つのタイプのテストのそれぞれに対して個別の最適化戦略を追求することで、両方のテストを有用なものにすることができました。

CI テストの改善:テストに要する時間を 3 分の 1 に短縮

CI テストはすべてのプルリクに対して実行されるため、どのテストが失敗したかをできるだけ早く開発者に知らせることが大切だと考えています。当社の CI ワークフローを評価したところ、ビルドパイプラインでは、バックエンドに約 15 分間、フロントエンドに 20 分間を要していました。

実行時間に基づくテストの並列化

最初に気づいたのは、サービスに基づいて Golang パッケージのテストを論理的に分割していたことです。つまり、いくつかの小さなサービスは 2 分でテストできていましたが、大規模なサービスには 16 分近くかかっていました。CircleCI の並列処理時間ベースの分割機能により、バラバラだったテストを、バランスよく大体同じ時間で完了できるようになりました。CircleCI コマンドのこのスニペットは、すべての Go パッケージを分割する方法を示しています。

commands:  
  make-check:
    description: Runs tests
    steps:
      - restore_cache:
          keys:
            - go-mod-1-13-v1-{{ checksum "go.mod" }}-{{ checksum "go.sum" }}
            - go-mod-1-13-v1-{{ checksum "go.mod" }}
            - go-mod-1-13-v1-
      - run:
          name: Go tests for hail
          shell: /bin/bash
          command: |
            set -e
            cd /home/circleci/project/
            # Improve sharding: https://github.com/golang/go/issues/33527
            PACKAGES="$(go list ./... | circleci tests split --split-by=timings --timings-type=classname)"
            export PACKAGE_NAMES=$(echo $PACKAGES | tr -d '\n')
            export BUILD_NUM=$CIRCLE_BUILD_NUM
            export SHA1=$CIRCLE_SHA1
            echo "Testing the following packages:"
            echo $PACKAGE_NAMES
            gotestsum --junitfile $ARTIFACTS_DIR/report.xml -- -covermode=count -coverprofile=$ARTIFACTS_DIR/coverage_tmp.out -p 1 $PACKAGE_NAMES
      - save_cache:
          key: go-mod-1-13-v1-{{ checksum "go.mod" }}-{{ checksum "go.sum" }}
          paths:
            - "/go/pkg/mod"

何を行っているかを見ていきましょう。

最初に、Go モジュールキャッシュを復元しました。キャッシュがないと、何も行われません。これにより、実行するたびに Go モジュールをダウンロードする必要がなくなります。go.mod および go.sum ファイルハッシュによって、キャッシュキーが設定されます。

実行ステップは、すべてのパッケージを表示して、時間で分割し、gotestsum を使用してテストを実行します。Gotestsum を使用すると、JUnit レポートを生成でき、どのテストが失敗したかを簡単に確認できます。

最後に、go.mod および go.sum ファイルが変更された場合、キャッシュを保存します。このキャッシュキーがすでに保存されている場合、この手順では何も実行されません。

これだけで、ワークフローに要していた時間が 15 分から 5 分に短縮されました。この処理の大きな利点は、テスト数の増加に合わせて、単に並列処理の数を増やすことができることです。

我々はこの成果で満足して終わりとはせず、さらに何かできないか試してみることにしました。

必要なツールをインストールするための設定手順の 1 つに約 1 分を要していましたが、全てのツールをインストールした新しいイメージを構築することで、このビルドの時間が 30〜45 秒短縮されました。一般的なイメージは頻繁に使用されることからキャッシュされており、CircleCI では非常にすばやくプルされることに注意してください。独自のイメージを構築する前に、節約できる時間を測定するとよいでしょう。

統合テストの改善:時間の節約とコードの複雑さの軽減

統合テストは別の意味で厄介です。Chrome のような GUI のない(ヘッドレス)ブラウザを使用したローカルブラウザーテストと、Browserstack や Saucelabs のようなサービスを使用したリモートブラウザテストの 2 つのブラウザオートメーションテストを当社は実行しています。ローカルテストは、契約している CircleCI プランの制限の影響のみを受けますが、リモートブラウザテストは、並列処理のボリュームに対して費用が発生するため、高価になる可能性があります。CircleCI を使用してリモートブラウザオートメーションテストを実行し、Browserstack でリモートブラウザをホストします。

再実行のために最適化しながらテストを分割する

ステージング、サンドボックス、および本番環境の 3 つの環境に対して統合テストを実行しています。サンドボックスでほぼすべてのテストを実行し、ステージングではサンドボックスよりも小数のテストを実行し、本番環境ではわずかなサブセットのみを実行します。API から統合テストをトリガーできる必要があります。API の v1 ではトリガーができなかったため、非常に複雑なシステムを構築してこの処理を実行していました。しかし、v2 APIが新たにリリースされ、理解しやすく、迅速かつ簡便で理にかなった方法でワークフローを再編成することができました。

これまでは、1 つのジョブで 3 つのタイプのブラウザについて数百のリモートテストを実行していたのです。つまり、1 つのテストでも失敗すると、テスト全体を再実行する必要があり、1 時間以上がかかっていました。これを 8 つのジョブに分割し、失敗したジョブのみを再実行できるようになりました。新しいワークフローは非常にシンプルになりました。このワークフローは、以下に示します。

ワークフローの仕事

新しいテストを追加するときのワークフロー

統合テストのワークフローの 1 つのタイプは、統合テストリポジトリへのすべてのプルリクで実行されます。これにより、追加または変更するテストが有効であることが確認されます。これらは、ローカルヘッドレスブラウザテストのみを実行し、このワークフローはできるだけ軽量な状態を維持します。

ビルドを促進するワークフロー

これらの統合テストは、ステージング、サンドボックス、または本番環境のいずれかの環境に対してマスターで実行されます。これらのテストは厳密である必要があり、Browserstack では複数のブラウザを使用します。さらに、並列化レベルは Browserstack によって制限されるため、並列テストはキューに入れられずに失敗するため、Browserstack を使用するワークフローをキューに入れることで、CircleCI でフェイクキューを作成します。

このスニペットは、リモートブラウザのスタックテストをキューに入れる方法を示していますが、CircleCI で実行されるヘッドレス Chrome テストはブロックしません。

version: 2.1

orbs:
  swissknife: roopakv/swissknife@0.8.0

# These parameters are used to run various workflows via the API and
# are not used directly for every PR. by setting the below params
# we can for example run only staging integration tests.
parameters:
  run_base_tests:
    type: boolean
    default: true
  run_cdstaging_integration_tests:
    type: boolean
    default: false
  run_staging_integration_tests:
    type: boolean
    default: false
  run_sandbox_integration_tests:
    type: boolean
    default: false
  run_production_integration_tests:
    type: boolean
    default: false

workflows:
  # This workflow is run on master changes merged to this repo
  build:
    when: << pipeline.parameters.run_base_tests >>
    jobs:
      - test-on-chrome:
          name: Staging on local chrome
          context: integration-tests-staging-context
      - test-on-chrome:
          name: Sandbox on local chrome
          context: integration-tests-sandbox-context
      - test-api:
          name: Staging API
          context: integration-tests-staging-context

  staging-local-chrome:
    when: << pipeline.parameters.run_cdstaging_integration_tests >>
    jobs:
      - test-on-chrome:
          name: Staging on local chrome
          context: integration-tests-staging-context

  # This workflow is triggered via the API to run tests against the
  # staging environment on browserstack and local chrome.
  staging:
    when: << pipeline.parameters.run_staging_integration_tests >>
    jobs:
      - swissknife/queue_up_workflow:
          max-wait-time: "1800"
          workflow-name: "^(staging|production|sandbox)$"
      - test-on-chrome:
          name: Staging on local chrome
          context: integration-tests-staging-context
      - bs-base:
          name: Staging BS base tests
          context: integration-tests-staging-context
          requires:
            - swissknife/queue_up_workflow
      - bs-shopify:
          name: Staging BS shopify tests
          context: integration-tests-staging-context
          requires:
            - swissknife/queue_up_workflow
      - bs-bigcommerce:
          name: Staging BS bigcommerce tests
          context: integration-tests-staging-context
          requires:
            - swissknife/queue_up_workflow
      - test-api:
          name: Staging API
          context: integration-tests-staging-context

  # This workflow is triggered via the API to run tests against the
  # sandbox environment on browserstack and local chrome.
  sandbox:
    when: << pipeline.parameters.run_sandbox_integration_tests >>
    jobs:
      - swissknife/queue_up_workflow:
          max-wait-time: "1800"
          workflow-name: "^(staging|production|sandbox)$"
      - test-on-chrome:
          name: Sandbox on local chrome
          context: integration-tests-sandbox-context
      - bs-base:
          name: Sandbox BS Base tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-shopify:
          name: Sandbox BS Shopify tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-bigcommerce:
          name: Sandbox BS bigcommerce tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-woocommerce:
          name: Sandbox BS woocommerce tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-miva:
          name: Sandbox BS miva tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-magento1:
          name: Sandbox BS magento1 tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow
      - bs-magento2:
          name: Sandbox BS magento2 tests
          context: integration-tests-sandbox-context
          requires:
            - swissknife/queue_up_workflow

  # This workflow is triggered via the API to run tests against the
  # production environment on browserstack and local chrome.
  production:
    when: << pipeline.parameters.run_production_integration_tests >>
    jobs:
      - swissknife/queue_up_workflow:
          max-wait-time: "1800"
          workflow-name: "^(staging|production|sandbox)$"
      - test-on-chrome:
          name: Prod on local chrome
          context: integration-tests-production-context
      - bs-base:
          name: Prod BS Base tests
          context: integration-tests-production-context
          requires:
            - swissknife/queue_up_workflow

ステージング環境とサンドボックス環境で同じジョブがあることに注意してください。これらのテストは同じであり、変更があるのはテストする環境だけですので、テストコンフィグをコンテキストへと移動し、テストする環境に応じてコンテキストを添付します。

Jenkins を使用して(コンプライアンス上の理由により)デプロイを管理し、統合テストをトリガーします。これで、簡単に統合テストをトリガーし、Jenkins でテストを待機できるようになりました。この例では、新しい v2 API を使用してこの操作を実行する方法を示します。

#!/bin/bash -x

# Exit immediately if a command exits with a non-zero status.
set -e

if [ "$1" == "" ]; then
  echo "Usage: $0 env"
  exit 1
fi

ENV=$1
SLUG=$2

PIPELINE_ID=""
WORKFLOW_ID=""
WORKFLOW_STATUS="running"

start_integration_test () {
  CREATE_PIPELINE_OUTPUT=$(curl --silent -X POST \
  "https://circleci.com/api/v2/project/${SLUG}/pipeline?circle-token=${CIRCLE_TOKEN}" \
  -H 'Accept: */*' \
  -H 'Content-Type: application/json' \
  -d '{
    "branch": "master",
    "parameters": {
      "run_'${ENV}'_integration_tests": true,
      "run_base_tests": false
    }
  }')
  PIPELINE_ID=$(echo $CREATE_PIPELINE_OUTPUT | jq -r .id)
  echo "The created pipeline is ${PIPELINE_ID}"
  # Sleep till circle starts a workflow from this pipeline
  # TODO(roopakv): Change this to a curl loop instead of a sleep
  sleep 20
}

get_workflow_from_pipeline () {
  GET_PIPELINE_OUTPUT=$(curl --silent -X GET \
  "https://circleci.com/api/v2/pipeline/${PIPELINE_ID}?circle-token=${CIRCLE_TOKEN}" \
  -H 'Accept: */*' \
  -H 'Content-Type: application/json')
  WORKFLOW_ID=$(echo $GET_PIPELINE_OUTPUT | jq -r .items[0].id)
  echo "The created worlkflow is ${WORKFLOW_ID}"
  echo "Link to workflow is"
  echo "https://circleci.com/workflow-run/${WORKFLOW_ID}"
}

running_statuses=("running" "failing")
wait_for_workflow () {
  while [[ " ${running_statuses[@]} " =~ " ${WORKFLOW_STATUS} " ]]
  do
    echo "Sleeping, will check status in 30s"
    sleep 30
    WORFLOW_GET_OUTPUT=$(curl --silent -X GET \
    "https://circleci.com/api/v2/workflow/${WORKFLOW_ID}?circle-token=${CIRCLE_TOKEN}" \
    -H 'Accept: */*' \
    -H 'Content-Type: application/json')
    WORKFLOW_STATUS=$(echo $WORFLOW_GET_OUTPUT | jq -r .status)
    echo "The workflow currently has status - ${WORKFLOW_STATUS}."
  done

  if [ "$WORKFLOW_STATUS" == "success" ]; then
    echo "Workflow was successful!"
    exit 0
  else
    echo "Workflow did not succeed. Status was ${WORKFLOW_STATUS}"
    exit 1
  fi
}

# remove noise so set +x
set +x

start_integration_test
get_workflow_from_pipeline
wait_for_workflow

単一のジョブからこのワークフロー制御のアプローチに移行することには、次の利点があります。

  • すべてのテストではなく、一部のテストだけを再実行できる
  • 時間の短縮化
  • 複雑なコードを使用して複数の環境を処理するのではなく、CircleCI コンテキストの中でコンフィグを設定できる

結論

要約すると、テストの実行時間を短縮し、テスト操作を容易にする取り組みを始めたところ、テストタイプごとに異なるアプローチを使用する必要があることに気付きました。単体テストを、実行時間を基準にして分割し、繰り返し発生する可能性のある作業を事前にビルドされたコンテナに移動しました。統合テストについては、並列処理が制限されるリモートブラウザで作業するために最適化しました。コンフィグを簡単に変更するだけで、いくつもの環境で同じテストを実行する仕組みを構築しました。その結果、パイプラインの複雑さも軽減され、新しいエンジニアが、テストインフラストラクチャを理解しやすくなりました。


Roopak Venkatakrishnan は、現在、Bolt社でシステム構築を担当しています。Bolt 社の前には、Spoke、Google、および Twitter で働いていました。複雑な問題を解決する業務の他にも、開発インフラとツールについても情熱を傾けています。オフの時間には、こだわりのコーヒーを楽しんだり、ハイキングをしたり、さまざまなトピックについてツイートしたりします。

さんの他の投稿を読む Roopak Venkatakrishnan