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