CI / CD パイプラインに革命を起こす: テストコンテナと Bazel の統合

現代のソフトウェア開発における課題の 1 つは、ソフトウェアを頻繁に自信を持ってリリースできることです。 これは、ソフトウェアをテストし、人間の介入を最小限に抑えてリリースできる優れたCI/CDセットアップが整っている場合にのみ実現できます。 しかし、最新のソフトウェアアプリケーションは、さまざまなサードパーティの依存関係も使用しており、多くの場合、複数のオペレーティングシステムとアーキテクチャで実行する必要があります。 

この投稿では、 BazelTestcontainers の組み合わせが、 密閉型ビルド システムを提供することで、開発者がソフトウェアをビルドしてリリースするのにどのように役立つかを説明します。

bazel 2400x1260 1 を使用して testcontainers テストを実行するバナー

Bazel コンテナと Testcontainers を一緒に使用する

Bazel は、多言語、マルチプラットフォーム プロジェクトをビルドおよびテストするために Google によって開発されたオープンソースのビルド ツールです。 いくつかの大手IT企業は、次のようなさまざまな理由でモノレポを採用しています。

  • コードの共有と再利用性 
  • プロジェクト間のリファクタリング 
  • 一貫性のあるビルドと依存関係の管理 
  • バージョン管理とリリース管理

多言語サポートと再現可能なビルドに重点を置いた Bazel は、このようなモノレポの構築に優れています。

Bazel の重要な概念は 密閉性であり、これは、すべての入力が宣言されたときに、ビルドシステムが出力を再構築する必要があるタイミングを知ることができることを意味します。 このアプローチは、同じ入力ソース コードと製品構成が与えられた場合、ビルドをホスト システムへの変更から分離することで、常に同じ出力を返すという決定論をもたらします。

Testcontainers は、開発およびテストのユース ケース用に使い捨てのオンデマンド コンテナーをプロビジョニングするためのオープン ソース フレームワークです。 テストコンテナを使用すると、データベース、メッセージブローカー、Webブラウザ、またはDockerコンテナで実行できるほぼすべてのものを簡単に操作できます。

Bazel と Testcontainers を一緒に使用すると、次の機能が提供されます。

  • Bazel は、C、C++、Java、Go、Python、Node.js などのさまざまなプログラミング言語を使用してプロジェクトをビルドできます。
  • Bazel は、分離されたビルド / テスト環境を目的の言語バージョンで動的にプロビジョニングできます。
  • Testcontainerは、必要な依存関係をDockerコンテナとしてプロビジョニングできるため、テストスイートは自己完結型になります。 データベースやメッセージブローカーなど、必要なサービスを手動で事前プロビジョニングする必要はありません。 
  • すべてのテストの依存関係は、Testcontainers API を使用してコードで表現でき、テスト間でそのようなリソースを共有することで、気密性を損なうリスクを回避できます。

Bazel と Testcontainers を使用して、さまざまな言語を使用するモジュールでモノレポをビルドおよびテストする方法を見てみましょう。
Javaproductsを使用するモジュールとGoを使用するモジュールを含むcustomersモノレポを探索します。どちらのモジュールもリレーショナルデータベース(PostgreSQL)と対話し、テストにTestcontainersを使用します。

Bazel を使ってみる

まず、Bazel の基本概念について理解していきましょう。Bazel をインストールする最善の方法は、 Bazelisk を使用することです。 公式のインストール手順に従って、Bazeliskをインストールします。インストールが完了すると、Bazelisk version コマンドと Bazel version コマンドを実行できるようになります。

$ brew install bazelisk
$ bazel version

Bazelisk version: v1.12.0
Build label: 7.0.0

Bazel を使用してプロジェクトをビルドする前に、そのワークスペースを設定する必要があります。 

