Bỏ qua để đến nội dung
getnextpdf.com

Kiểm thử các PDF được tạo trong CI

Công thức này dành cho các nhà phát triển ứng dụng tạo PDF bằng NextPDF và muốn giữ đầu ra của riêng họ dưới kiểm thử. Đây là phía người tiêu dùng của kỷ luật kiểm thử riêng của engine: bạn không kiểm thử lại NextPDF, bạn khẳng định rằng tài liệu của mình vẫn nói đúng điều nó nên nói và vẫn trông như nó từng trông.

Hai phong cách khẳng định bao quát gần như mọi thứ:

  • Khẳng định ngữ nghĩa trên văn bản trích xuất — tạo, khôi phục văn bản Unicode, và khẳng định nó chứa các chuỗi bạn mong đợi. Cách này sống sót qua các tinh chỉnh bố cục và thay đổi phông chữ.
  • Khẳng định golden (snapshot) trên các byte — ghim DeterministicSettings để một lần dựng lại giống hệt từng byte, rồi so sánh các byte mới với một tệp tham chiếu đã commit. Cách này bắt mọi thay đổi ngoài ý muốn.

Hãy dùng các khẳng định ngữ nghĩa cho tính đúng đắn của nội dung và các khẳng định golden như một dây bẫy hồi quy. Cả hai chạy không đổi trong CI một khi runner tạo ra cùng các byte mà máy trạm của bạn tạo.

Terminal window
composer require --dev phpunit/phpunit
composer require nextpdf/core:^3

Khẳng định trên văn bản trích xuất, không phải trên một diff byte

Phần tiêu đề “Khẳng định trên văn bản trích xuất, không phải trên một diff byte”

Một diff byte thô của hai PDF là mong manh: một dấu thời gian mới, một phông chữ được tạo tập con lại, hay một đối tượng được sắp xếp lại đều thay đổi các byte mà không thay đổi điều người đọc thấy. Hãy khẳng định trên nội dung thay vì vậy.

NextPDF Core là một bộ tạo, nên hãy làm cho văn bản có thể trích xuất trước. Đây là hai cơ chế riêng biệt, không phải một. Việc trích xuất văn bản dựa vào một /ToUnicode CMap đúng (ISO 32000-2 §9.10.2) ánh xạ các mã glyph trở lại Unicode — engine phát nó cho các phông chữ được nhúng, nên các trình trích xuất khôi phục các ký tự thực thay vì các chỉ số glyph thô. Tagged PDF là riêng biệt: enableTaggedPdf()setLanguage() thêm cây cấu trúc ghi lại thứ tự đọc và khả năng tiếp cận, vốn không phải là thứ tạo ra /ToUnicode CMap. Hãy bật cả hai trước khi bạn ghi nội dung: CMap để khôi phục văn bản sạch sẽ, gắn thẻ cho thứ tự đọc. Xem Tạo nội dung văn bản có thể trích xuất để biết chi tiết phía bộ tạo. Rồi khôi phục văn bản và khẳng định trên nó.

Với các sự kiện về số trang và cấu trúc, độ sâu Quick của module Inspect có một dự phòng pure-PHP chạy in-process khi không có sidecar Spectrum nào khả dụng — tiện trên một runner CI, nhưng đó là một lần quét đã suy giảm. Nó gắn cờ một vấn đề INSPECT-FALLBACK-001 “accuracy may be limited” và suy ra số trang từ một regex /Type /Page thô trên các byte thô, không phải một lần phân tích cây-đối-tượng đầy đủ. Khi một sidecar Spectrum được cấu hình, ngay cả độ sâu Quick cũng dùng nó — InspectDepth kiểm soát mức độ phân tích mà sidecar thực hiện, nên Quick không vốn dĩ là không-sidecar.

<?php
declare(strict_types=1);
use NextPDF\Inspect\Inspector;
use NextPDF\Inspect\InspectConfig;
$result = (new Inspector())->inspect($pdfBytes, InspectConfig::quick());
// With no sidecar injected, Quick depth takes the in-process PHP fallback:
// a degraded scan (page count from a regex) that flags INSPECT-FALLBACK-001.
// If a Spectrum sidecar is available, Inspector uses it even at Quick depth.
$pageCount = $result->pageCount; // int (regex-derived in the fallback)
$version = $result->pdfVersion; // e.g. "2.0"
$encrypted = $result->isEncrypted; // bool

Inspector::inspect() trả về một InspectResult bất biến. Để khôi phục văn bản đầy đủ, chạy một trình trích xuất hạ nguồn (pdftotext, hoặc sidecar Spectrum của Inspect ở độ sâu Standard) trên các byte và khẳng định trên đầu ra của nó — khẳng định trên văn bản được khôi phục, không bao giờ trên các byte chính xác của bộ tạo.

