Misoca の PDFテスト

こんにちは、弥生の日高 @hidakatsuya です。普段は クラウド見積・納品・請求書サービス「Misoca」 の開発に携わっています。

Misoca には、作成した請求書などの帳票を PDF としてダウンロードするだけでなく、PDF の内容を印刷したり、郵送したり、FAX として送信するなど、PDF が関連する機能が多くあります。そして、毎日非常に多くの PDF が Misoca 上で生成されています。

今回は、そんな Misoca の PDF をどのようにテストしているのかについてまとめたいと思います。

Misoca の PDF

本題に入る前に、Misoca で扱っている PDF についてもう少しだけ説明しておきます。

  • 請求書・納品書・見積書・注文書・注文請書・検収書・領収書の7種類の PDF がある
  • 請求書・納品書・見積書は複数のテンプレートを提供している
  • 執筆時点で、請求書には14種類、見積書・納品書にはそれぞれ2種類のテンプレートがある

また、2020年以降での PDF に関するリリース(ある程度の規模の機能追加や変更)回数を集計したところ、24回のリリースを実施していました。月に平均2回程度、PDF に関するリリースをしていたということになります。

Misoca の PDFテスト

これだけの種類の PDF をこの頻度で安全にリリースするためには、自動テストの仕組みは不可欠です。Misoca でも、CI として PDF 生成の結果を自動テストする環境を整えています。

  • テストケースごとに生成した「実際の PDF」と「期待するPDF」と比較し、一致しない場合は失敗として差分結果も出力する
  • 帳票の種別ごと、テンプレートごとに 8~25 のテストケースがあり、合計で 482 ケースある
  • それなりに重いので、リリーステスト*1pdf_spec_*ブランチの push 時にテストを実行する

これら一連のテストを Misoca チームでは「PDF spec」と呼称しています。

PDF spec

PDF spec は、他のテスト同様に RSpec で動作します。テストコードは spec/pdf/ に配置し、あくまで Misoca の spec の一部に過ぎません。PDFの比較には、diff-pdf を使い、PDF ファイルを直接比較することでテストします。

構成と実装

請求書のスタンダードテンプレートの spec を例に詳細を説明します。

Rails.root/
 ├── app/
 :
 ├── spec/
      ├── pdf/
      :    ├── invoice/STANDARD/
           :    ├── expected
                │    ├── general_usage.pdf
                │    ├── max_input.pdf
                │    :
                └── pdf_spec.rb

pdf_spec.rb がテスト本体で、expected/ 配下の general_usage.pdf などがテストケースの「期待するPDF」です。

pdf_spec.rb は、実際とは少し異なりますが、概ね次のような実装になっています。

RSpec.describe Invoice, type: :pdf do
  include_context 'pdf spec'

  it_behaves_like 'pdf spec: general usage'
  it_behaves_like 'pdf spec: max input'
  ...
end

「期待するPDF」との比較テストは include_context 'pdf spec' の中で行なわれます。

it { expect(subject).to match_pdf(expected_pdf_path, output_diff: diff_pdf_path) }

match_pdf で PDF の比較が行われ、一致しない場合はテストは失敗します。

この時、後述のオプションを指定すると、diff_pdf_path で指定したパスに差分のPDFが出力されます。差分は、次のように変更箇所がハイライトされた普通の PDF ファイルとして出力されます。これは diff-pdf の機能です。

f:id:hidakatsuya:20220222115124p:plain
領収書PDFのタイトルのフォントサイズを大きくした場合の差分PDF

なお、この match_pdf Matcher は、筆者作の pdf_matcher-testing gem で定義されています。*2

PDF spec の機能と bin/pdf_spec コマンド

前述の通り、PDF spec は RSpec の example の一部に過ぎないため、bin/rspec spec/pdf/invoice/STANDARD で実行することができますが、手元での開発時は専用のコマンド bin/pdf_spec を使って実行します。

以下は bin/pdf_spec の実際のコマンドヘルプです。

$ bin/pdf_spec
Usage: pdf_spec [OPTIONS] specpath

OPTIONS:
  --verbose          テスト結果の差分PDFと結果PDFを tmp/pdf_spec/ に出力する
  --update_expected  期待PDF(expected.pdf)を結果PDFで更新する。差分PDFも出力する

  詳細は spec/support/shared_context/pdf/pdf_spec_shared_context.rb のコメントを参照
  その他、rspecコマンドに渡す任意のオプションを指定できます

specpath:
  spec/pdf/ 配下の spec ファイルを指定します

