Simulating ISO 8583 messages for testing
You can't point your test suite at Visa. Here's how to simulate ISO 8583 traffic instead — build requests, craft the responses you need (including the ugly declines), and wire it all into pytest and CI.
You cannot point your integration tests at Visa’s production switch. You probably can’t point them at the certification environment either — not on every commit, not without a queue and a VPN and someone’s approval. And yet the code that builds, sends, and interprets ISO 8583 messages is exactly the code you least want to ship untested, because when it’s wrong, it’s wrong about money.
The answer is to simulate. Not “mock the whole thing and hope” — actually generate real, well-formed ISO 8583 messages and feed them through your code the way the network would. Here’s how to do that without it becoming its own maintenance nightmare.
What you’re actually trying to test
Two things, mostly:
- Your requests are correct. Given an order, does your code build an
0100with the right fields, right encodings, right amount (in minor units — see the format primer if that phrase made you nervous)? - Your response handling is correct. When the issuer approves, you capture. When it declines, you don’t — and you do the right thing for each kind of decline. This is the half everyone under-tests.
That second point is where simulation earns its keep, because in the real world you almost never see the interesting responses on demand. You can’t ask a bank to decline your test card for “insufficient funds” at 2pm on a Tuesday. But you can generate that response in a test.
sequenceDiagram
autonumber
participant Test as Your test
participant Code as Payment code
participant Sim as ISO 8583 simulator
Test->>Code: charge($10, test card)
Code->>Sim: 0100 auth request
alt approved
Sim-->>Code: 0110 · DE39 = 00
Code-->>Test: captured ✓
else declined
Sim-->>Code: 0110 · DE39 = 51
Code-->>Test: declined, not captured ✓
end
Building a request
Start with the outbound side. A minimal authorization is a message type and a few fields:
from iso8583sim.core.builder import ISO8583Builder
from iso8583sim.core.types import ISO8583Message
def auth_request(pan, amount_minor):
return ISO8583Message(mti="0100", fields={
2: pan,
3: "000000", # processing code: purchase
4: f"{amount_minor:012d}", # amount, 12 digits, no decimal
11: "000001", # STAN
41: "TERM0001", # terminal ID
})
raw = ISO8583Builder().build(auth_request("4111111111111111", 1000))
That raw is the real on-the-wire bytes, bitmap and all. Feed it to your parser, your gateway, your logging — whatever you’re testing — exactly as if it came off a socket.
Every network reserves BINs for exactly this. 4111 1111 1111 1111 is the canonical Visa test PAN; there are equivalents for the others. Real card numbers do not belong in your fixtures, your logs, or your git history.
Crafting the responses you need
A response is just another message — an 0110 with a response code in DE 39. The whole point of simulating is that you choose that code, so build a small catalogue of the ones worth testing:
| DE 39 | Meaning | What it exercises in your code |
|---|---|---|
00 |
Approved | The happy path — capture, receipt, fulfilment |
51 |
Insufficient funds | Soft decline; retry / dunning logic |
05 |
Do not honor | Generic hard decline |
54 |
Expired card | Whether you caught this before sending |
91 |
Issuer unavailable | Timeout, fallback, and reversal behavior |
14 |
Invalid card number | Input validation upstream |
Producing any of them is a two-line helper:
def response_to(request, code):
return ISO8583Message(mti="0110", fields={
39: code, # response code
4: request.fields[4], # echo the amount
11: request.fields[11], # echo the STAN so it can be matched
})
Now you can drive your response handler through every branch, deterministically, on every test run.
Wiring it into pytest
Because the messages are just objects and bytes, they slot straight into a normal parametrized test. No network, no fixtures server, no flakiness:
import pytest
@pytest.mark.parametrize("code, should_capture", [
("00", True), # approved
("51", False), # insufficient funds
("05", False), # do not honor
("91", False), # issuer unavailable
])
def test_capture_decision(gateway, code, should_capture):
request = auth_request("4111111111111111", 1000)
response = response_to(request, code)
result = gateway.handle(response)
assert result.captured is should_capture
Run it in CI on every push and you’ve locked down the part of the system that’s genuinely scary to get wrong. When a teammate “simplifies” the decline logic six months from now, this test is what catches it.
Going further: validation, networks, and chip data
Two things worth layering in once the basics pass:
- Validate before you trust.
ISO8583Validatorchecks a message against structural rules and, optionally, network-specific ones — so your test can assert not just “my code accepted it” but “this message was actually well-formed for Visa.” Catching a malformed request in a unit test beats catching it in certification. - Don’t forget DE 55. If you touch chip cards, the EMV data in field 55 is its own TLV format, and it has its own failure modes. Simulate it early; it’s the field most likely to surprise you.
And when you need variety rather than a fixed set — a hundred plausible-but-different transactions to fuzz a reconciliation job — you can generate them programmatically or, if you’d rather describe them in English, hand the description to the LLM generator and let it produce the messages.
The short version
Simulating ISO 8583 isn’t about elaborate infrastructure. It’s a builder, a parser, and a handful of helper functions that let you produce any request and any response you can name — then run them through your code deterministically, offline, on every commit. The messages are real; only the network is imaginary. Start from the quick start, write the decline tests first, and sleep better before your next release.