Series MapLesson 58 / 64
Final StretchOrdered learning track

Learn Java Payment Systems Part 058 Testing Strategy For Payment Platforms

13 min read2407 words
PrevNext
Lesson 5864 lesson track5464 Final Stretch

title: Build From Scratch: Large Production Grade Java Payment Systems - Part 058 description: Testing strategy for production-grade Java payment systems, including contract tests, provider simulator, ledger property tests, concurrency tests, webhook replay, reconciliation tests, chaos testing, migration tests, and release evidence. series: learn-java-payment-systems seriesTitle: Build From Scratch: Large Production Grade Java Payment Systems order: 58 partTitle: Testing Strategy for Payment Platforms tags:

  • java
  • payments
  • payment-systems
  • testing
  • property-based-testing
  • contract-testing
  • testcontainers
  • simulator
  • chaos-testing
  • ledger
  • enterprise-architecture date: 2026-07-02

Part 058 — Testing Strategy for Payment Platforms

Testing payment platform bukan mencari coverage tinggi.

Testing payment platform adalah membangun bukti bahwa sistem menjaga invariant finansial saat menghadapi input buruk, retry, concurrency, provider failure, webhook duplikat, reconciliation mismatch, operator action, dan migration.

Coverage 90% bisa tetap gagal menangkap:

  • double charge,
  • double ledger posting,
  • refund melebihi captured amount,
  • payout dua kali,
  • webhook out-of-order,
  • provider timeout yang dianggap failed,
  • settlement batch non-reproducible,
  • manual adjustment tanpa approval,
  • idempotency key reuse dengan body berbeda,
  • ledger projection drift.

Rule utama:

In payment systems, tests should prove invariants, not just execute branches.

1. Mental Model: Test Evidence, Not Just Test Cases

Payment system perlu test yang menghasilkan evidence.

Evidence untuk:

  • engineer,
  • reviewer,
  • SRE,
  • finance operations,
  • security,
  • compliance,
  • auditor,
  • incident commander.

Test suite harus bisa menjawab:

Under which conditions can money be created, lost, duplicated, or misreported?

Jawaban ideal:

We tested those conditions explicitly, and invariant checks fail the build if they happen.

Bukan:

The unit tests passed.

2. Payment Testing Pyramid

Pyramid generic:

unit -> integration -> e2e

Tidak cukup untuk payment.

Payment testing stack lebih tepat:

Pyramid ini bukan berarti semua test lambat.

Sebagian besar tetap cepat.

Yang berubah adalah orientasi: invariant finansial menjadi pusat.

3. Test Classification

Test TypePurposeSpeedExample
Value object testcorrectness lokalsangat cepatMoney rounding, currency minor unit
State machine testlegal transitioncepatauthorized -> captured legal
Posting rule testledger balancedcepatcapture posts debit/credit correctly
Property-based testinvariant across many inputscepat-sedangrefund never exceeds captured
Idempotency testduplicate command safetysedangsame key returns same result
Concurrency testrace condition safetysedang-lambatcapture vs cancel race
Contract testAPI/provider boundarycepat-sedangerror schema backward compatible
Adapter simulator testexternal behaviorsedangtimeout + delayed webhook
Integration testreal DB/broker behaviorsedangunique constraint, outbox relay
Replay testhistorical/event sequence safetysedang-lambatreplay webhooks after deploy
Reconciliation testfile matching correctnesssedangmany-to-one settlement matching
Settlement testpayout calculation reproduciblesedang-lambatreserve + fee + refund batch
Chaos testresilience under failurelambatkill webhook worker mid-run
Migration testdata evolution safetylambatledger backfill exact totals

4. Core Invariant Test List

Minimal invariant yang harus dites berulang:

4.1 Payment Invariants

  • satu purchase tidak boleh menghasilkan lebih dari satu successful charge kecuali eksplisit multi-capture/split flow,
  • payment terminal tidak boleh kembali ke non-terminal tanpa correction workflow,
  • unknown outcome tidak boleh dianggap failed tanpa evidence,
  • provider success evidence tidak boleh diabaikan oleh internal failure,
  • duplicate webhook tidak boleh mengubah hasil kedua kali,
  • out-of-order webhook tidak boleh menurunkan state,
  • same idempotency key + same fingerprint harus replay response,
  • same idempotency key + different fingerprint harus conflict.