ワークスペースは、プロジェクトのソースファイルを保持するディレクトリであり、次のファイルが含まれています。

  • この WORKSPACE.bazel ファイルは、ディレクトリとその内容を Bazel ワークスペースとして識別し、プロジェクトのディレクトリ構造のルートにあります。
  • MODULE.bazel Bazel プラグインへの依存関係を宣言するファイル(「ルールセット」と呼ばれます)。
  • 1 つ以上 BUILD (または BUILD.bazel) ファイルには、プロジェクトのさまざまな部分のソースと依存関係が記述されています。 ファイルを含む BUILD ワークスペース内のディレクトリはパッケージです。

最も単純なケースでは、 MODULE.bazel ファイルは空のファイルであり、ファイル BUILD には次のように 1 つ以上の汎用ターゲットを含めることができます。

genrule(
    name = "foo",
    outs = ["foo.txt"],
    cmd_bash = "sleep 2 && echo 'Hello World' >$@",
)

genrule(
    name = "bar",
    outs = ["bar.txt"],
    cmd_bash = "sleep 2 && echo 'Bye bye' >$@",
)

ここでは、と の 2 つのターゲットがあります。 foo barこれで、次のように Bazel を使用してこれらのターゲットをビルドできます。

$ bazel build //:foo <- runs only foo target, // indicates root workspace
$ bazel build //:bar <- runs only bar target
$ bazel build //... <- runs all targets

モノレポでの Bazel ビルドの構成

ここでは、testcontainers-bazel-demo リポジトリで Bazel の使用について説明します。このリポジトリは、Javaを使用したモジュールとproductsGoを使用したモジュールを含むcustomersモノレポです。その構造は次のようになります。

testcontainers-bazel-demo
|____customers
| |____BUILD.bazel
| |____src
|____products
| |____go.mod
| |____go.sum
| |____repo.go
| |____repo_test.go
| |____BUILD.bazel
|____MODULE.bazel

Bazel では、プロジェクトの種類ごとに異なるルールを使用してビルドします。 Bazel は、Java パッケージの構築、Go パッケージの構築、 rules_go rules_python Python パッケージの構築などに使用します rules_java 。

また、追加機能を提供する追加のルールを読み込む必要がある場合もあります。 Javaパッケージをビルドするには、外部のMaven依存関係を使用し、テストの実行にJUnit 5 を使用する場合があります。 その場合、Mavenの依存関係を使用できるようにロード rules_jvm_external する必要があります。 

新しい外部依存関係サブシステムである Bzlmod を使用して、外部依存関係をロードします。 MODULE.bazelファイルでは、次のように追加のrules_jvm_externalものをcontrib_rules_jvmロードできます。

bazel_dep(name = "contrib_rules_jvm", version = "0.21.4")
bazel_dep(name = "rules_jvm_external", version = "5.3")

maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
   name = "maven",
   artifacts = [
       "org.postgresql:postgresql:42.6.0",
       "ch.qos.logback:logback-classic:1.4.6",
       "org.testcontainers:postgresql:1.19.3",
       "org.junit.platform:junit-platform-launcher:1.10.1",
       "org.junit.platform:junit-platform-reporting:1.10.1",
       "org.junit.jupiter:junit-jupiter-api:5.10.1",
       "org.junit.jupiter:junit-jupiter-params:5.10.1",
       "org.junit.jupiter:junit-jupiter-engine:5.10.1",
   ],
)
use_repo(maven, "maven")

ファイル内の MODULE.bazel 上記の構成を理解しましょう。

  • Bazel Central レジストリ から ルールを rules_jvm_external ロードし、サードパーティの Maven 依存関係を使用するための拡張機能をロードしました。
  • アーティファクト構成で maven.install Maven座標を使用して、すべてのJavaアプリケーションの依存関係を構成しました。
  • JUnit 5テストの実行をサポートするルールをスイートとしてロードcontrib_rules_jvmしています。

これで、プログラムを実行して @maven//:pin 、推移的な依存関係のJSONロックファイルを、後で使用できる形式で rules_jvm_external 作成できます。

bazel run @maven//:pin

生成されたファイルrules_jvm_external~4.5~maven~maven_install.jsonmaven_install.jsonの名前を に変更します。次に、依存関係を固定したことを反映して、を更新し MODULE.bazel ます。

