JavaでPDF出力を比較してスナップショットテストする

帳票などのPDFを出力するアプリケーションを開発するときに、最終的に出力されたPDFをレイアウトまで含めて自動テストしたいと思ったことはありませんか？この文書では、2つのPDFファイルを画像化して比較することで、レイアウトまで含めて回帰テストする方法を紹介します。

### 基本的なアイデア

* 期待する出力のPDFファイルがすでに手元にあるとし、プログラムが出力したPDFがこのスナップショットと一致するかをテストする
    * デグレを検知したいだけなので、完全に未知の2つのPDFをうまく比較できる必要はなく、ページ数やページサイズが同じ場合だけうまく比較できればよい
* PDFを1ページずつ画像化してピクセル単位で比較し、一致しない場合は差分箇所が分かる画像を出力する
    * 以前紹介した[「PDFの画像比較をリファクタリングに活かす」](https://qiita.com/oohira/items/184dbbe7c631e8c335a1) とアイデアは同じ

### 確認環境

* PDFBox 2.0.15
* JUnit 5.4.2
* AdoptOpenJDK 11.0.3+7
* macOS 10.14.5

### PDFの画像化まで

PDFの画像化は [Apache PDFBox](https://pdfbox.apache.org/) を使用すれば想像以上に簡単です。先に述べた通り、自動回帰テストが目的なので、ページ数やページサイズが異なる場合はいさぎよくテストを失敗させて諦めます。

画像化する際のDPIを大きくすれば大きくするほど高解像度な画像を使った精密な比較ができるようになりますが、その分マシンリソース（CPU、メモリ）を必要とします。

```java
static void assertPdfEquals(InputStream expected, InputStream actual) throws IOException {
    try (PDDocument doc1 = PDDocument.load(expected);
         PDDocument doc2 = PDDocument.load(actual)) {
        // ページ数が異なる場合はテスト失敗
        assertEquals(doc1.getNumberOfPages(), doc2.getNumberOfPages());

        PDFRenderer renderer1 = new PDFRenderer(doc1);
        PDFRenderer renderer2 = new PDFRenderer(doc2);
        for (int i = 0; i < doc1.getNumberOfPages(); i++) {
            BufferedImage image1 = renderer1.renderImageWithDPI(i, 144, ImageType.RGB);
            BufferedImage image2 = renderer2.renderImageWithDPI(i, 144, ImageType.RGB);

            // サイズが異なる場合もテスト失敗
            assertEquals(image1.getWidth(), image2.getWidth());
            assertEquals(image1.getHeight(), image2.getHeight());

            // 画像一致をテストし、一致しない場合は差分画像を一時ファイルに出力
            Path path = Files.createTempFile("diff-" + i + "-", ".png");
            try (OutputStream os = Files.newOutputStream(path)) {
                assertTrue(compareImage(image1, image2, os), path);
            }
        }
    }
}
```

### 画像の比較まで

画像の比較も、1ピクセルずつRGB値の完全一致を確認するだけであれば、特に難しいことはありません。ポイントとしては、単に比較するだけでなく、差分画像を作るために一致しなかったピクセルを強調色に塗り替えてしまう点です。

```java
static boolean compareImage(BufferedImage image1, BufferedImage image2, OutputStream os) throws IOException {
    boolean matched = true;
    for (int x = 0; x < image1.getWidth(); x++) {
        for (int y = 0; y < image1.getHeight(); y++) {
            int p1 = image1.getRGB(x, y);
            int p2 = image2.getRGB(x, y);
            // 一致したピクセルはそのまま残し、一致しなかったピクセルはマゼンタに変えてしまう
            if (p1 != p2) {
                matched = false;
                image1.setRGB(x, y, Color.MAGENTA.getRGB());
            }
        }
    }
    // 差分画像を出力する
    if (os != null) {
        ImageIO.write(image1, "png", os);
    }
    return matched;
}
```

### 差分画像の出力例

期待するPDFに対して、実際のPDFでは「見出しの日付が追加されている」「明細の項目3が削除されている」「項目3が削除されたことで小計や合計が変わっている」ことが差分画像と見比べることで読み取れると思います。

* 期待するPDF
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/5f7f8d29-bf70-f201-8d30-0f6e43ce9f2e.png)

* 実際のPDF
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/30b0c04c-6de0-4035-a691-9f685e11e5d1.png)

* 差分画像
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/d2165cce-7394-d492-1a3c-a04f2fe1f9a7.png)

### 補足

* 差分画像が必要ない場合には、一致しないピクセルを見つけた瞬間に早期returnした方が速くなります。
* 多少の不一致を許したい場合には、単純なtrue/falseではなく、ピクセルの一致割合を返すようなAPIにすることもできるでしょう。
* 不一致ピクセルを塗り替える色を工夫することで、差分画像を変えることができます。例えば、一致部分は真っ黒にし、不一致部分だけを白色で目立たせることもできます。

```java
    // 一致したピクセルは黒色、一致しなかったピクセルは白色
    if (p1 == p2) {
        image1.setRGB(x, y, Color.BLACK.getRGB());
    } else {
        matched = false;
        image1.setRGB(x, y, Color.WHITE.getRGB());
    }
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/0ca63388-3352-68c3-e764-581684735c2a.png)

### まとめ

PDF出力を画像化することでスナップショットテストする方法を紹介しました。PDFのように「単にデータが出力されているだけでなく、正しいレイアウトで表示されている」ことまで確認しようと思うと、人間が目視で回帰テストするには限界があります。

今回紹介した手法を使えば、自分たちが実装するアプリケーションのデグレを検出するだけでなく、PDF出力ライブラリをバージョンアップしたときの意図しないレイアウト変更にも気付けるようになり、より安心して開発できること間違いなしです。

### 参考文献

* [Introducing PDFUtil – Compare two PDF files textually or Visually](http://www.testautomationguru.com/introducing-pdfutil-to-compare-pdf-files-extract-resources/)