4.2 Ledger Invariants

  • setiap journal balanced,
  • setiap posting rule menghasilkan debit/credit yang benar,
  • setiap business operation punya idempotency key ledger,
  • tidak ada negative available balance kecuali policy mengizinkan,
  • refund total tidak boleh melebihi captured refundable amount,
  • payout total tidak boleh melebihi available payable,
  • correction/reversal tidak boleh mutate journal lama,
  • balance projection bisa direbuild dari ledger entries.

4.3 Settlement Invariants

  • settlement batch punya input watermark,
  • settlement batch reproducible,
  • high severity reconciliation break tidak boleh auto-settle,
  • merchant hold/reserve mengurangi payable,
  • payout instruction dibuat sekali per settlement item,
  • failed payout tidak boleh dianggap paid.

4.4 Operational Invariants

  • manual adjustment butuh reason/evidence,
  • high-risk action butuh maker-checker,
  • approver tidak boleh sama dengan maker,
  • stale approval tidak boleh dieksekusi,
  • break-glass action selalu audited,
  • sensitive reveal selalu logged dan time-bounded.

5. State Machine Tests

State machine test harus membuktikan legal transition.

Contoh model:

enum PaymentState {
    REQUIRES_PAYMENT_METHOD,
    REQUIRES_CONFIRMATION,
    AUTHENTICATING,
    AUTHORIZED,
    CAPTURED,
    CANCELLED,
    FAILED,
    UNKNOWN,
    REFUNDED,
    DISPUTED
}

Test jangan hanya happy path.

@Test
void capturedPaymentCannotBeCancelled() {
    Payment p = PaymentFixtures.captured();

    assertThatThrownBy(() -> p.cancel(CancelReason.MERCHANT_REQUEST))
        .isInstanceOf(IllegalStateTransitionException.class);
}

@Test
void unknownPaymentCannotBeBlindlyRetriedAsNewCharge() {
    Payment p = PaymentFixtures.unknownAfterProviderTimeout();

    assertThatThrownBy(() -> p.startNewProviderAttemptWithoutInquiry())
        .isInstanceOf(UnknownOutcomeRequiresResolutionException.class);
}

State machine test matrix:

FromEventExpected
requires_confirmationconfirm acceptedprocessing/authenticating/authorized
authorizedcapture successcaptured
authorizedcancel successcancelled
capturedcancel requestedrejected
capturedrefund successpartially/refunded
unknownwebhook successcaptured/authorized depending event
unknowninquiry failed definitivefailed
failedwebhook successconflict or repair workflow
capturedduplicate capture webhookno-op idempotent

6. Ledger Property-Based Tests

Property-based testing cocok untuk ledger.

Daripada test 5 contoh refund, generate banyak sequence.

Property:

For any valid sequence of captures, refunds, chargebacks, reserves, releases, and payouts:
- every journal is balanced,
- available never violates policy,
- total refunded <= total captured,
- final projection equals sum(entries),
- replaying same operation idempotently does not change totals.

Pseudo jqwik-style:

@Property
void ledgerRemainsBalancedForValidPaymentLifecycle(
    @ForAll("validPaymentOperationSequences") List<PaymentOperation> operations
) {
    Ledger ledger = new InMemoryLedger();
    PaymentAggregate payment = PaymentFixtures.authorizable();

    for (PaymentOperation op : operations) {
        payment.apply(op, ledger);
        assertThat(ledger.allJournals()).allMatch(Journal::isBalanced);
    }

    BalanceProjection rebuilt = BalanceProjection.rebuildFrom(ledger.entries());
    assertThat(rebuilt).isEqualTo(ledger.currentProjection());
}

Property-based testing tidak mengganti example-based tests.

Ia menutup ruang input yang terlalu besar untuk ditulis manual.

7. Idempotency Tests

Idempotency test harus mencakup API, provider, webhook, ledger, outbox, dan backoffice.

7.1 API Idempotency