lock_file属性maven.install()を そして、 use_repo 依存関係の更新に使用されるリポジトリも公開 unpinned_maven するように呼び出しを更新します。

maven.install(
    ...
    lock_file = "//:maven_install.json",
)

use_repo(maven, "maven", "unpinned_maven")

これで、依存関係を更新するときに、次のコマンドを実行してロックファイルを更新できます。

​​bazel run @unpinned_maven//:pin

ファイルで customers/BUILD.bazel ビルドターゲットを次のように構成しましょう。

load(
 "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
 "default_java_toolchain", "DEFAULT_TOOLCHAIN_CONFIGURATION", "BASE_JDK9_JVM_OPTS", "DEFAULT_JAVACOPTS"
)

default_java_toolchain(
 name = "repository_default_toolchain",
 configuration = DEFAULT_TOOLCHAIN_CONFIGURATION,
 java_runtime = "@bazel_tools//tools/jdk:remotejdk_17",
 jvm_opts = BASE_JDK9_JVM_OPTS + ["--enable-preview"],
 javacopts = DEFAULT_JAVACOPTS + ["--enable-preview"],
 source_version = "17",
 target_version = "17",
)

load("@rules_jvm_external//:defs.bzl", "artifact")
load("@contrib_rules_jvm//java:defs.bzl", "JUNIT5_DEPS", "java_test_suite")

java_library(
   name = "customers-lib",
   srcs = glob(["src/main/java/**/*.java"]),
   deps = [
       artifact("org.postgresql:postgresql"),
       artifact("ch.qos.logback:logback-classic"),
   ],
)

java_library(
   name = "customers-test-resources",
   resources = glob(["src/test/resources/**/*"]),
)

java_test_suite(
   name = "customers-lib-tests",
   srcs = glob(["src/test/java/**/*.java"]),
   runner = "junit5",
   test_suffixes = [
       "Test.java",
       "Tests.java",
   ],
   runtime_deps = JUNIT5_DEPS,
   deps = [
       ":customers-lib",
       ":customers-test-resources",
       artifact("org.junit.jupiter:junit-jupiter-api"),
       artifact("org.junit.jupiter:junit-jupiter-params"),
       artifact("org.testcontainers:postgresql"),
   ],
)

この BUILD 構成を理解しましょう。

  • Javaバージョンをロード default_java_toolchain して構成しました 17.
  • 本番jarファイルをビルドする名前customers-libのターゲットを構成しjava_libraryました。
  • すべてのテストを実行するテストスイートを定義する名前customers-lib-testsを持つターゲットを定義しjava_test_suiteました。また、他のターゲット customers-lib と外部の依存関係への依存関係も構成しました。
  • また、Java以外のソース(ログ設定ファイルなど)を依存関係としてテストスイートターゲットに追加するために、別の customers-test-resources ターゲットを名前で定義しました。

customersパッケージには、顧客の詳細を PostgreSQL データベースに格納および取得するクラスがありますCustomerService。そして、 を使用してメソッドTestcontainersをテストしCustomerServiceますCustomerServiceTest。完全なコードについては、 GitHub リポジトリ を参照してください。

手記: Bazel ビルド ファイル ジェネレータである Gazelle を使用して、手動でファイルを書き込む代わりにファイルを生成 BUILD.bazel できます。

Testcontainers テストの実行

Testcontainers テストを実行するには、Testcontainers がサポートするコンテナー ランタイムが必要です。 Docker Desktop を使用してローカルの Docker がインストールされているとします。

これで、Bazel ビルド構成で、パッケージを customers ビルドしてテストする準備が整いました。

# to run all build targets of customers package
$ bazel build //customers/...

# to run a specific build target of customers package
$ bazel build //customers:customers-lib

# to run all test targets of customers package
$ bazel test //customers/...

# to run a specific test target of customers package
$ bazel test //customers:customers-lib-tests

