Read OSS

適合性テストとCI:10以上の言語実装を同期させる仕組み

中級

前提知識

  • 第1回:アーキテクチャとナビゲーションガイド(リポジトリ全体の構造)

適合性テストとCI:10以上の言語実装を同期させる仕組み

クロスランゲージの互換性を謳うシリアライゼーション形式の信頼性は、テストスイートの質に直結します。Protobufは10を超える言語実装をサポートしており、それぞれのランタイムアーキテクチャは大きく異なります。リフレクションとアリーナを持つC++(第3〜4回)から、upbを基盤とする動的言語(第5回)、Rustのデュアルカーネル方式(第6回)まで、すべての実装がワイヤー上のビット1つひとつを同じ意味に解釈することをどうやって保証するのでしょうか。

その答えが「適合性テストフレームワーク」です。これは、バイナリ・JSON・テキストフォーマットというすべてのワイヤー表現において、各言語実装を正規仕様と照合するために専用設計されたシステムです。本記事では、テストアーキテクチャ、サブプロセス間の通信プロトコル、既知の差異を追跡する失敗リストの仕組み、そしてこれらを実行するCIインフラを詳しく見ていきます。

適合性テストのアーキテクチャ

適合性フレームワークは conformance/ ディレクトリに置かれており、2つのコア抽象を中心に構成されています。ConformanceTestSuite がテストケースを定義し、ConformanceTestRunner が特定の言語実装に対してそれを実行します。

テストスイートは拡張性を重視した設計になっています。ヘッダーファイルには、次のような使用パターンが示されています。

class MyConformanceTestSuite : public ConformanceTestSuite {
 public:
    void RunSuiteImpl() {
        // INSERT ACTUAL TESTS.
    }
};

テストは以下の3つのワイヤーフォーマットカテゴリをカバーします。

カテゴリ 説明
BINARY_TEST protobufバイナリワイヤーフォーマットを通じたラウンドトリップ
JSON_TEST JSONワイヤーフォーマットを通じたラウンドトリップ
TEXT_FORMAT_TEST テキストフォーマットを通じたラウンドトリップ

各テストケースは、あるフォーマットの入力ペイロード、別フォーマット(または同一フォーマット)での期待される出力、そして操作が成功すべきか失敗すべきかを指定します。JSONはバイナリと意味論が異なるため(フィールド名とフィールド番号の違い、デフォルト値の扱いの違いなど)、このクロスフォーマットテストは特に重要です。

classDiagram
    class ConformanceTestSuite {
        +RunSuiteImpl()*
        +RunTest(request, response)
        -failure_list_: FailureListTrieNode
    }
    
    class ConformanceTestRunner {
        <<interface>>
        +RunTest(request, response)*
    }
    
    class ForkPipeRunner {
        "Subprocess communication"
        +RunTest(request, response)
    }
    
    class InProcessRunner {
        "Direct function call"
        +RunTest(request, response)
    }
    
    ConformanceTestSuite --> ConformanceTestRunner
    ConformanceTestRunner <|-- ForkPipeRunner
    ConformanceTestRunner <|-- InProcessRunner

スイートは FailureListTrieNode を使って既知の失敗を効率的にマッチングし、Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse のようなワイルドカードパターンもサポートしています。

サブプロセスによるテストプロトコル

conformance.proto で定義された適合性プロトコルは、シンプルなリクエスト/レスポンス方式を採用しています。各言語は「テスト対象プログラム(testee)」を実装し、ConformanceRequest メッセージを受け取って ConformanceResponse メッセージを返します。

リクエストには以下の情報が含まれます。

  • 複数フォーマット(protobufバイナリ、JSON、JSPB、テキストフォーマット)のいずれかによるペイロード
  • 要求される出力フォーマット
  • パースに使用するメッセージ型
  • テストカテゴリ
sequenceDiagram
    participant Runner as Test Runner (C++)
    participant Testee as Language Testee<br/>(e.g., Python)
    
    Runner->>Testee: Fork + pipe
    
    loop For each test case
        Runner->>Testee: [4-byte length] + ConformanceRequest
        Note over Testee: Parse input payload<br/>Re-serialize to output format
        Testee->>Runner: [4-byte length] + ConformanceResponse
        Note over Runner: Compare response<br/>against expected result
    end
    
    Runner->>Testee: Close stdin (EOF)

通信には、4バイトのリトルエンディアン長プレフィックスの後にシリアライズされたprotobufメッセージを続けるシンプルな長さ区切りプロトコルを使用しています。これは意図的にシンプルな設計です。stdinから読み込んでstdoutに書き出せる言語であれば、どんな言語でも適合性テストのtesteeを実装できます。

ConformanceResponse が示す結果は次のいずれかです。

  • Success:リクエストされたフォーマットでシリアライズされた出力を含む
  • Parse error:入力が無効だった(ネガティブテストケースで期待される結果)
  • Serialize error:パースは成功したがシリアライズに失敗した
  • Runtime error:予期しないエラーが発生した
  • Skipped:testeeがそのテストカテゴリに対応していない

ランナーが最初に送るリクエストは特別で、message_type = "conformance.FailureSet" が設定されており、testeeはその言語で既知の失敗テスト一覧を返します。これにより、ランナーは「想定内の失敗」と「予期しないリグレッション」を区別できます。

失敗リストと既知の非適合

各言語は、失敗することがわかっているテストを記録した失敗リストファイルを管理しています。C++用の conformance/failure_list_cpp.txt を見ると、そのパターンがよくわかります。

# This is the list of conformance tests that are known to fail for the C++
# implementation right now.  These should be fixed.

Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse    # Should have failed to parse
Recommended.*.JsonInput.FieldNameDuplicate             # Should have failed to parse
Recommended.*.JsonInput.StringFieldSingleQuoteBoth     # Should have failed to parse