@Test
void sameIdempotencyKeyAndSameFingerprintReplaysSameResponse() {
    CreatePaymentRequest request = request(amount("IDR", 100_000));

    ApiResponse first = client.createPayment(request, idempotencyKey("k1"));
    ApiResponse second = client.createPayment(request, idempotencyKey("k1"));

    assertThat(second.body()).isEqualTo(first.body());
    assertThat(paymentRepository.countByClientReference(request.clientReference())).isEqualTo(1);
}

@Test
void sameIdempotencyKeyWithDifferentFingerprintIsConflict() {
    client.createPayment(request(amount("IDR", 100_000)), idempotencyKey("k1"));

    ApiResponse second = client.createPayment(request(amount("IDR", 200_000)), idempotencyKey("k1"));

    assertThat(second.status()).isEqualTo(409);
}

7.2 Ledger Idempotency

@Test
void sameBusinessPostingDoesNotCreateSecondJournal() {
    LedgerCommand command = capturePosting(paymentId, captureId, amount("IDR", 100_000));

    ledger.post(command);
    ledger.post(command);

    assertThat(ledger.findJournalsByBusinessRef("capture", captureId)).hasSize(1);
}

7.3 Webhook Idempotency

@Test
void duplicateWebhookIsNoopAfterFirstApply() {
    ProviderWebhook event = providerEvent("evt_123", "payment_succeeded");

    webhookIngestion.receive(event);
    webhookIngestion.receive(event);

    assertThat(rawWebhookRepository.countByProviderEventId("evt_123")).isEqualTo(1);
    assertThat(ledger.countJournalsForPayment(event.paymentRef())).isEqualTo(1);
}

8. Concurrency Tests

Concurrency bug jarang muncul di test linear.

Payment concurrency cases:

  • two confirm requests same idempotency key,
  • two confirm requests different idempotency key same payment,
  • capture vs cancel,
  • refund vs refund,
  • webhook success vs API timeout resolution,
  • payout worker double claim,
  • settlement finalize double click,
  • manual adjustment approval race,
  • balance reservation race.

Test pattern:

@Test
void concurrentCaptureAndCancelOnlyOneWins() throws Exception {
    PaymentId paymentId = fixture.authorizedPayment();
    CyclicBarrier barrier = new CyclicBarrier(2);

    Future<Result> capture = executor.submit(() -> {
        barrier.await();
        return paymentService.capture(paymentId, idempotencyKey("cap"));
    });

    Future<Result> cancel = executor.submit(() -> {
        barrier.await();
        return paymentService.cancel(paymentId, idempotencyKey("void"));
    });

    List<Result> results = List.of(capture.get(), cancel.get());

    assertThat(results).filteredOn(Result::isSuccess).hasSize(1);
    assertThat(ledger.journalsFor(paymentId)).satisfies(journals -> {
        assertThat(journals).allMatch(Journal::isBalanced);
        assertThat(journals).doesNotHaveDuplicates();
    });
}

Gunakan real database untuk concurrency test.

Mock repository tidak bisa membuktikan unique constraint, row lock, isolation behavior, atau deadlock handling.

9. Contract Tests

Payment platform punya banyak boundary:

  • public API,
  • merchant API,
  • webhook API,
  • provider adapter port,
  • internal event schema,
  • ledger posting command,
  • reconciliation file schema,
  • settlement report schema.

Contract tests harus menjaga backward compatibility.

Contoh contract rules:

Public API:
- existing field cannot change type
- enum value cannot be removed
- error code remains stable
- idempotency response shape stable
- amount minor unit semantics stable

Webhook:
- event id required
- event type required
- object id required
- signature header required
- payload version explicit

Internal events:
- aggregate id required
- event id globally unique
- occurred_at immutable
- schema version required
- backward-compatible additions only

Pact dapat dipakai untuk consumer-driven contract tests, terutama komunikasi service-to-service HTTP. Untuk OpenAPI-first API, contract validation juga bisa dilakukan dengan schema validators dan backward-compatibility check di CI.

10. Provider Simulator Tests

Jangan bergantung pada provider sandbox untuk semua test.

