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 · Updated 2026-08-02 · 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.
Here are working recipes for all five, plus a GitHub Actions job you can copy 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 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 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 Proxy Nodes digital 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 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. It is byte-compatible with the shipping firmware — 1.2.33 as of this writing — because both are held to one shared protocol contract, which is what level 5 is about. A GitHub Actions job:
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 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 146 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 # the same 146 specs, real hardwareEvery 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.
That symmetry is the point. The same 146 assertions pass against the simulator in CI and against a Proxy Node Kit on a bench, so "works on the twin" stops being a leap of faith — it's the same contract the firmware itself is tested against on every push. The protocol is documented and the twin and suite are free to run; details on the simulator page. We have not announced an open-source release of the firmware.
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. The Proxy Nodes 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. The Proxy Nodes suite runs the same 146 specs against either, so 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.