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 emitted

Golden 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.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 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 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 says "unknown" — some transports genuinely can't read a sensor. The ProxyNode API reports known: false in 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 hardware

Every 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