Provider sandbox penting, tetapi tidak cukup karena:

  • sulit mengatur timeout deterministik,
  • sulit menghasilkan duplicate webhook ekstrem,
  • sulit membuat out-of-order sequence spesifik,
  • sulit membuat malformed response,
  • sulit menguji incident volume,
  • provider sandbox bisa berubah.

Bangun simulator sendiri.

Simulator harus mendukung:

scenarios:
  happy_path_authorize_capture:
    authorize_response: authorized
    capture_response: captured
    webhook_sequence:
      - authorization_succeeded
      - capture_succeeded

  timeout_then_late_success:
    authorize_response: timeout
    inquiry_response_after_seconds: 30
    final_status: authorized
    webhook_delay_seconds: 90

  duplicate_webhook:
    authorize_response: authorized
    webhook_sequence:
      - authorization_succeeded
      - authorization_succeeded
      - authorization_succeeded

  out_of_order_capture:
    capture_response: captured
    webhook_sequence:
      - capture_succeeded
      - authorization_succeeded

  provider_5xx_then_idempotent_success:
    first_response: http_500
    retry_same_key_response: authorized

  malformed_response:
    authorize_response: invalid_json

Simulator API example:

POST /simulator/scenarios
Content-Type: application/json

{
  "scenarioId": "timeout-then-late-success",
  "paymentRef": "pay_123",
  "webhookDelaySeconds": 90
}

Simulator bukan mainan.

Simulator adalah safety lab.

11. Webhook Replay Tests

Webhook harus bisa direplay.

Replay tests membuktikan bahwa memproses ulang event tidak merusak state.

Test cases:

CaseExpected
replay same eventno duplicate ledger
replay older event after newer eventno state regression
replay event with invalid signaturerejected/quarantined
replay event after mapping version changedeterministic mapping or explicit migration
replay all events for one paymentfinal state same
replay events after partial DB failureinbox/outbox recover
replay poison eventquarantined, does not block partition forever

Pseudo:

@Test
void replayingWebhookHistoryProducesSameFinalState() {
    List<RawWebhookEvent> events = fixture.webhookHistory("pay_123");

    PaymentState first = replayEngine.replay(events).finalPaymentState();
    PaymentState second = replayEngine.replay(events).finalPaymentState();

    assertThat(second).isEqualTo(first);
    assertThat(ledger.journalsFor("pay_123")).doesNotHaveDuplicates();
}

Replay harus tersedia untuk:

  • satu payment,
  • satu provider reference,
  • satu merchant,
  • satu file,
  • satu time range,
  • satu failed consumer group.

12. Reconciliation Tests

Reconciliation matching harus diuji seperti engine, bukan SQL report.

Test cases:

ScenarioExpected
exact one-to-one matchmatched
provider fee differs within tolerancematched_with_tolerance
amount mismatch above tolerancebreak
duplicate provider recordduplicate break
one bank settlement row contains many paymentsaggregate match
provider ref missing but amount/date matchescandidate, not auto-match unless policy allows
late settlement next daytiming difference
refund in provider report missing internallybreak + correction proposal
internal ledger has captured but provider missingbreak + inquiry

Golden file testing penting.

src/test/resources/reconciliation/golden/
  adyen-settlement-v1.csv
  adyen-settlement-v1.expected.json
  stripe-balance-v1.csv
  stripe-balance-v1.expected.json
  bank-statement-id-v1.csv
  bank-statement-id-v1.expected.json

Parser harus versioned.

Kalau provider mengubah format file, test harus gagal sebelum production matching rusak.

13. Settlement Tests

Settlement test harus membuktikan batch reproducible.

Property:

Given same ledger watermark and same merchant policy versions,
settlement result must be identical.

Test:

@Test
void settlementRunIsReproducibleAtSameWatermark() {
    LedgerWatermark watermark = ledger.currentWatermark();
    SettlementRun first = settlementEngine.calculate(cutoff, watermark);
    SettlementRun second = settlementEngine.calculate(cutoff, watermark);

    assertThat(second.fingerprint()).isEqualTo(first.fingerprint());
}

