📝

PDFをVRTする仕組みを導入しました

に公開

はじめに

弊社では注文に対して領収書・納品書・請求書などのPDFを生成しています。
これらの書類はお客様に直接届くものであり、レイアウト崩れや金額の表示ミスは信頼に関わる重大な問題です。

しかし、PDF生成ロジックの変更時にすべてのパターンを目視で確認するのは現実的ではありません。そこで、PDFに対するVRT(Visual Regression Test)の仕組みをGitHub Actions上に構築し、意図しない変更を自動検知できるようにしました。

課題

PDF帳票の生成には、注文内容に応じた多くのバリエーションがあります。
これらすべてを手動で確認するのは現実的ではなく、以下の問題がありました。

  • レビューでPDFの差分が見えないため、変更の影響範囲がわからない
  • 目視確認では微妙なレイアウトのズレなどを見逃す可能性が高い
  • 人力で確認するテストはコストが高い

技術選定

生成されたPDFはS3上に保存されています。
ベースラインとして設定したファイルとテスト実行時に生成されたファイルを比較し差分がないか確認を行います。
アプローチとして2パターンを検討しました。

  1. PDFをそのまま比較する
    PDFファイルを変換など行わずそのまま差分を比較する方法です。
    PDFを比較するツールとしてはdiff-pdfがメジャーなライブラリです。
    コマンドラインで実行でき、比較結果もPDFで出力できるので手軽に利用できるのが利点です。
    しかし、我々のケースでは発行日や発行回数など生成のたびに変更されるものが存在するのでそのままの比較では毎回差分が出てしまいます。
    diff-pdfでは特定の範囲をマスクすることができなかったため今回は採用を見送りました。

  2. PDFを画像に変換し比較する
    今回はこちらを採用しました。
    PDFファイルを一度pngファイルに変換し、画像で比較を行う方法です。
    コマンドラインで実行したいという要望は叶えたかったため、以下のツールを採用しています。

ツール 概要 採用理由
poppler-utils (pdftoppm) PDF→PNG変換 軽量でCLIから簡単に使える
ImageMagick (compare) 画像のピクセル差分比較 特定の範囲をマスクして差分比較ができる

アーキテクチャ

全体の処理フローは以下の通りです。

GitHub Actions (pdf-vrt.yml)

├─ 1. Lambda呼び出し(PDF再生成)
│     └─ VPC内 Internal APIs → S3にPDF保存

├─ 2. VRTスクリプト実行(run.sh)
│     ├─ S3からベースラインのPDF取得
│     ├─ S3から最新PDF取得
│     ├─ pdftoppm で PNG に変換
│     ├─ ImageMagick でマスク適用
│     ├─ compare で差分比較
│     └─ レポート生成

├─ 3. 差分画像をArtifactとしてアップロード

└─ 4. 失敗時にSlack通知

ベースライン管理

ベースラインは baselines.yml で管理しています。各テストケースに対して、S3オブジェクトのキーとバージョンIDを記載しています。

orders:
  - order_id: "0000001"
    description: "お弁当(領収書・納品書)"
    documents:
      receipt:
        - s3_key: "0000001/receipt.pdf"
          version_id: "xxxxxxxxxxxxxxxxxxxxxxxx"
      delivery_note:
        - s3_key: "0000001/delivery_note.pdf"
          version_id: "xxxxxxxxxxxxxxxxxxxxxxxx"

ベースラインの更新時は、ワークフローのupdateモードを実行すると、S3の最新バージョンIDを取得して baselines.yml を更新するPRが自動作成されます。

実装

PDF を画像に変換する

pdftoppmを使って、PDFの各ページをPNG画像に変換します。

pdftoppm -png -r 150 expected.pdf expected
pdftoppm -png -r 150 actual.pdf actual
# → expected-1.png, expected-2.png, ... が生成される

動的な箇所をマスクする

発行日や発行回数など、実行のたびに変わる箇所は baselines.yml にマスク座標を定義し、塗りつぶすことで無視します。

settings:
  mask_coords:
    receipt:
      - {x1: 1000, y1: 100, x2: 1200, y2: 120}
    bill:
      - {x1: 900, y1: 50, x2: 1200, y2: 140}

