Test Receipt Printing in CI — No Hardware
How to put receipt printing under CI: golden-file byte tests, decoded snapshots, a printer service in the pipeline, injected faults, and conformance runs.
Published 2026-07-24 · Updated 2026-09-21 · For developers →
You can test receipt printing in CI without a printer by testing at five layers: assert on the exact ESC/POS bytes your code emits (golden files), decode those bytes to text for readable snapshot tests, run a virtual printer as a service in the pipeline and point your app at it, inject faults — paper out, cover open, offline — to prove your error handling works, and finally run a conformance suite that holds the simulator and the real device to the same contract. Each layer catches a class of bug the previous one can't.
The examples below are patterns to adapt to your application. Proxy Nodes commands
require an authorized developer-workspace checkout with dependencies installed;
request evaluation access first. The twin and suite are not standalone
public package installs. Use the Node version in .nvmrc and pnpm version in
package.json. Your application's encoder, fixtures and test helpers are not supplied
by this guide.
Why printing goes untested
Receipt printing is usually the least-tested code path in a POS product, for an understandable reason: the test fixture is a physical machine that eats paper. So teams test by printing at their desk during development, and the code that handles a printer's bad days — the source of most "printer offline" support tickets — ships unexercised. The fix is the same as for any hardware dependency: move the printer's observable behavior into software, then test against it at increasing levels of realism.
Think of it as a pyramid. Fast, plentiful byte tests at the bottom; a few slow, high-fidelity integration tests at the top.
Level 1: golden-file byte assertions
Your encoder turns an order into ESC/POS bytes. Those bytes are deterministic, so pin them down. With snapshot testing (Vitest/Jest shown; any framework works):
import { test, expect } from "vitest";
import { encodeReceipt } from "../src/escpos/encode";
import { sampleOrder } from "./fixtures/orders";
test("standard receipt encodes to known bytes", () => {
const bytes = encodeReceipt(sampleOrder);
expect(Buffer.from(bytes).toString("hex")).toMatchSnapshot();
});
test("receipt ends with feed-and-cut", () => {
const bytes = encodeReceipt(sampleOrder);
const tail = Buffer.from(bytes.slice(-3)).toString("hex");
expect(tail).toBe("1d5600"); // GS V 0 — full cut
});In Python, the Dummy printer in python-escpos captures bytes in memory with no device attached:
from escpos.printer import Dummy
d = Dummy()
d.text("TOTAL $12.40\n")
d.cut()
assert b"TOTAL $12.40" in d.output
assert b"\x1dV" in d.output # a GS V cut command was emittedGolden files are fast and catch regressions instantly — an accidental encoding change, a library upgrade that reorders init commands, a stray style toggle. Their weakness: a hex blob is unreadable in code review. When a snapshot changes, nobody can tell from the diff whether the receipt is better or broken. That's what level 2 is for.
Level 2: decode and snapshot the rendered receipt
Run your bytes through an ESC/POS decoder and snapshot the text rendering instead of (or alongside) the hex. Now diffs are human-readable:
MAIN STREET DINER
------------------------
1x Burger 9.50
- 1x Fries 2.90
+ 1x Fries LARGE 3.90
------------------------
- TOTAL 12.40
+ TOTAL 13.40A reviewer sees changes in the decoded output, which still needs comparison with real paper. Several routes to a decoded rendering: the open-source renderers surveyed in the ESC/POS emulator guide (escpos-netprinter converts jobs to HTML in Docker, which diffs fine), or the Proxy Nodes digital twin, which renders accepted jobs to text and appends them to receipts.log in the simulator working directory. Each entry includes a timestamp and endpoint; normalize those fields and use an isolated log for deterministic snapshots. This output does not reproduce real fonts, graphics, cutting or paper width.
Levels 1 and 2 test what you send. They cannot test whether you send it correctly over a network, handle the printer's replies, or survive its failures. In-process tests end where the socket begins.
Level 3: run a virtual printer as a CI service
The integration step: start a printer stand-in as a real network service inside the CI job, point your application at it, and run end-to-end tests. The application executes its production code path — connect, transmit, poll status — against something that behaves like the device.
The Proxy Nodes digital twin is built for this seat. It is a plain process (pnpm sim) that listens the way the physical node does: raw ESC/POS on TCP :9100 with DLE EOT status replies, the HTTP API on :8080 (POST /print, GET /status, GET /events for SSE telemetry), and mDNS discovery. Its printing surface is held to the same protocol contract as the shipping firmware — Nodeware 1.2.55 as of this writing — which is what level 5 is about. It is not a complete copy of a node: it has no /bootlog, /network, /compose or /restart, it does not enforce apiMode, and its scan log never records a rejected read. An illustrative GitHub Actions job in a checkout with access to the developer workspace:
test:integration below is your application's test script, not a command supplied by Proxy Nodes.
jobs:
print-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: pnpm install
# Start the virtual printer in the background, with the
# fault-injection control plane enabled for level 4.
- name: Start printer twin
run: pnpm sim --host 127.0.0.1 --no-mdns --no-beacon --control > /tmp/printer-twin.log 2>&1 &
- name: Wait for it to come up
run: npx wait-on tcp:9100 http-get://localhost:8080/status
- name: Run integration tests against it
run: pnpm test:integration
env:
PRINTER_HOST: "127.0.0.1"
PRINTER_PORT: "9100"
NODE_API: "http://127.0.0.1:8080"Two practical CI notes:
- Skip mDNS in CI. Hosted runners rarely route multicast. Pass the twin's address explicitly (as above) and keep discovery tests for a machine where multicast works.
- Make the app configurable. The entire trick is that the printer address is configuration. If your printer host is hard-coded, fixing that is step zero — and it's the same change that later makes hardware swaps painless in production.
What you can now assert that levels 1–2 never could: the job arrived intact over TCP; a DLE EOT 4 (10 04 04) paper-status query got the right reply byte; your timeout fires when it should. Even from a shell:
printf '\x10\x04\x04' | nc -w1 127.0.0.1 9100 | xxd
# one status byte back — the paper sensor reportLevel 4: drive fault injection
This is the layer with the highest bug-per-test yield, because it's the layer nobody tests by hand. Interactively, the twin's console takes fault commands — paper out, cover open, offline, plus drawer, weight, and scan for the other peripherals. For CI, starting it with --control exposes an opt-in HTTP control plane (/__control) so tests can flip the same faults programmatically — no reaching into simulator internals, which would silently stop working the moment the twin runs in a separate container.
The offline control changes a modeled online flag; it does not close HTTP/TCP listeners.
Use a separate socket fixture or terminate the test process to exercise connection loss.
It also does not enforce device API authentication or deliver configured webhooks.
The following is pseudocode: injectFault, pos, order, alerts and the retry
queue belong to your application. Do not add automatic reprints after an uncertain write;
have an operator check whether output already occurred.
The test shape:
test("paper-out surfaces to the operator", async () => {
await injectFault("paper_out"); // via the control plane
const result = await pos.printReceipt(order);
expect(result.ok).toBe(false); // the job must not pretend to succeed
expect(alerts.latest()).toMatch(/paper/i); // a human was told
await clearFault("paper_out");
expect(pos.pendingOrders()).toContain(order); // retain for an explicit, safe retry
});Scenarios worth pinning down, in rough order of production pain:
- Offline mid-job — printer vanishes after the connection opens. Does anything hang?
- Paper out — is the failure loud? Is the order preserved for reprint?
- Cover open — transient by nature; does your code recover when it closes, without duplicate prints?
- Status genuinely unreadable — some transports can't read a sensor at all. The Proxy Nodes API models that state as unknown rather than guessing; your UI should render it honestly too (why printers lie about status).
Level 5: run a conformance suite
The last step reverses the direction of trust: instead of your tests trusting the twin, verify the twin — and later, the physical device — against a written contract. The Proxy Nodes conformance suite is conformance specs behind one command, and it runs identically against the in-process twin, a twin in Docker, or real hardware:
pnpm conformance # in-process twin (CI default)
pnpm conformance 192.168.1.42 # physical target: can print and mutate device stateEvery response is validated against the shared protocol schemas, and the specs cover the things integrations actually trip on: :9100 jobs printing both on close and while the socket is held open, DLE EOT replies agreeing with GET /status, status queries split across TCP chunks, SSE event framing, and JSON (not HTML) error bodies. In CI the suite runs with --strict, which turns every would-be skip into a failure — the twin can never quietly lose a capability and keep reporting green.
Run physical-target checks only on a dedicated bench node, with the correct peripherals and an operator present. The suite can print, operate peripherals and change configuration. Record the firmware, printer and power/cable arrangement with the results.
Passing the software target does not establish physical compatibility. Rerun applicable
checks on hardware, and review skips: missing capabilities or peripheral flags can leave
tests unexecuted. --strict makes skips fail but cannot make simulated USB timing or
paper behavior real. Access and the supported evaluation path are described on the
simulator page.
What NOT to mock
Mocking in the wrong place is how printing tests pass while printing fails. Rules that hold up:
- Don't mock your encoder. It's pure and deterministic — test it for real (level 1). A mocked encoder tests nothing.
- Don't mock at the function boundary (
printerClient.send = jest.fn()). It asserts your code called a function, not that the bytes, framing, or timeouts were right — and it goes stale the moment the client changes. - Do fake at the network boundary. Production talks to a socket; tests should talk to a socket. That's the boundary where a twin slots in with zero application changes.
- Don't write a test double that always succeeds. Real printers jam, sleep, and vanish. A double that can't fail proves your happy path and nothing else — see virtual receipt printer options for which tools can misbehave on demand.
- Don't skip byte tests because integration tests exist. Golden files run in milliseconds and localize failures to the encoder; keep both layers.
Where to start
If you have nothing today: add level 1 golden tests this afternoon — python-escpos's Dummy printer or a snapshot of your encoder output needs no infrastructure. Add the twin-as-a-service job when you next touch status or error handling. The ESC/POS command reference helps when a golden diff needs decoding by eye.
More printing guides on the developers hub.
Frequently asked questions
- How do I test receipt printing without a printer?
- Layer five techniques: snapshot-test the exact ESC/POS bytes your code emits, decode them to text for readable diffs, run a virtual printer or digital twin as a network service and point your app at it, inject faults like paper-out to test error handling, and run a conformance suite that holds the simulator to the real device's contract. None requires hardware.
- What is a golden-file test for ESC/POS?
- A test that pins your encoder's exact byte output to a stored snapshot. If a code or dependency change alters the bytes, the test fails and shows the diff. It's the fastest layer of print testing — python-escpos's Dummy printer or any snapshot framework works.
- Can GitHub Actions run a printer emulator?
- Yes. Start a headless emulator or digital twin as a background process in the job, wait for its ports (e.g. with wait-on), then run integration tests with the printer host set to 127.0.0.1. Avoid mDNS-based discovery in CI — hosted runners rarely support multicast.
- How do I simulate printer errors like paper-out in automated tests?
- Use a twin with a fault-injection control plane. With developer-workspace evaluation access arranged at /simulator, start the Proxy Nodes twin with --control on a restricted test interface. It models paper and cover states. Its offline flag does not disconnect sockets; test transport loss and actual hardware faults separately.
- Should I mock the printer client in unit tests?
- Mock at the network boundary, not the function boundary. A jest.fn() stand-in for your client proves a call happened, not that the bytes, framing, or timeout behavior were correct. A socket-level fake — an emulator or twin — exercises the code production actually runs.
- What does a conformance suite add over integration tests?
- Direction. Integration tests check your app against the twin; a conformance suite checks the twin — and the real device — against a written protocol contract. The Proxy Nodes suite runs the same conformance specs against either, but a passing simulator run does not replace running applicable checks on the exact physical installation.
Related reading
- ESC/POS Emulator: Print Without a PrinterESC/POS emulators compared: which render receipts, which listen on port 9100, and the one that answers DLE EOT status and fakes paper-out on demand.
- Virtual Receipt Printer for Dev and TestingPick a virtual thermal printer by job: print-to-PDF for proofing, online renderers, port-9100 emulators, or a network digital twin for integration work.
- ESC/POS Commands: a Practical ReferenceThe ESC/POS commands that matter in production — init, text style, feed, cut, drawer kick, status — with raw bytes and the quirks between brands.