Test additional:

  • reserve hold reduces payable,
  • refund reduces payable,
  • chargeback reduces payable,
  • negative balance recovered from next settlement,
  • minimum payout threshold carries forward,
  • risk hold blocks payout,
  • invalid bank account blocks payout,
  • high severity reconciliation break blocks settlement,
  • same settlement item cannot create duplicate payout.

14. Operational Safety Tests

Backoffice action harus diuji seperti financial API.

Contoh:

@Test
void makerCannotApproveOwnHighRiskAdjustment() {
    AdjustmentRequest request = backoffice.createAdjustment(
        actor("ops-a"),
        merchantId,
        amount("IDR", 50_000_000),
        reason("reconciliation correction")
    );

    assertThatThrownBy(() -> backoffice.approveAdjustment(actor("ops-a"), request.id()))
        .isInstanceOf(SegregationOfDutiesViolation.class);
}

@Test
void staleApprovalCannotExecuteAfterObjectChanged() {
    AdjustmentRequest request = fixture.pendingAdjustment();
    backoffice.approve(actor("ops-b"), request.id(), request.version());
    backoffice.modify(actor("ops-a"), request.id(), newReason("changed"));

    assertThatThrownBy(() -> backoffice.execute(request.id()))
        .isInstanceOf(StaleApprovalException.class);
}

Operator action tests harus memverifikasi audit event:

  • actor,
  • role,
  • object,
  • before/after,
  • reason,
  • evidence link,
  • approval chain,
  • request id,
  • IP/device/session metadata jika relevan.

15. Integration Tests with Real Dependencies

Mock tidak cukup untuk:

  • PostgreSQL transaction isolation,
  • row lock,
  • unique constraint,
  • partial index,
  • JSONB query,
  • Kafka partition ordering,
  • Redis TTL/atomic operation,
  • outbox relay,
  • inbox dedupe,
  • migration script.

Gunakan real dependencies dalam test environment.

Testcontainers for Java menyediakan lightweight throwaway instances untuk database, message brokers, dan dependency lain dalam JUnit tests.

Contoh structure:

@Testcontainers
class PaymentConcurrencyIntegrationTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");

    @Container
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.7.0")
    );

    @Test
    void duplicateLedgerPostingBlockedByUniqueConstraint() {
        // run against real PostgreSQL unique constraint
    }
}

Tetap hati-hati:

  • integration test tidak boleh terlalu lambat semua,
  • gunakan fixture minimal,
  • gunakan deterministic clock,
  • hindari sleep tidak terkendali,
  • pisahkan test cepat dan slow suite.

16. Time Control Tests

Payment platform bergantung pada waktu:

  • auth expiry,
  • VA expiry,
  • QR expiry,
  • subscription billing cycle,
  • dunning retry,
  • settlement cutoff,
  • payout schedule,
  • dispute deadline,
  • sanctions rescreening,
  • reconciliation file availability,
  • idempotency TTL.

Jangan pakai Instant.now() langsung di domain.

Gunakan Clock.

public final class PaymentExpiryService {
    private final Clock clock;

    public PaymentExpiryService(Clock clock) {
        this.clock = clock;
    }

    public boolean isExpired(PaymentInstruction instruction) {
        return !Instant.now(clock).isBefore(instruction.expiresAt());
    }
}

Test:

@Test
void virtualAccountExpiresAtConfiguredTime() {
    MutableClock clock = MutableClock.at("2026-07-02T10:00:00Z");
    PaymentInstruction va = fixture.virtualAccountExpiresAt("2026-07-02T11:00:00Z");

    assertThat(service.isExpired(va)).isFalse();

    clock.advance(Duration.ofHours(2));

    assertThat(service.isExpired(va)).isTrue();
}

Untuk billing/subscription, Stripe menyediakan test clocks/simulations untuk menguji objek Billing bergerak melewati waktu dan lifecycle webhook.

17. Mutation and Negative Testing

Payment code harus diuji dengan input salah.

Negative tests:

  • amount zero/negative,
  • currency unsupported,
  • minor unit invalid,
  • idempotency key missing,
  • idempotency fingerprint mismatch,
  • duplicate provider reference,
  • invalid webhook signature,
  • expired instruction success webhook,
  • refund before capture,
  • payout without beneficiary verification,
  • settlement without reconciliation gate,
  • manual adjustment without evidence.