Làm cho đầu ra giống hệt từng byte cho các snapshot golden

Phần tiêu đề “Làm cho đầu ra giống hệt từng byte cho các snapshot golden”

Một kiểm thử golden chỉ hoạt động nếu một lần dựng lại tạo ra cùng các byte. PDF có hai nguồn không-tất-định tích hợp sẵn: các trường ngày (CreationDate / ModDate) và định danh tệp trong trailer (ISO 32000-2 §7.5.5). NextPDF loại bỏ cả hai thông qua DeterministicSettings, một giá trị cấu hình hạng nhất — không phải một mánh kiểm thử.

DeterministicSettings nhận một DateTimeImmutable cố định và một fileIdSeed hex 32 ký tự. Truyền nó trên Config, rồi dựng tài liệu của bạn từ cấu hình đó. Với hồ sơ tất định được ghim (dấu thời gian cố định và /ID), cùng một đầu vào cho ra đầu ra giống hệt từng byte qua các lần chạy trên cùng một chuỗi công cụ đã ghim — bản vá PHP, các phiên bản extension và thư viện nén, và các tệp phông chữ đều được giữ cố định. Qua các máy khác nhau ở bất kỳ điều nào trong số đó, các byte vẫn có thể phân kỳ; hãy ưu tiên các khẳng định trích-xuất-văn-bản ở đó và dành snapshot golden cho một môi trường cố định, đã ghim.

<?php
declare(strict_types=1);
use DateTimeImmutable;
use NextPDF\Core\Config;
use NextPDF\Core\Document;
use NextPDF\Core\DeterministicSettings;
function buildInvoice(int $invoiceId): string
{
$config = new Config(
deterministic: new DeterministicSettings(
timestamp: new DateTimeImmutable('2026-01-01T00:00:00+00:00'),
fileIdSeed: '00000000000000000000000000000000', // exactly 32 hex chars
),
);
$document = Document::createStandalone($config);
$document->setLanguage('en');
$document->enableTaggedPdf('en'); // structure tree for reading order; /ToUnicode is emitted separately
$document->addPage();
$document->setFont('helvetica', '', 12);
$document->multiCell(0, 7, "Invoice #{$invoiceId}");
return $document->getPdfData();
}

fileIdSeed phải là đúng 32 ký tự thập lục phân, nếu không hàm khởi tạo ném InvalidConfigException. Nếu bạn đã giữ một Config, bạn có thể dẫn xuất một bản sao tất định với $config->withDeterministic($settings) thay vì dựng lại nó.

Một kiểm thử PHPUnit cho cả hai phong cách khẳng định

Phần tiêu đề “Một kiểm thử PHPUnit cho cả hai phong cách khẳng định”

Lớp kiểm thử này thực thi một khẳng định ngữ nghĩa và một khẳng định golden trên cùng một bộ dựng. Tệp golden được tạo một lần, được con người xem xét, và được commit; sau đó kiểm thử thất bại trên bất kỳ thay đổi byte nào.

<?php
declare(strict_types=1);
namespace App\Tests\Pdf;
use PHPUnit\Framework\TestCase;
use function App\Pdf\buildInvoice; // the deterministic builder above
final class InvoicePdfTest extends TestCase
{
private const GOLDEN = __DIR__ . '/__snapshots__/invoice-42.pdf';
public function testInvoiceTextIsPresent(): void
{
$pdf = buildInvoice(42);
// Recover text with an external extractor (installed in CI, see below).
$text = self::extractText($pdf);
self::assertStringContainsString('Invoice #42', $text);
}
public function testInvoiceBytesMatchGolden(): void
{
$pdf = buildInvoice(42);
// First run: write the golden, then review and commit it by hand.
if (! \is_file(self::GOLDEN)) {
\file_put_contents(self::GOLDEN, $pdf);
self::markTestIncomplete('Golden file created — review and commit it.');
}
self::assertSame(
\file_get_contents(self::GOLDEN),
$pdf,
'Generated PDF bytes drifted from the committed golden snapshot.',
);
}
private static function extractText(string $pdf): string
{
// tempnam() creates a zero-byte file; track it so the finally block
// removes both it and the .pdf path, leaking neither.
$tmp = \tempnam(\sys_get_temp_dir(), 'pdf');
$tmpPdf = $tmp . '.pdf';
try {
\file_put_contents($tmpPdf, $pdf);
// Run pdftotext via proc_open so we can read the exit code AND
// stderr. shell_exec() returns "" on a missing/failed binary, which
// would silently turn a broken runner into a passing assertion —
// the opposite of a reliable CI test. pdftotext writes UTF-8 to "-"
// (stdout). Requires poppler-utils on the runner (see workflow).
$descriptors = [
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'], // stderr
];
$process = \proc_open(
['pdftotext', $tmpPdf, '-'],
$descriptors,
$pipes,
);
if (! \is_resource($process)) {
throw new \RuntimeException(
'Could not start pdftotext. Install poppler-utils on the runner.',
);
}
$text = \stream_get_contents($pipes[1]);
$stderr = \stream_get_contents($pipes[2]);
\fclose($pipes[1]);
\fclose($pipes[2]);
$exitCode = \proc_close($process);
if ($exitCode !== 0) {
throw new \RuntimeException(\sprintf(
'pdftotext failed (exit %d): %s. Is poppler-utils installed on the runner?',
$exitCode,
\trim((string) $stderr) !== '' ? \trim((string) $stderr) : '(no stderr)',
));
}
return (string) $text;
} finally {
// Remove both the original tempnam() file and the .pdf we wrote.
@\unlink($tmp);
@\unlink($tmpPdf);
}
}
}

