Kiểm thử các PDF được tạo trong CI
Tổng quan nhanh
Phần tiêu đề “Tổng quan nhanh”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.
Cài đặt
Phần tiêu đề “Cài đặt”composer require --dev phpunit/phpunitcomposer require nextpdf/core:^3Khẳ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() và 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; // boolInspector::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ọiaddFontDirectory()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 GitHub Actions
Phần tiêu đề “Workflow GitHub Actions”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=pdfpoppler-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.
Trường hợp đặc biệt & lưu ý
Phần tiêu đề “Trường hợp đặc biệt & lưu ý”- 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. fileIdSeedlà đú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émInvalidConfigExceptionlú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ùngpdftotexthoặc sidecar Spectrum của Inspect. Việc của bộ tạo là phát một/ToUnicodeCMap đú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.
Hiệu năng
Phần tiêu đề “Hiệu năng”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.
Ghi chú bảo mật
Phần tiêu đề “Ghi chú bảo mật”- 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-utilsqua 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.
Tuân thủ
Phần tiêu đề “Tuân thủ”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.