Mutation testing berguna untuk domain invariants.

Contoh mutation yang harus terbunuh:

refund <= captured changed to refund < captured
journal debit == credit changed to debit >= credit
state transition allowed for terminal state
idempotency fingerprint check removed
risk hold ignored in payout eligibility

Jika mutation seperti itu lolos, test suite belum menjaga uang.

18. End-to-End Scenario Suite

E2E bukan satu happy path.

Minimal E2E payment scenarios:

18.1 Card Happy Path

create intent -> confirm -> authorize -> capture -> webhook -> ledger -> reconciliation -> settlement -> payout -> merchant statement

18.2 Timeout Then Late Success

confirm -> provider timeout -> internal unknown -> webhook success after delay -> state resolved -> ledger posted once -> no second charge

18.3 Duplicate Client Retry

client confirm timeout -> retries same idempotency key -> same result -> one provider operation if provider idempotency allows, one ledger posting

18.4 Duplicate Webhook

provider sends same success event 3 times -> raw event dedupe -> state unchanged after first -> ledger one journal

18.5 Refund Race

two refund requests concurrently -> total refund never exceeds captured amount

18.6 Reconciliation Break Blocks Settlement

provider report amount mismatch -> high severity break -> settlement hold -> ops resolves -> settlement resumes

18.7 Payout Unknown Outcome

payout request timeout -> status inquiry -> eventual success -> no duplicate payout

19. Chaos and Recovery Tests

Chaos test payment-specific.

Generic chaos:

kill service, see if it restarts

Payment chaos:

kill service exactly after provider success but before internal commit
kill outbox relay after marking event claimed
drop webhook consumer after raw persist but before apply
restart ledger projection mid-batch
make provider inquiry return stale state
delay Kafka partition for settlement events
corrupt one reconciliation file row
expire operator approval mid-execution

Recovery expected:

  • no duplicate money movement,
  • unknown workflow opens case if needed,
  • outbox/inbox replay catches up,
  • ledger remains balanced,
  • projection rebuild produces same result,
  • settlement batch blocks if evidence incomplete.

Chaos without invariant assertion is theater.

20. Replay-Based Regression

Every production incident should create a regression replay.

Incident artifact:

incident-2026-07-02-provider-timeout-late-webhook/
  raw-requests.jsonl
  provider-responses.jsonl
  webhooks.jsonl
  ledger-before.json
  expected-ledger-after.json
  expected-payment-state.json
  notes.md

Replay test:

@Test
void incident20260702ProviderTimeoutLateWebhookDoesNotDoubleCharge() {
    ReplayScenario scenario = ReplayScenario.load("incident-2026-07-02-provider-timeout-late-webhook");

    ReplayResult result = replayEngine.run(scenario);

    assertThat(result.paymentState()).isEqualTo(PaymentState.CAPTURED);
    assertThat(result.providerChargeCount()).isEqualTo(1);
    assertThat(result.ledgerJournals()).allMatch(Journal::isBalanced);
    assertThat(result.duplicateFinancialMovements()).isZero();
}

Setiap serious bug harus menjadi permanent test.

21. Test Data Strategy

Payment test data harus realistis tapi aman.

Jangan pakai data produksi mentah.

Gunakan:

  • synthetic PAN/test card numbers dari provider sandbox,
  • synthetic customer identity,
  • synthetic merchant profile,
  • anonymized/tokenized production-like distribution,
  • generated ledger sequences,
  • generated bank statement narratives,
  • golden provider files.

Data dimensions:

DimensionExamples
currencyIDR, USD, JPY, zero-decimal currency
amountsmall, large, boundary, fractional pre-minor-unit
merchantlow risk, high risk, held, negative balance
methodcard, VA, QR, wallet, payout
providersuccess, decline, timeout, duplicate webhook
timecutoff boundary, weekend, holiday, DST if applicable
statepending, unknown, captured, refunded, disputed

22. CI/CD Test Gates

Tidak semua test jalan di setiap commit.

Suggested gates:

