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 emitted

Golden 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.40

A 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.0.24 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 report

Level 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:

  1. Offline mid-job — printer vanishes after the connection opens. Does anything hang?
  2. Paper out — is the failure loud? Is the order preserved for reprint?
  3. Cover open — transient by nature; does your code recover when it closes, without duplicate prints?
  4. 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 123 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 123 specs, real hardware

Every 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 123 assertions pass against the simulator in CI and against a Station Hub 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, the twin and suite are free to run, and source publication is on the engineering docket; 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 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 123 specs against either, so behavior verified in simulation is credible on hardware.

Related reading