Back to Test Case Writing
Q5Question 5 of 5

Writing payment gateway test cases focused on financial correctness and reliability

Key Concepts
  • Payment testing prioritizes money correctness and reliability over cosmetics because a mistake moves real money
  • The success path confirms debit, credit, transaction record, and confirmed status with exact amounts using BigDecimal
  • Decline cases: insufficient balance, invalid or expired card, and wrong CVV each must leave balances completely untouched
  • The most critical case is payment-succeeds-but-confirmation-fails, which must never result in a double charge
  • Idempotency keys make retries safe by ensuring the same logical payment is charged exactly once regardless of resends
  • Refund tests verify that total refunded never exceeds the captured amount and that refunds are themselves idempotent

Definition

Payment testing prioritizes money correctness and reliability, declines, timeouts, duplicates, refunds, currency, and especially payment-succeeds-but-confirmation-fails, and idempotency keys matter more than UI validation because retries are routine and only idempotency stops a double charge.
  • Financial correctness – Every payment must debit and credit the exact amounts, record a transaction reference, and leave balances untouched on decline.
  • Reliability failures – Timeouts, network drops, callback failures, and browser closures mid-payment must never leave money in limbo.
  • Idempotency – A unique key on every payment request ensures that retries produce the original result, not a second charge.
  • Refunds – Full and partial refunds must be capped so total refunded never exceeds the captured amount, and refunds must be idempotent.
  • Payment-succeeds-but-confirmation-fails – The single most dangerous case: the gateway charged the customer but the merchant never heard, so the system must reconcile, not retry blindly.

When Priya pays fifty thousand rupees through the National Bank of India gateway, the tester cares less about whether the form looks nice and far more about money being exactly right. What if her card is expired, what if the network drops right after the bank charged her but before the shop heard back, what if she double-clicks pay, what if she closes the browser halfway? The scariest case is: the bank really took her money, but the message confirming it got lost, so the shop thinks she did not pay. If the system is not careful, she gets charged twice or the shop ships nothing. The safeguard is an idempotency key, a unique ticket on the request so that even if it is sent five times, the money moves exactly once.

Understanding the Concepts

What is the success path for a payment?

The success path confirms a valid payment debits the payer, credits the merchant, records a transaction with a unique reference, and returns a confirmed status. This is the baseline against which all failure cases are compared.

JAVA
// PaymentSuccessTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class PaymentSuccessTest {

    private final PaymentService paymentService = new PaymentService();

    @Test
    void successfulPaymentDebitsAndCreditsCorrectly() {
        PaymentRequest request = PaymentRequest.builder()
            .idempotencyKey("PAY-2024-001")
            .payerAccount("ACC-PRIYA-001")
            .merchantAccount("MERCHANT-NBI-001")
            .amount(new BigDecimal("50000.00"))
            .currency("INR")
            .build();

        PaymentResult result = paymentService.processPayment(request);

        assertEquals("CAPTURED", result.getStatus());
        assertNotNull(result.getTransactionId());
        assertEquals(new BigDecimal("50000.00"), result.getAmount());
        // Verify balances
        assertEquals(new BigDecimal("450000.00"),
            accountService.getBalance("ACC-PRIYA-001"));
        assertEquals(new BigDecimal("50000.00"),
            accountService.getBalance("MERCHANT-NBI-001"));
    }
}

What decline cases should you test?

Decline cases each produce a specific, safe outcome. The invariant is that a declined payment leaves balances completely untouched.

JAVA
// PaymentDeclineTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class PaymentDeclineTest {

    @Test
    void insufficientBalanceRejectsWithoutPartialDebit() {
        BigDecimal originalBalance = accountService.getBalance("ACC-PRIYA-001");
        PaymentRequest request = PaymentRequest.builder()
            .amount(new BigDecimal("9999999.00"))
            .payerAccount("ACC-PRIYA-001")
            .build();

        PaymentResult result = paymentService.processPayment(request);

        assertEquals("DECLINED", result.getStatus());
        assertEquals("INSUFFICIENT_FUNDS", result.getErrorCode());
        // Balance must be completely untouched
        assertEquals(originalBalance,
            accountService.getBalance("ACC-PRIYA-001"));
    }

    @ParameterizedTest
    @CsvSource({
        "4111111111111111, 12/20, EXPIRED_CARD",
        "0000000000000000, 12/30, INVALID_CARD",
    })
    void invalidCardIsRejected(String cardNumber, String expiry,
                                String expectedError) {
        PaymentResult result = paymentService.processPayment(
            PaymentRequest.builder()
                .cardNumber(cardNumber)
                .expiry(expiry)
                .amount(new BigDecimal("1000.00"))
                .build()
        );
        assertEquals("DECLINED", result.getStatus());
        assertEquals(expectedError, result.getErrorCode());
    }
}

What happens when a payment times out?

A payment timeout means the gateway did not respond in time, so the merchant does not know the outcome. The correct behavior is to treat the result as unknown and reconcile, never assuming failure, because the charge may have gone through.