gates:
  pull_request_fast:
    - value_object_tests
    - state_machine_tests
    - posting_rule_tests
    - api_schema_compatibility
    - selected_property_tests

  pull_request_integration:
    - postgres_constraint_tests
    - outbox_inbox_tests
    - adapter_contract_tests
    - webhook_signature_tests

  nightly:
    - high_volume_property_tests
    - concurrency_stress_tests
    - reconciliation_golden_file_suite
    - settlement_reproducibility_suite
    - provider_simulator_matrix

  pre_release:
    - e2e_payment_lifecycle
    - chaos_recovery_suite
    - migration_backfill_tests
    - performance_regression_suite
    - security_negative_tests

Gate harus punya owner.

Kalau nightly gagal terus dan tidak ada yang peduli, test itu bukan safety net.

23. Test Observability

Test environment juga butuh observability.

Setiap E2E/chaos test harus mengeluarkan:

  • trace id,
  • payment id,
  • provider operation id,
  • webhook event id,
  • journal id,
  • outbox event id,
  • settlement run id,
  • reconciliation run id.

Saat test gagal, engineer harus bisa membuka timeline:

payment created
attempt started
provider timeout
unknown state recorded
webhook received
webhook applied
ledger posted
outbox published
projection updated
settlement held/released

Tanpa timeline, debugging test payment akan lambat.

24. Release Evidence Pack

Untuk major release payment platform, buat release evidence pack.

Isi:

release-evidence/
  api-contract-diff.md
  database-migration-report.md
  ledger-invariant-report.json
  reconciliation-golden-report.json
  settlement-reproducibility-report.json
  provider-simulator-matrix.json
  concurrency-test-summary.json
  performance-regression-summary.json
  security-negative-test-summary.json
  known-risk-and-mitigations.md

Ini bukan birokrasi.

Ini cara membuat perubahan payment defensible.

25. Anti-Patterns

25.1 Test Hanya Happy Path Provider Sandbox

Provider sandbox bukan bukti cukup.

Sandbox tidak selalu bisa menghasilkan failure yang kamu butuhkan.

25.2 Mock Ledger

Mock ledger bisa membuat test lulus walaupun journal tidak balanced.

25.3 Tidak Menguji Duplicate

Di payment, duplicate bukan edge case.

Duplicate adalah normal case.

25.4 Tidak Menguji Out-of-Order

Webhook/event async tidak punya jaminan datang sesuai keinginan domain.

25.5 Menyamakan Decline dengan Error

Issuer decline bukan platform error.

Test harus membedakan business decline vs technical failure.

25.6 Menghapus Test Incident Lama

Incident regression adalah aset.

Jangan hapus hanya karena lambat tanpa mengganti safety-nya.

25.7 Test Tanpa Financial Assertion

Assertion seperti ini tidak cukup:

assertThat(response.status()).isEqualTo(200);

Tambahkan:

assertThat(provider.chargeCount(paymentId)).isEqualTo(1);
assertThat(ledger.journalsFor(paymentId)).allMatch(Journal::isBalanced);
assertThat(balanceProjection.rebuild()).isEqualTo(balanceProjection.current());

26. Minimal Test Suite Blueprint

Kalau membangun dari nol, mulai dengan suite ini:

01-money-value-object-tests
02-payment-state-machine-tests
03-idempotency-tests
04-ledger-posting-rule-tests
05-ledger-property-tests
06-provider-adapter-contract-tests
07-webhook-ingestion-tests
08-outbox-inbox-integration-tests
09-concurrency-tests
10-provider-simulator-e2e-tests
11-reconciliation-golden-file-tests
12-settlement-reproducibility-tests
13-payout-unknown-outcome-tests
14-backoffice-safety-tests
15-chaos-recovery-smoke-tests

Urutan ini efektif karena mulai dari invariant lokal lalu naik ke lifecycle.

27. References

28. Closing Thought

Payment test suite harus membuat kesalahan finansial sulit lolos.

Bukan tidak mungkin.

Tapi sulit.

Test yang baik tidak hanya menjawab:

Does the code work?

Ia menjawab:

When the world behaves badly, can this platform still explain every unit of money?

Itulah standar testing untuk payment system production-grade.

Lesson Recap

You just completed lesson 58 in final stretch. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.