PDFPipe

Testing documents / Running it in CI

Catching pagination mistakes without rendering the document

Asserting on the rules that govern page behaviour rather than on the pages themselves, which is instant and catches the omissions rather than the outcomes.

Why the obvious approach does not work

Pagination assertions need a rendered document, which makes them slow, so they end up in a tier that runs rarely. But a large share of pagination defects are not subtle layout outcomes, they are missing rules: a totals block with no break-inside, a table with no thead, a heading with no break-after. Those are properties of the markup and the stylesheet, and they can be asserted statically in milliseconds. It does not replace rendering, and it moves the most common failures into the fast tier.

What to do instead

Each of these is a decision with a wrong answer, and the wrong answer usually produces a suite that passes rather than one that fails.

  • Assert that blocks which must not split carry a break-inside rule. That is a static check on the CSS and it catches the omission that causes the most common visible defect.
  • Assert that every table that can paginate has a thead, since a table built from divs or with its header in tbody will not repeat it.
  • Assert that headings carry break-after: avoid, so a heading cannot end a page alone.
  • Estimate the page count from content length and a measured lines-per-page figure, and assert it is in the region you expect. It is approximate and it catches an order-of-magnitude change instantly.
  • Keep these as a guard rather than a proof. They say the rules are present, not that the outcome is right, and the rendered assertions still have to exist.
  • Run them on the generated markup, not on the template source, so a conditional that omits a rule for one branch is caught.

In practice

Test code, with the thing people write instead kept in a comment where it is the more instructive half.

js
// Static, instant, and it catches the most common pagination defect:
// a rule that was never written.
import { parse } from "node-html-parser";

test("blocks that must not split say so", () => {
  const doc = parse(renderInvoice(fixtures.typical));
  const css = readFileSync("templates/print.css", "utf8");

  for (const sel of [".totals", ".signing", ".tax-breakdown", ".remit"]) {
    expect(doc.querySelector(sel)).not.toBeNull();
    expect(cssFor(css, sel)).toMatch(/break-inside:\s*avoid/);
  }
});

test("every paginating table has a thead", () => {
  const doc = parse(renderInvoice(fixtures["many-lines"]));
  for (const table of doc.querySelectorAll("table")) {
    expect(table.querySelector("thead")).not.toBeNull();
  }
});

test("headings do not end a page alone", () => {
  const css = readFileSync("templates/print.css", "utf8");
  expect(cssFor(css, "h2")).toMatch(/break-after:\s*avoid/);
});

// A rough page estimate, from a measured lines-per-page figure. It is
// approximate on purpose: it catches an order-of-magnitude change in
// milliseconds, and the rendered assertion catches the rest.
test("page count is in the right region", () => {
  const rows = fixtures["many-lines"].rows.length;
  const estimate = Math.ceil(rows / ROWS_PER_PAGE) ;
  expect(estimate).toBeGreaterThan(3);
  expect(estimate).toBeLessThan(6);
});

What people do instead

Believing it replaces rendering. It proves the rules are present and says nothing about whether they produced the right result, so a suite made only of these will pass while a table splits in the wrong place for a reason the rules did not cover.

What a failure actually tells you

A failure names a missing rule and points at the selector, which is the most directly actionable failure in the whole cluster: the fix is one line and the page tells you which.

Frequently asked

Why not just compare the PDF bytes?

Because two renders of identical input are not byte-identical. A PDF carries a creation timestamp and a document identifier, and font subsetting can differ between runs, so a byte comparison fails on the first run and keeps failing. The techniques here all pick a projection of the document that is stable across runs and still says something about whether it is correct.

How much of this is worth doing for one document?

The cheap end, and it is genuinely cheap: a page count and a handful of containment assertions on extracted text will catch most regressions for the cost of one render. Visual comparison and the full fixture matrix earn their keep when the document is customer-facing and numerous, and not before.

Does any of this need a browser in the CI image?

No. Rendering happens over an API call, so the runner needs a client rather than an engine, and the tests that need no document at all, the contract checks and the static pagination checks, need nothing. Keeping a browser in the image to run tests means testing a renderer that is not the one shipping.

Related testing techniques

The techniques that pair with this one, then the rest of the same group.

Every technique here needs a document to assert on. Render one of your real templates first, then decide which projection of it is worth a test.