Khẳng định byte chỉ có ý nghĩa vì buildInvoice() ghim DeterministicSettings. Không có nó, riêng CreationDate đã làm thất bại kiểm thử golden ở mỗi lần chạy.

Ghim phông chữ để CI tạo ra cùng các byte

Phần tiêu đề “Ghim phông chữ để CI tạo ra cùng các byte”

Đầu ra giống hệt từng byte phụ thuộc vào việc cùng các byte phông chữ được tạo tập con trên mọi máy. Một phông chữ phân giải khác trên runner so với trên máy trạm của bạn thay đổi tập con được nhúng và phá vỡ kiểm thử golden — ngay cả khi DeterministicSettings được ghim.

Hai quy tắc giữ phông chữ ổn định:

  • Dùng các phông chữ chuẩn Base 14 (ví dụ helvetica) cho các kiểm thử golden nơi bạn không cần một kiểu chữ cụ thể. Chúng tránh nhúng các byte phông chữ tùy chỉnh — chúng dựa vào các metric tích hợp ổn định, dù vẻ ngoài kết xuất chính xác vẫn có thể phụ thuộc vào việc thay thế phông chữ của trình xem.
  • Đưa bất kỳ phông chữ tùy chỉnh nào vào kho và trỏ NextPDF tới nó tường minh, thay vì dựa vào một đường dẫn phông chữ hệ thống khác nhau giữa các máy. Đặt Config(fontsDirectory: ...) hoặc gọi addFontDirectory() với thư mục đã commit:
<?php
declare(strict_types=1);
use NextPDF\Core\Config;
use NextPDF\Core\Document;
$config = new Config(fontsDirectory: __DIR__ . '/fonts'); // committed to the repo
$document = Document::createStandalone($config);
$document->addFontDirectory(__DIR__ . '/fonts'); // or add it imperatively
$document->addPage();
$document->setFont('dejavusans', '', 12); // resolved from the repo

Đừng cài phông chữ từ trình quản lý gói OS cho các kiểm thử golden: các gói phông chữ của bản phân phối khác nhau về phiên bản và hinting, nên một lần nâng cấp runner âm thầm thay đổi các byte của bạn. Một thư mục phông chữ được đưa vào kho loại bỏ biến số đó.

Workflow này cài PHP với các extension NextPDF cần, cài một trình trích xuất văn bản cho các khẳng định ngữ nghĩa, và chạy PHPUnit. Dòng php-version: "8.4" ghim phiên bản phụ PHP (8.4), không phải bản vá — setup-php phân giải nó thành 8.4.x mới nhất khả dụng. Để tái lập được ở mức byte, hãy ghim một bản vá cụ thể mà bạn hỗ trợ (ví dụ php-version: "8.4.8") để một lần nâng cấp image runner không thể dịch chuyển bản build PHP dưới các snapshot golden của bạn.

name: PDF tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: curl, gd, intl, mbstring, openssl, zlib
coverage: none
- name: Install text extractor for PDF assertions
run: sudo apt-get update && sudo apt-get install -y poppler-utils
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: Run the test suite
run: vendor/bin/phpunit --testsuite=pdf

