Test Receipt Printing in CI — No Hardware
How to put receipt printing under CI: render ESC/POS to text, assert on bytes, simulate paper-out and offline faults, and run conformance checks.
Published 2026-07-24 · For developers →
You can test receipt printing in CI without a printer by testing at four 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, and inject faults — paper out, cover open, offline — to prove your error handling works. Each layer catches a class of bug the previous one can't.
Here are working recipes for all four, plus a GitHub Actions sketch and a short list of things you should not mock.
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 that 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 cheap 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 exactly what changed on 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 ProxyNodes twin, which renders every job it receives to readable text and appends it to a receipts.log you can snapshot.
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 ProxyNodes digital twin is built for this seat. It's 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. A GitHub Actions sketch:
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 --control &
- 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 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");
await expect(pos.retryQueue.flush()).resolves.toBeTruthy(); // and the order survived
});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 says "unknown" — some transports genuinely can't read a sensor. The ProxyNode API reports
known: falsein that case rather than guessing; your UI should render that 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 ProxyNodes conformance suite is one command that runs identically against the in-process twin, a twin in Docker, or real hardware on a bench:
pnpm conformance # in-process twin (CI default)
pnpm conformance 192.168.1.42 # the same suite, real hardwareEvery response is validated against shared protocol schemas, and the suite covers 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 it runs with --strict, which turns every would-be skip into a failure — so the twin can never quietly lose a capability and keep reporting green. That symmetry is the point: when the same assertions pass in simulation and on the bench, "works on the twin" stops being a leap of faith. (Twin and suite are source-open at launch — the repository is not public yet. Details 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 actually 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 four 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, and inject faults like paper-out to test error handling. 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, cheapest 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. The ProxyNodes twin, started with --control, lets tests flip paper-out, cover-open, and offline over HTTP; its DLE EOT and status responses change accordingly, so your app experiences the fault exactly as it would with hardware.
- 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, with identical assertions for both. When both pass, behavior verified in simulation is credible on hardware.
Related reading
- ESC/POS Emulator: Print Without a PrinterCompare ESC/POS emulators and receipt printer simulators for development — and how to emulate paper-out, cover-open, and DLE EOT status in software.
- Virtual Receipt Printer for Dev and TestingOptions for a virtual thermal printer: print-to-image drivers, GitHub emulators, and a network digital twin your app can't tell from real hardware.
- 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.