JAVA
// PaymentTimeoutTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class PaymentTimeoutTest {

    @Test
    void timeoutCreatesUnknownStatusForReconciliation() {
        // Simulate gateway timeout
        gatewayMock.setResponseDelay(30_000); // 30 seconds

        PaymentRequest request = PaymentRequest.builder()
            .idempotencyKey("PAY-TIMEOUT-001")
            .amount(new java.math.BigDecimal("5000.00"))
            .build();

        PaymentResult result = paymentService.processPayment(request);

        // Must NOT assume failure
        assertEquals("UNKNOWN", result.getStatus());
        // Must be queued for reconciliation
        assertTrue(reconciliationQueue.contains("PAY-TIMEOUT-001"));
    }
}

Why does idempotency matter more than UI validation?

Idempotency and duplicate-transaction handling matter more than UI validation because of risk asymmetry. A UI validation bug produces an annoying but recoverable user error. A duplicate-charge bug moves real money wrongly, triggers chargebacks, regulatory exposure, and loss of trust.

JAVA
// IdempotencyTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class IdempotencyTest {

    @Test
    void sameIdempotencyKeyProducesSingleCharge() {
        String key = "PAY-IDEMPOTENT-001";
        PaymentRequest request = PaymentRequest.builder()
            .idempotencyKey(key)
            .amount(new BigDecimal("10000.00"))
            .payerAccount("ACC-PRIYA-001")
            .build();

        PaymentResult first = paymentService.processPayment(request);
        PaymentResult second = paymentService.processPayment(request);
        PaymentResult third = paymentService.processPayment(request);

        // All three return the same transaction ID
        assertEquals(first.getTransactionId(), second.getTransactionId());
        assertEquals(first.getTransactionId(), third.getTransactionId());
        // Balance debited only once
        assertEquals(new BigDecimal("490000.00"),
            accountService.getBalance("ACC-PRIYA-001"));
    }
}

What is the payment-succeeds-but-confirmation-fails case?

This is the single most critical case. The gateway charged the customer, but the success response or webhook never reached the merchant. If the merchant naively retries, the customer is billed twice. If it marks the order failed, the customer paid for nothing.

JAVA
// ConfirmationFailureTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class ConfirmationFailureTest {

    @Test
    void lostConfirmationDoesNotDoubleCharge() {
        String key = "PAY-LOST-CONFIRM-001";

        // First call succeeds at gateway but confirmation is lost
        gatewayMock.setConfirmationLost(true);
        PaymentResult first = paymentService.processPayment(
            PaymentRequest.builder()
                .idempotencyKey(key)
                .amount(new BigDecimal("25000.00"))
                .build()
        );
        assertEquals("UNKNOWN", first.getStatus());

        // Retry with same idempotency key
        gatewayMock.setConfirmationLost(false);
        PaymentResult retry = paymentService.processPayment(
            PaymentRequest.builder()
                .idempotencyKey(key)
                .amount(new BigDecimal("25000.00"))
                .build()
        );

        // Must return the original capture, not a new charge
        assertEquals("CAPTURED", retry.getStatus());
        // Balance debited only once
        assertEquals(new BigDecimal("475000.00"),
            accountService.getBalance("ACC-PRIYA-001"));
    }
}

What refund cases should you test?

Refund tests verify full refunds, partial refunds, and the invariant that total refunded never exceeds the captured amount. Refunds must also be idempotent.

JAVA
// RefundTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

public class RefundTest {

    @Test
    void partialRefundReversesCorrectAmount() {
        String txnId = createSuccessfulPayment(new BigDecimal("50000.00"));

        RefundResult refund = paymentService.refund(txnId,
            new BigDecimal("20000.00"));

        assertEquals("REFUNDED", refund.getStatus());
        assertEquals(new BigDecimal("20000.00"), refund.getRefundedAmount());
        assertEquals(new BigDecimal("30000.00"), refund.getRemainingAmount());
    }

    @Test
    void refundExceedingCapturedAmountIsRejected() {
        String txnId = createSuccessfulPayment(new BigDecimal("10000.00"));

        assertThrows(RefundExceedsCaptureException.class, () ->
            paymentService.refund(txnId, new BigDecimal("15000.00"))
        );
    }

    @Test
    void duplicateRefundWithSameKeyIsIdempotent() {
        String txnId = createSuccessfulPayment(new BigDecimal("10000.00"));
        String refundKey = "REFUND-001";

        RefundResult first = paymentService.refund(txnId,
            new BigDecimal("5000.00"), refundKey);
        RefundResult second = paymentService.refund(txnId,
            new BigDecimal("5000.00"), refundKey);

        assertEquals(first.getRefundId(), second.getRefundId());
    }
}

What happens when the user closes the browser mid-payment?

The user closing the browser mid-payment must not leave money in limbo. The server-side transaction state is the source of truth, not the browser.