Examples:
  $ bin/pdf_spec --verbose spec/pdf/invoice/STANDARD
  $ bin/pdf_spec --update_expected -fd -e "min input" spec/pdf/invoice/STANDARD ...

上記の通り、このコマンドは、単に PDF spec を簡単に実行するだけではなく、PDF の変更を行う際の PDF spec に伴う開発フローを助ける役割も担っています。

PDF に差分が生じていないかを確認する

まず、単に spec を実行すると、PDF を比較し差分が生じていないか(壊していないか)を確認できます。

$ bin/pdf_spec spec/pdf/invoice/

PDF の結果と差分が意図したものか確認する

そして、--verbose オプションを指定することで、テストケースの実際の PDF と期待する PDF との差分(あれば)を tmp/pdf_spec/ に出力して、PDF への変更内容が期待通りかを確認することができます。

$ bin/pdf_spec --verbose spec/pdf/invoice/

期待する PDF の内容を更新する

最後に、意図通りの変更ができていることを確認したら、--update_expected オプションを指定し、期待するPDFを変更後の内容で更新することができます。更新された「期待するPDF」を push して一連の実装は完了となります。

$ bin/pdf_spec --update_expected spec/pdf/invoice/

なお、このコマンドの実装は非常にシンプルなものとなっています。以下は bin/pdf_spec の一部です。

for arg in "$@"; do
  case $arg in
    "--verbose"|"--update_expected")
    mode=${arg#--}
    ;;

    *)
    args+=("$arg")
    ;;
  esac
done

if [[ ${#args[@]} -eq 0 ]]; then
  exit_with_usage
fi

PDF_SPEC=$mode bin/rspec "${args[@]}"

実は、 verboseupdate_expected などの機能は、spec 側で PDF_SPEC 環境変数の値を読み取って実現しています。そのため、bin/pdf_spec は、--verbose などのオプションを解釈して、PDF_SPEC 環境変数に適切な値をセットし、bin/rspec を実行しているだけです。

これまでの改善・工夫

最後にこれまで行なってきた改善や工夫をいくつか紹介します。

PDF spec のテストケースが多過ぎてテストが遅く、テストケースのメンテも大変

当初、請求書だけでテストケースは 2000 を超えていました。網羅性が高く安心感はありますが、通常のビルドで実行するには、あまりにも実行時間もリソースも占有しすぎていました。

これは、シンプルに「テストケースを減らす」ことで改善した形ですが、まず「PDF spec の責務」を定義することから始めました。

  • 関心のあるもの
    • PDFの表示が意図したものか
    • 表示がくずれていないか、表示されるべきところに正しく表示されているか
  • 関心のないもの
    • PDFを出力する過程や出力した後での状態の変化
    • 例えば、金額の計算結果の確認は PDF spec の責務ではない

そして、この責務に則り、PDF spec (PDF そのものの比較) でカバーすべきものと、PDF 生成の実装 (PDF生成の過程のロジックなど) の spec でカバーすべきものへ整理することで、PDF spec のテストケースを全種類で 480程度に圧縮しました。

目視では判別できない差分でテストが失敗する

稀に、差分の PDF をみても差分を確認できないケースが発生したことがありました。この手のビジュアルリグレッションテストではよくあるやつですね。

これは、diff-pdf の v0.4v0.5 で追加された --channel-tolerance--dpi オプションを使って、検知する差分の閾値を調整することで解決できます。

おわりに

今回は、Misoca の PDF を支える PDF spec について書きました。

PDF に問題があった場合の影響は大きく多岐に渡ります。PDF spec の整備によって、常に一定の PDF の品質が担保されことにより、PDF の変更に対しての心理的な安心感も確保されたことは非常に大きいと思います。

一方で、現在の仕組みは、CI だけでなく、手元でも手軽に実行でき非常に便利な反面、大量のPDFファイル(期待するPDF)をリポジトリに保存している*3 点や、通常の spec に比べるとどうしても重く遅い処理のため、CI 全体の時間・リソースを少なからず圧迫してしまっている点など、課題点も多く残るのが現状です。

今後も、reg-suit の導入など、より良い環境を模索していきたいと思っています。

お知らせ

弥生では一緒に働く仲間を募集しています!

herp.careers

*1:Misoca では基本的に毎日午前午後の2回リリースします。

*2:その他関連するものとして、PDFの比較を行う pdf_matcher gem や GitHub Actions で diff-pdf をインストールする setup-diff-pdf action などもあります。

*3:Git LFS として扱っています。