ビルドを初めて実行するときは、必要な依存関係をダウンロードしてからターゲットを実行するのに時間がかかります。 ただし、コードや構成を変更せずにビルドまたはテストを再試行すると、Bazel はビルドまたはテストを再実行せず、キャッシュされた結果を表示します。 Bazel には、コードの変更を検出し、実行に必要なターゲットのみを実行する強力なキャッシュ メカニズムがあります。

Testcontainers を使用する際には、Docker イメージ名とタグ (Postgres:16など) を使用して、必要な依存関係をコードの一部として定義します。 そのため、コード(Docker イメージ名やタグなど)を変更しない限り、Bazel はテスト結果をキャッシュします。

同様に、Go パッケージ用に Bazel ビルドを構成するために Gazelle を使用できます rules_go 。 Go パッケージでの Bazel の構成の詳細については、 MODULE.bazelproducts/BUILD.bazel ファイルを参照してください。

前述したように、Testcontainers テストを実行するには、Testcontainers でサポートされているコンテナー ランタイムが必要です。 複雑な CI プラットフォームに Docker をインストールするのは困難な場合があり、複雑な Docker-in-Docker セットアップを使用する必要がある場合があります。 さらに、一部のDockerイメージはオペレーティングシステムアーキテクチャと互換性がない場合があります(例:Apple M1)。 

Testcontainers Cloud は、localhostまたはCIランナーにDockerを配置し、クラウドVM上でコンテナを透過的に実行する必要性を排除することで、これらの問題を解決します。

GitHub Actions を使用して Testcontainers Cloud で Bazel を使用して Testcontainers テストを実行する例を次に示します。

name: CI

on:
 push:
   branches:
     - '**'

jobs:
 build:
   runs-on: ubuntu-latest
   steps:
   - uses: actions/checkout@v4

   - name: Configure TestContainers cloud
     uses: atomicjar/testcontainers-cloud-setup-action@main
     with:
       wait: true
       token: ${{ secrets.TC_CLOUD_TOKEN }}

   - name: Cache Bazel
     uses: actions/cache@v3
     with:
       path: |
         ~/.cache/bazel
       key: ${{ runner.os }}-bazel-${{ hashFiles('.bazelversion', '.bazelrc', 'WORKSPACE', 'WORKSPACE.bazel', 'MODULE.bazel') }}
       restore-keys: |
         ${{ runner.os }}-bazel-

   - name: Build and Test
     run: bazel test --test_output=all //...

GitHub Actions ランナーにはすでに Bazelisk がインストールされているため、すぐに Bazel を使用できます。 Secretsを使用して環境変数を設定し TC_CLOUD_TOKEN 、Testcontainers Cloudエージェントを開始しました。 ビルドログを確認すると、Testcontainers Cloudを使用してテストが実行されていることがわかります。

概要

Bazel ビルドシステムを使用して、異なるプログラミング言語を使用する複数のモジュールでモノレポをビルドおよびテストする方法を示しました。 Testcontainers と組み合わせることで、ビルドを自己完結型で密閉型にすることができます。

Bazel と Testcontainers は自己完結型のビルドに役立ちますが、密閉型ビルドにするには追加の対策を講じる必要があります。 

  • Bazel は、JDK 17や Go 1など、特定のバージョンの SDK を使用するように設定できます。20など、ビルドがホストマシンにインストールされているバージョンではなく、常に同じバージョンを使用するようにします。 
  • Testcontainers テストでは、コンテナーの依存関係に Docker タグ latest を使用すると、非決定論的な動作が発生する可能性があります。 また、一部の Docker イメージ発行元は、同じタグを使用して既存のイメージをオーバーライドします。 ビルド/テストを決定論的にするには、常に Docker イメージ ダイジェストを使用して、ビルドとテストで常にまったく同じバージョンのイメージを使用し、再現可能で密閉されたビルドを提供するようにします。
  • Testcontainers Cloud を使用して Testcontainers テストを実行すると、Docker セットアップの複雑さが軽減され、決定論的なコンテナー ランタイム環境が提供されます。

詳細については、 Testcontainers の Web サイト にアクセスし、 無料のアカウントを作成して Testcontainers Cloud の使用を開始してください。

さらに詳しく