ImageMagickのconvertコマンドでマスクを適用します。

convert "expected-1.png" \
  -fill white -draw "rectangle 1000,100 1200,120" \
  "expected-1-masked.png"

画像の差分を比較する

ImageMagickのcompareコマンドで、2つの画像のピクセル単位の差分を検出します。-metric AEを指定することで、異なるピクセル数を数値で返します。

diff=$(compare -metric AE expected-1-masked.png actual-1-masked.png diff-1.png 2>&1)
# diff=0 なら差分なし、1以上なら差分あり

差分がある場合は diff画像(赤色で差分箇所を強調した画像)を出力し、Artifactとしてアップロードします。

並列実行で高速化

テストケースが多数あるため、GNU parallelで4並列で実行しています。

cat "$TEST_CASES_FILE" | parallel --jobs 4 --colsep '\t' \
  compare_pdf {1} {2} {3} {4} {5} "$BUCKET" "$OUTPUT_DIR" "$RESULTS_DIR" "$BASELINE_FILE"

各テストケースの結果はJSONファイルとして個別に出力され、最後に集約してレポートとサマリーを生成します。

Lambda関数(PDF再生成)

PDFを生成する内部APIはVPC内にあるため、直接GitHub Actionsから呼び出すことができません。そこで、VPC接続のAWS Lambda関数(Go)を中継として使っています。

GitHub Actions → Lambda (VPC内) → 内部API → S3にPDF保存

Lambda関数は以下の流れで動作します。

  1. GitHub Actionsから baselines.yml の注文リストをもとにLambdaを呼び出す
  2. Lambdaが注文ID・書類タイプごとにVPC内の各APIにPOSTリクエストを送信
  3. APIがPDFを再生成してS3に保存

Lambda関数のインターフェースは以下の通りです。

// 入力
{
  "orders": [
    {
      "orderId": "0000001",
      "documentTypes": ["receipt", "delivery_note"]
    }
  ]
}

// 出力
{
  "success": true,
  "results": [
    {"orderId": "0000001", "documentType": "receipt", "success": true}
  ],
  "summary": {"total": 1, "succeeded": 1, "failed": 0}
}

CI に組み込む

GitHub Actionsのワークフローとして、以下の2つのトリガーで実行できます。

  1. 手動実行(workflow_dispatch): Actions画面からモード(test/update)と対象注文を選択して実行
  2. 自動トリガー(repository_dispatch): PDF生成APIのリポジトリからデプロイ時に自動でtrigger-vrtイベントを発火

repository_dispatchはPDF生成を行うAPIが書類の種類ごとに別のプロダクトになっているため、それぞれの変更を検知して相互に影響がないか確認することが目的です。

on:
  workflow_dispatch:
    inputs:
      mode:
        description: 'test: 差分検出 / update: ベースライン更新'
        required: true
        type: choice
        options:
          - test
          - update
  repository_dispatch:
    types: [trigger-vrt]

失敗時はSlack通知が飛び、差分画像をArtifactからダウンロードして確認できます。

課題

今回構築したVRTは社内のステージング環境で動作しています。
できるだけ本番に近い形でテストしたかったのでこのような形になっていますが、注文内容を誰かが変更したり、データベースのデータ書き換えが発生するとテストが失敗してしまう問題が発生します。
現時点では対象データにテスト用であることを明記することで意図しない変更が発生しないような運用をとっていますが将来的にはより安全にテストができる環境を整えていく必要があると考えています。

まとめ

VRT導入前はPDFに関する修正があった時に検証シートを作成し目視で想定外の影響が出ていないか確認を行なっていたため、細かなレイアウトの崩れやデータの不具合に気付けないことが度々発生していました。
また、PDF生成処理には影響がないと思って修正した内容が原因で出力されるPDFに不備が発生するといったこともありました。
今回VRTを導入したことで、すべての修正に対して差分を比較することが可能になったためリリース前のテスト工数の削減やリリース後に発覚する不具合を未然に防ぐことができるようになりました。
テスト項目はまだまだ足りていないケースもあるため今後も継続的にアップデートしていく予定です。

PDFのテストを考えられている方の参考になれば幸いです。

参考

GitHubで編集を提案
スタフェステックブログ

Discussion