poppler-utils cung cấp pdftotext cho các khẳng định văn bản. Danh sách extension khớp với những gì NextPDF Core yêu cầu cứng: curl, gd, intl, mbstring, openssl, và zlib phủ việc nối mạng, xử lý ảnh raster, văn bản và đối chiếu được quốc tế hóa, văn bản đa byte, mật mã cho mã hóa/ký, và nén luồng. Hãy cài tất cả chúng — composer.json của Core yêu cầu từng cái, nên một extension thiếu làm thất bại composer install, không chỉ một tính năng đơn lẻ. Nếu một bước khẳng định sau phân tích đầu ra HTML hoặc XML, hãy thêm dom cho bước đó; nó không phải là một yêu cầu của Core. Vì các phông chữ được đưa vào kho, không cần cài gói phông chữ nào — đó chính là điều giữ các byte của runner bằng với của bạn.

  • Các kiểm thử golden cần DeterministicSettings. Không có một dấu thời gian và fileIdSeed được ghim, CreationDate, ModDate, và định danh tệp trailer thay đổi mỗi lần chạy và khẳng định byte không bao giờ qua.
  • fileIdSeed là đúng 32 ký tự hex. Bất kỳ độ dài nào khác hoặc một ký tự không-hex đều ném InvalidConfigException lúc khởi tạo.
  • Phông chữ là một phần của các byte. Một phiên bản phông chữ khác trên runner tạo tập con lại các glyph và làm thất bại kiểm thử golden. Hãy đưa phông chữ vào kho hoặc dùng Base 14.
  • Core không đi kèm extractText(). Việc khôi phục văn bản cho các khẳng định là công việc của người tiêu dùng: dùng pdftotext hoặc sidecar Spectrum của Inspect. Việc của bộ tạo là phát một /ToUnicode CMap đúng (tự động cho các phông chữ được nhúng) để các trình trích xuất khôi phục Unicode thực; enableTaggedPdf() thêm cây cấu trúc lên trên, nhưng nó không phải là thứ tạo ra CMap.
  • Độ sâu Quick của Inspect có một dự phòng pure-PHP in-process khi không có sidecar (độ chính xác giới hạn — gắn cờ INSPECT-FALLBACK-001); Standard và Full luôn yêu cầu sidecar. Với CI không có sidecar, dự phòng Quick cho số trang, phiên bản, và cờ mã hóa — hãy coi kết quả của nó là gần đúng và dựa vào văn bản trích xuất cho tính đúng đắn của nội dung.
  • Tạo lại các golden một cách có chủ đích. Khi một thay đổi là cố ý, hãy xóa snapshot, chạy lại để ghi một cái mới, và xem xét diff trước khi commit. Đừng bao giờ ghi đè tự động một golden trong CI.

Cả hai phong cách khẳng định đều rẻ. Một so sánh golden là một lần dựng cộng với một lần so chuỗi. Lối ngữ nghĩa thêm một lệnh gọi pdftotext ngoài-tiến-trình cho mỗi tài liệu; hãy giữ chúng cho các tài liệu mà bạn thực sự khẳng định trên văn bản của chúng. Dự phòng PHP Quick của Inspect (không sidecar) là một lần quét một lượt của các byte, nên nó thêm thời gian không đáng kể vào một kiểm thử; khi một sidecar được cấu hình, độ sâu Quick thực hiện một lần khứ hồi sidecar thay vì vậy.

  • Hãy coi văn bản trích xuất là có thể đọc bằng máy: đừng bao giờ khẳng định rằng một bí mật vắng mặt khỏi các byte như một kiểm soát bảo mật. Văn bản được gắn thẻ có thể đọc bởi bất kỳ ai có tệp. Để bảo mật, hãy mã hóa.
  • Dựng đường dẫn tệp tạm cho trình trích xuất bằng tempnam() và dọn dẹp nó; đừng truyền các fixture kiểm thử qua một đường dẫn chia sẻ đoán được.
  • Ghim các phiên bản công cụ và action (một bản vá PHP cụ thể như 8.4.8, không chỉ phiên bản phụ 8.4; poppler-utils qua bản phân phối; các SHA hoặc tag của action) để một lần nhảy chuỗi-cung-ứng không thể âm thầm thay đổi các byte golden hoặc chuỗi công cụ của bạn.

Hướng dẫn này không đưa ra tuyên bố tiêu chuẩn quy phạm nào. Tính tất định mà nó dựa vào là việc loại bỏ hai trường không-tất-định được nêu trong ISO 32000-2 — định danh tệp trailer (/ID, §7.5.5) và các trường ngày trong thông tin tài liệu (CreationDate / ModDate, mang trong từ điển thông tin tài liệu, một vị trí riêng biệt với trailer) — thông qua DeterministicSettings. Các khẳng định văn bản dựa vào /ToUnicode CMap (§9.10.2) mà engine phát cho các phông chữ được nhúng; enableTaggedPdf() thêm cây cấu trúc riêng biệt và không tạo ra CMap đó. Mọi lệnh gọi NextPDF được trình bày đều là API công khai đã xác minh.