Layout snapshot testing: catch re-flows before they ship

June 10, 2026 · View on GitHub

GraphCompose's layout engine is deterministic: the same document produces the same resolved geometry, every run, on every machine. That makes layout itself testable — DocumentSession.layoutSnapshot() captures the page count, canvas, and the depth-first list of every node's resolved bounds and metadata as stable JSON, deliberately leaving out renderer-specific bytes (font embedding, PDFBox object IDs, timestamps). When a change re-flows something it shouldn't have, the JSON diff shows it instantly — no PDF diffing, no golden images.

A snapshot test in three lines

import com.demcha.compose.testing.layout.LayoutSnapshotAssertions;

@Test
void invoiceLayoutIsStable() throws Exception {
    try (DocumentSession document = GraphCompose.document()
            .pageSize(DocumentPageSize.A4)
            .margin(DocumentInsets.of(28))
            .create()) {
        new InvoiceTemplateV2(theme).compose(document, sampleInvoice());

        LayoutSnapshotAssertions.assertMatches(document, "templates/invoice/invoice_baseline");
    }
}

The slash-delimited key is a logical path: this example compares against src/test/resources/layout-snapshots/templates/invoice/invoice_baseline.json. LayoutSnapshotAssertions ships in the main GraphCompose artifact, so consumer projects can use it without any extra test dependency.

First run and updates

On the first run the baseline does not exist: the assertion fails, writes the actual snapshot, and tells you how to accept it. Accepting — and updating after any deliberate layout change — is one flag:

./mvnw test -Dtest=YourSnapshotTest -Dgraphcompose.updateSnapshots=true

This overwrites the committed baseline with the current layout. Review the JSON diff before committing: that diff is the layout change.

On mismatch

A failed comparison writes the offending snapshot to target/visual-tests/layout-snapshots/<path>.actual.json and the assertion message names both files:

Layout snapshot mismatch for invoice_baseline.
Expected: src/test/resources/layout-snapshots/templates/invoice/invoice_baseline.json
Actual:   target/visual-tests/layout-snapshots/templates/invoice/invoice_baseline.actual.json
Re-run with -Dgraphcompose.updateSnapshots=true to update the baseline.

Diff the two to see exactly which node moved, grew, or paginated differently. A passing run cleans up any stale .actual.json.

Using it in consumer projects

This is not just an internal tool — if you build templates on GraphCompose, snapshot tests are the cheapest regression net for them: compose the template with fixed sample data, assert the snapshot, and a GraphCompose upgrade (or your own refactor) that shifts the layout fails loudly instead of silently re-flowing a customer document.

Baselines default to src/test/resources/layout-snapshots; overloads take explicit roots when your project keeps them elsewhere:

LayoutSnapshotAssertions.assertMatches(document,
        Path.of("src", "test", "resources", "my-baselines"),
        Path.of("target", "snapshot-failures"),
        "quotes/quote_standard");

For non-JUnit flows, the underlying pieces are public too: document.layoutSnapshot() returns the snapshot and LayoutSnapshotJson.toJson(snapshot) serialises it, so you can wire the same check into any harness.

Pair the snapshot with a rendered PDF from the same session (document.toPdfBytes()) when you want human-reviewable output next to the machine check.

Runnable walkthrough of the full workflow: LayoutSnapshotRegressionExample. A real in-tree test using the production pattern: ShapeContainerLayoutSnapshotTest.