Testing ISO 20022 Message Flows
Validating pacs.008 and camt.053 Mappings End-to-end
Validating pacs.008 and camt.053 Mappings End-to-end
I test payment systems for banks, and most of my time on ISO 20022 projects goes into one question: does the data a customer entered survive every step until it appears, unchanged, on a statement? A schema-valid pacs.008 is where that work starts, not where it ends. This article sets out the test strategy we use for structured payment messages, in three layers: schema checks, round-trip mapping and reconciliation. The examples use JUnit 5, XMLUnit and Testcontainers.
ISO 20022 messages are rich and strict, and that is exactly why they are tested badly. The XSD for pacs.008 (FI-to-FI customer credit transfer) allows far more than any real payment scheme accepts. CBPR+, the SEPA rulebooks and the HVPS+ guidelines each add their own rules on top: mandatory fields, maximum lengths, allowed character sets and code lists. A message can pass the XSD and still be rejected by the scheme, or worse, be accepted with data silently cut short.
The mapping is where the risk sits. Most bank systems don't produce ISO 20022 natively. They map from an internal payment model, from MT103 legacy flows or from a core banking format. Every mapping step can truncate, reformat or drop a field. Since the end of the MT/MX coexistence period for cross-border payments, those losses are no longer hidden behind a legacy format; they show up as rejections, compliance gaps or reconciliation breaks.
So we test in three layers, each catching a different class of defect.
The first layer is fast and runs on every build. Every message the mapper produces, across a scenario matrix of payment types, currencies, charge codes and party structures, is validated against the exact XSD version the scheme uses:
1 class Pacs008SchemaTest {
2
3 static Schema schema;
4
5 @BeforeAll
6 static void loadSchema() throws SAXException {
7 schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI)
8 .newSchema(Pacs008SchemaTest.class.getResource("/xsd/pacs.008.001.08.xsd"));
9 }
10
11 @ParameterizedTest
12 @MethodSource("com.oceanobe.payments.Scenarios#sepaCreditTransfers")
13 void generatedMessageIsSchemaValid(Payment payment) {
14 String xml = pacs008Mapper.toXml(payment);
15 assertDoesNotThrow(() ->
16 schema.newValidator().validate(new StreamSource(new StringReader(xml))));
17 }
18 }
The XSD alone doesn't encode the scheme's usage guidelines, so we add explicit rule tests next to it. These are the ones that catch real defects:
Character sets. SEPA traditionally restricts content to a basic Latin character set. A debtor named Ștefănescu needs a defined conversion rule, and the test should assert the exact output, not just that "something valid" was sent.
Length limits, for example unstructured remittance information (RmtInf/Ustrd) capped at 140 characters.
Structured addresses. CBPR+ is moving away from fully unstructured postal addresses. Mappers that still put a whole address into AdrLine need tests that fail before the scheme rejects the message.
1 @Test
2 void remittanceOver140CharactersIsRejectedNotTruncated() {
3 Payment payment = aSepaPayment().withRemittance("X".repeat(141)).build();
4
5 assertThatThrownBy(() -> pacs008Mapper.toXml(payment))
6 .isInstanceOf(MappingRuleViolation.class)
7 .hasMessageContaining("RmtInf/Ustrd");
8 }
Whether the right behaviour is to reject or to truncate with a warning is a business decision, not a testing one. What matters is that the test makes the decision explicit and visible. This is where we pair with the business analyst: each usage-guideline rule becomes an acceptance criterion and then a test.
Schema checks prove the structure. Round-trip tests prove the meaning. We run two kinds.
Golden-file comparison. For each key scenario, a reviewed reference pacs.008 is stored in the repository. The generated message is compared to it with XMLUnit, ignoring fields that are expected to change on every run:
1 @Test
2 void sepaBasicMatchesGoldenFile() {
3 Diff diff = DiffBuilder
4 .compare(Input.fromStream(resource("/golden/pacs008_sepa_basic.xml")))
5 .withTest(Input.fromString(pacs008Mapper.toXml(goldenPayment())))
6 .ignoreWhitespace()
7 .withNodeFilter(n -> !Set.of("MsgId", "CreDtTm", "UETR").contains(n.getLocalName()))
8 .checkForSimilar()
9 .build();
10
11 assertFalse(diff.hasDifferences(), diff.toString());
12 }
Golden files double as documentation. When a scheme publishes a new usage guideline version, the diff in the pull request shows reviewers exactly what changed in the output.
Domain round-trip. Map from the internal model to XML, parse the XML back, and compare the result with the original:
1 Payment parsed = pacs008Parser.parse(pacs008Mapper.toXml(original));
2 assertThat(parsed).usingRecursiveComparison().isEqualTo(original);
Any field that doesn't survive the round trip is either a mapping defect or a known, documented loss. Both outcomes are useful. We feed this test with generated data (long names, diacritics, maximum-length references, amounts at the precision limit, currencies with zero or three decimal places), because hand-written fixtures rarely cover those edge cases.
The final layer tests what the bank's customers and finance teams actually see. A payment sent as pacs.008 has to show up in the camt.053 end-of-day statement, and the statement itself has to be internally consistent.
Statement invariants. These can be checked on any camt.053, including files received from other banks. The most important one is that the opening booked balance plus the signed sum of entries equals the closing booked balance:
1 @Test
2 void statementBalancesAddUp() {
3 Statement stmt = camt053Parser.parse(resource("/camt053/2026-10-01.xml"));
4
5 BigDecimal movement = stmt.entries().stream()
6 .map(e -> e.creditDebit() == CRDT ? e.amount() : e.amount().negate())
7 .reduce(BigDecimal.ZERO, BigDecimal::add);
8
9 assertThat(stmt.openingBooked().add(movement))
10 .isEqualByComparingTo(stmt.closingBooked());
11 }
Two details catch out many parsers. First, balances carry their sign in CdtDbtInd, not in the amount, so an overdrawn opening balance must be negated. Second, amounts are always BigDecimal, never double.
End-to-end matching. With Testcontainers we run the real path in one test: a payment command published to Kafka, the payment service producing pacs.008, a clearing simulator answering with pacs.002, and the statement service generating camt.053. The test then finds the entry by EndToEndId and checks it field by field:
1 assertThat(statement.entryFor(payment.endToEndId())).hasValueSatisfying(entry -> {
2 assertThat(entry.amount()).isEqualByComparingTo(payment.amount());
3 assertThat(entry.currency()).isEqualTo(payment.currency());
4 assertThat(entry.creditDebit()).isEqualTo(DBIT);
5 assertThat(entry.remittanceInfo()).isEqualTo(payment.remittanceInfo());
6 });
The same test runs for rejection paths. A pacs.002 with status RJCT must result in no booked debit, or in a debit and a matching return, depending on the scheme. Here too, the rule should be written down before the test is. We drive the clearing simulator with real ISO external reason codes, such as AC01 (incorrect account number), AM04 (insufficient funds) and RR04 (regulatory reason). Each code then maps to a customer-facing message and a status in the channel, and those mappings are tested too.
Most of the value of this strategy comes from choosing the right scenarios. Here is an excerpt of the kind of matrix we keep:
Scenario | Scheme | Edge case | Expected outcome
Domestic SEPA credit transfer | SEPA SCT | Debtor name with Romanian diacritics | Converted per agreed rule, golden file matches
Cross-border EUR → USD | CBPR+ |Fully unstructured creditor address |Rejected by mapper before sending
Salary batch, 5,000 payments | SEPA SCT |Mixed valid and invalid IBANs | Valid items booked, invalid ones reported individually
JPY payment | CBPR+ |Zero-decimal currency |Amount has no fractional part in pacs.008 and camt.053
Rejected payment | SEPA SCT |pacs.002 RJCT, reason AC01 |No booked debit, customer sees "incorrect account"
Each row is cheap to add and maps directly to a test in one of the three layers.
A few practices keep this strategy affordable as schemes evolve:
Version XSDs and golden files together, in folders named after the usage guideline release. A scheme upgrade becomes a reviewed diff, not a surprise.
Keep the scenario matrix in one place, owned jointly by the tester and the BA. Each row lists payment type, scheme, edge-case data and expected outcome.
Run each layer at a different cadence. Layers 1 and 2 run on every commit in seconds. Layer 3 runs on every merge and nightly against the integration environment.
Treat rejections from the scheme's test environment as test cases. Each real rejection reason becomes a regression test in Layer 1.
ISO 20022 brings structure that legacy formats never had. Testing it well means checking the meaning of the data, not just the shape of the message. The three layers answer three questions: is the message valid, did the data survive the mapping, and does the money reconcile? Together they give risk and operations teams evidence they can rely on long before a payment reaches production.