各行はテスト名のパターン(* ワイルドカードをサポート)と、失敗理由を説明するコメントで構成されています。たとえば Recommended.*.JsonInput.BoolFieldDoubleQuotedFalse は「任意のメッセージ型でダブルクォート囲みの boolean false 値を JSON 入力でテストする、Recommended テスト」を意味します。

flowchart TD
    A["Conformance Test Runs"] --> B{Test passes?}
    B -->|Yes| C{Was it in<br/>failure list?}
    B -->|No| D{Was it in<br/>failure list?}
    C -->|Yes| E["⚠️ Unexpected pass!<br/>Remove from failure list"]
    C -->|No| F["✅ Pass"]
    D -->|Yes| G["✅ Expected failure"]
    D -->|No| H["❌ Regression!<br/>CI fails"]

このワークフローは次のように機能します。

  1. 適合性スイートに新しいテストが追加される
  2. ある言語実装がそのテストに失敗した場合、テスト名をその言語の失敗リストに追加する
  3. 失敗が想定内として扱われるため、CIはパスする
  4. 言語実装が修正されたら、テスト名を失敗リストから削除する
  5. 失敗リストに載っていないテストが失敗した場合、CIはリグレッションとして検知して失敗する

このシステムにより、protobufチームはすべての実装が対応していない段階でも、厳格なJSONパースのような「将来に向けたテスト」を追加してCIをブロックせずに済みます。また、各言語の適合状況を一目で把握できる明確なリストにもなっています。

ヒント: 新しい言語でprotobufライブラリを実装する場合は、まず適合性のtesteeを実装することから始めましょう。テストスイートを実行するだけで、ワイヤーフォーマットのどの動作が誤っているかをすぐに把握できます。失敗リストの仕組みを活用すれば、完全な適合性に向けて段階的に進めることができます。

CI/CDインフラ

CIインフラは言語ごとのGitHub Actionsワークフローとして構成されており、test_runner.yml によってオーケストレーションされています。

テストランナーは以下のタイミングで起動します。

  • mainへのプッシュ:マージ後の検証
  • プルリクエスト:マージ前の検証
  • 1時間ごとのスケジュール:断続的な失敗や環境変化の検出
  • 手動トリガー:デバッグ目的

各言語には専用のワークフローファイルがあります。

ワークフロー ファイル
C++ test_cpp.yml
Java test_java.yml
Python test_python.yml
Ruby test_ruby.yml
PHP test_php.yml
C# test_csharp.yml
Objective-C test_objectivec.yml
Rust test_rust.yml
HPB test_hpb.yml
upb test_upb.yml
Bazel test_bazel.yml
flowchart TD
    TRIGGER["Push / PR / Schedule"] --> RUNNER["test_runner.yml"]
    RUNNER --> SAFE{"Safe source?<br/>(internal branch)"}
    SAFE -->|Yes| JOBS["Spawn per-language jobs"]
    SAFE -->|No| LABEL{"'safe for tests'<br/>label?"}
    LABEL -->|Yes| JOBS
    LABEL -->|No| SKIP["Skip tests"]
    
    JOBS --> CPP["test_cpp.yml"]
    JOBS --> JAVA["test_java.yml"]
    JOBS --> PY["test_python.yml"]
    JOBS --> RUST["test_rust.yml"]
    JOBS --> MORE["...other languages"]
    
    CPP --> BAZEL["Bazel test //..."]
    JAVA --> BAZEL
    PY --> BAZEL

テストランナーは、フォークされたプルリクエストに対して保護戦略を実装しています。リポジトリ内からのPRは即座にテストを実行しますが、フォークからのPRはコンピューティングリソースの不正利用(PWN request)を防ぐため、"safe for tests" ラベルが必要です。このラベルは付与された後すぐに削除されるため、コミットのたびに再承認が必要になります。

並行制御によって重複した実行を防いでいます。

concurrency:
  group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

これにより、PRブランチに新しいプッシュがあると、直前のコミットに対して実行中のテストがキャンセルされます。

全体像

適合性テストとCIのシステムこそが、protobufが掲げるマルチ言語対応の約束を現実のものにしています。これがなければ、微妙な差異が積み重なっていくでしょう。ある言語のJSONパーサーが若干非標準の入力を受け入れてしまい、別の言語のバイナリエンコーダーがエッジケースを異なる方法で処理する——そうした差異が蓄積されると、ある言語でシリアライズしたメッセージが別の言語でサイレントに失敗したり、データが破損したりする事態につながります。

以下の要素の組み合わせが、それを防いでいます。

  • テストの意味論を正確に定義する形式仕様(conformance.proto
  • 既知のギャップを文書化した言語ごとの失敗リスト
  • リグレッションを即座に検出する自動CI
  • クロスフォーマットテスト(バイナリ、JSON、テキスト)

これらの仕組みにより、PythonでシリアライズしたprotobufメッセージをRust(やJava、C++、PHP)でデシリアライズしても同じ結果が得られます。コンパイラパイプライン(第2回)からコードジェネレーター(第6回)まで続いてきたこのシリーズ全体を通じて、適合性テストスイートは正確性の最終的な裁定者です。

これがprotobufのモノレポです。コンパイラ、2つのランタイム、10以上の言語実装、そして包括的なテストフレームワークを単一リポジトリの中で協調させる卓越したエンジニアリングの産物です。そのアーキテクチャを理解することは、protobufの内部実装の知識を得るだけでなく、大規模なマルチ言語インフラプロジェクトを構築・維持するためのケーススタディを手に入れることでもあります。