JAVA
// BrowserCloseTest.java
package com.bank.payment;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class BrowserCloseTest {

    @Test
    void serverStateIsAuthoritativeWhenBrowserCloses() {
        String key = "PAY-BROWSER-CLOSE-001";

        // Payment initiated, browser closes before redirect
        PaymentRequest request = PaymentRequest.builder()
            .idempotencyKey(key)
            .amount(new java.math.BigDecimal("15000.00"))
            .build();

        paymentService.initiatePayment(request);
        // Simulate: browser closes, no callback received

        // Status check must reflect true gateway state
        PaymentStatus status = paymentService.checkStatus(key);
        assertNotNull(status);
        // If gateway captured, the order should be fulfilled
        // If gateway did not capture, the order is cancelled
        assertTrue(
            status.getStatus().equals("CAPTURED")
            || status.getStatus().equals("CANCELLED")
        );
    }
}

What are common interview follow-up questions?

  • "Why not just validate the payment form UI?" Because a correct form can still double-charge, timeout, or lose a confirmation. The money-safety risks are at the API and integration level.
  • "What is the difference between idempotency and deduplication?" Idempotency is enforced by the gateway using the key the client provides. Deduplication may also happen on the merchant side by checking transaction references. Both are needed.
  • "How do you test currency conversion?" Assert that the rate is applied correctly, rounding uses the correct scale with no floating-point error, and both the charged and settled currencies are recorded.

Visual Overview

DIAGRAM
  Payment Test Case Families
  ──────────────────────────

  ┌───────────────────────────────────────────┐
  │  SUCCESS PATH                             │
  │  Debit payer + credit merchant + record   │
  │  Verify exact amounts with BigDecimal     │
  └────────────────────┬──────────────────────┘
                       │
  ┌────────────────────┴──────────────────────┐
  │  DECLINE CASES                            │
  │  Insufficient funds: balance untouched    │
  │  Expired card: rejected before charge     │
  │  Wrong CVV: rejected by processor         │
  └────────────────────┬──────────────────────┘
                       │
  ┌────────────────────┴──────────────────────┐
  │  RELIABILITY CASES                        │
  │  Timeout -> UNKNOWN -> reconcile          │
  │  Network drop -> retry with same key      │
  │  Browser close -> server state is truth   │
  │  Confirmation lost -> idempotent retry    │
  └────────────────────┬──────────────────────┘
                       │
  ┌────────────────────┴──────────────────────┐
  │  IDEMPOTENCY (most critical)              │
  │  Same key sent 3x -> 1 charge             │
  │  Lost confirm + retry -> original result  │
  └────────────────────┬──────────────────────┘
                       │
  ┌────────────────────┴──────────────────────┐
  │  REFUNDS                                  │
  │  Full refund: exact reversal              │
  │  Partial refund: correct remainder        │
  │  Over-refund: rejected                    │
  │  Duplicate refund: idempotent             │
  └───────────────────────────────────────────┘
DIAGRAM
  Payment-Succeeds-but-Confirmation-Fails
  ────────────────────────────────────────

  Client            Gateway            Merchant
    │                  │                   │
    │── Pay request ──>│                   │
    │                  │── Charge card ──> │
    │                  │<── Captured ──────│
    │                  │                   │
    │<── Confirm ──────│   X LOST X        │
    │   (never arrives)│                   │
    │                  │                   │
    │── Retry (same key)─>│                │
    │                  │── Check key ──>   │
    │                  │<── Already done ──│
    │<── Original result──│                │
    │   (no double charge)                 │

Key Takeaway

When I test a payment gateway I keep one principle front of mind: this moves real money, so financial correctness and reliability matter far more than cosmetics. My cases start with the success path, a valid payment debits the payer, credits the merchant, records a transaction with a unique reference, and returns captured, all verified with BigDecimal for exact amounts. Then I cover declines, each with the invariant that a declined payment leaves balances completely untouched.

The reliability cases are where payments differ most from ordinary forms: a timeout where the result is unknown and must be reconciled, a network drop that triggers a retry with the same idempotency key, the user closing the browser where the server state is authoritative, and the single most dangerous case: the payment succeeding at the gateway while the confirmation back to the merchant is lost. Idempotency keys make retries safe by ensuring the gateway recognizes a repeat and returns the original result instead of charging again.

Refund tests verify full and partial reversals, assert that total refunded never exceeds the captured amount, and confirm that refunds themselves are idempotent. The reason idempotency and duplicate handling matter more than UI validation is risk asymmetry: a UI validation bug is annoying but recoverable, while a duplicate-charge bug moves real money wrongly and triggers chargebacks and regulatory exposure.

At the National Bank of India I tested exactly this and caught a case where a timeout caused the client to retry without an idempotency key, which would have double-charged customers on slow network days. I made the idempotency key mandatory and added the reconciliation check.

Remember one sentence: in payment testing, the quiet common failure of charged-but-not-confirmed is the one that actually loses money, and only idempotency keys and reconciliation prevent it.

---

Share
Use ← → arrow keys to navigate