DEV Community

Cover image for Testing Payment Gateways in Flutter Without Real Money
Gulshan Yadav
Gulshan Yadav

Posted on • Originally published at misar.blog

Testing Payment Gateways in Flutter Without Real Money

So, in this article, I will be showing you how you can test payment gateways in your Flutter app without spending a single rupee — or dollar, or euro. Payment testing is the most anxiety-inducing part of building a checkout, and it should not be: every serious gateway ships a sandbox, and every Flutter project should ship a fake payment client for unit tests. Combine the two and you can test your entire payment flow — button tap, sheet, result handling, error paths — with zero real money involved.

In my first payment integration, I tested with a real card in production mode. Never again. What I should have done from day one is this layered approach: sandbox modes for end-to-end flow, a fake client for unit and widget tests, and mocked HTTP for parsing tests. This article is that approach, written down so you do not repeat my mistake.

Let's jump into the coding part.

Strategy 1: Use Each Gateway's Sandbox Mode

Every major gateway has a test environment, and they all work the same way: real API calls, real flows, no real charges.

Gateway Sandbox Test card
PayPal Sandbox API (api-m.sandbox.paypal.com) + test business/personal accounts N/A — sandbox accounts
Stripe Test mode key (sk_test_...) 4242 4242 4242 4242
Razorpay Test mode key 4111 1111 1111 1111
Google Pay Environment.test in google_pay.json Any card in TEST
Apple Pay Sandbox card in iOS Wallet settings 4242 4242 4242 4242

The golden rule: the sandbox uses your test API keys, never your live keys. Guard against the live key accidentally leaking into a test build — it is the single most common payment-testing disaster, and it is how people discover they charged a real card in a "test."

Strategy 2: The Fake Payment Client (The Core Pattern)

The sandbox covers end-to-end flow, but it is slow, it is external, and it does not let you script failure. For unit and widget tests, inject a fake payment client behind an interface. First, define the abstraction your UI depends on:

abstract class PaymentService {
  Future<PaymentResult> pay({required String itemId, required String amount});
}

class PaymentResult {
  final bool success;
  final String? token;
  final String? error;
  const PaymentResult.success(this.token) : success = true, error = null;
  const PaymentResult.failure(this.error) : success = false, token = null;
  const PaymentResult.cancelled() : success = false, token = null, error = null;
}
Enter fullscreen mode Exit fullscreen mode

The real implementation calls the gateway SDK (or your backend), while a fake implementation you control entirely:

class FakePaymentService implements PaymentService {
  final bool shouldFail;
  const FakePaymentService({this.shouldFail = false});

  @override
  Future<PaymentResult> pay({required String itemId, required String amount}) async {
    if (shouldFail) return const PaymentResult.failure('card_declined');
    return PaymentResult.success('tok_fake_12345');
  }
}
Enter fullscreen mode Exit fullscreen mode

Now your widget receives the PaymentService via constructor injection, and your tests swap in the fake:

class CheckoutPage extends StatelessWidget {
  final PaymentService paymentService;
  const CheckoutPage({super.key, required this.paymentService});

  Future<void> _pay(BuildContext context) async {
    final result = await paymentService.pay(itemId: 'premium', amount: '9.99');
    if (!context.mounted) return;
    if (result.success) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Payment successful')),
      );
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(result.error ?? 'Payment failed')),
      );
    }
  }
  // build() ...
}
Enter fullscreen mode Exit fullscreen mode

In your widget test:

testWidgets('shows success on payment', (tester) async {
  final service = const FakePaymentService();
  await tester.pumpWidget(MaterialApp(home: CheckoutPage(paymentService: service)));
  await tester.tap(find.text('Pay'));
  await tester.pump();
  expect(find.text('Payment successful'), findsOneWidget);
});

testWidgets('shows error when payment fails', (tester) async {
  final service = const FakePaymentService(shouldFail: true);
  // ... assert error snackbar appears
});
Enter fullscreen mode Exit fullscreen mode

This tests the UI and its error handling — the states that matter to users — without a network call, without a card, and instantly.

The Real Implementation (For Comparison)

Here is what the real implementation looks like against a fake, so you can see the interface in action. It calls your backend, which talks to the gateway — this is the production default because the app should never hold the gateway secret:

class ApiPaymentService implements PaymentService {
  final http.Client _client;
  final String _baseUrl;
  const ApiPaymentService(this._client, this._baseUrl);

  @override
  Future<PaymentResult> pay({required String itemId, required String amount}) async {
    try {
      final res = await _client.post(
        Uri.parse('$_baseUrl/api/create-payment-intent'),
        body: {'itemId': itemId, 'amount': amount},
      ).timeout(const Duration(seconds: 15));
      if (res.statusCode == 200) {
        final token = jsonDecode(res.body)['clientSecret'] as String;
        return PaymentResult.success(token);
      }
      return PaymentResult.failure('payment_failed_${res.statusCode}');
    } on TimeoutException {
      return const PaymentResult.failure('network_timeout');
    } on SocketException {
      return const PaymentResult.failure('no_network');
    } catch (e) {
      return PaymentResult.failure('unexpected_error');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice what the interface buys you: the widget code never knows whether it is talking to FakePaymentService or ApiPaymentService. That is the whole point — your UI tests run against the fake, your integration tests run against the sandbox, and neither ever touches real money.

The Full Test Pyramid for Payments

It helps to see where each technique sits, from fast and cheap to slow and real:

            ┌───────────────────────────────┐
            │  Sandbox E2E (device, manual) │  ← Strategy 5
            ├───────────────────────────────┤
            │  Webhook replay (backend)     │  ← Strategy 4
            ├───────────────────────────────┤
            │  MockClient parsing tests     │  ← Strategy 3
            ├───────────────────────────────┤
            │  Fake payment service (unit)  │  ← Strategy 2
            ├───────────────────────────────┤
            │  Sandbox config (per gateway) │  ← Strategy 1
            └───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

You want all five layers in place, but you do not run them on every push. The fake and mock layers run in CI on every commit, in milliseconds. The sandbox E2E runs once before release. The webhook replay runs whenever your backend's fulfillment logic changes. Test your own code as fast as possible; test the gateway's integration as rarely as possible without skipping it.

Strategy 3: Mock the HTTP Layer for Parsing Tests

If your payment flow calls a backend (which it should — the gateway token should never be handled on-device alone), test how you parse the backend's response. The http package ships a MockClient for exactly this:

import 'package:http/testing.dart';
import 'package:http/http.dart' as http;

final mock = MockClient((request) async {
  if (request.url.path == '/api/verify-payment') {
    return http.Response('{"status": "success"}', 200,
        headers: {'content-type': 'application/json'});
  }
  return http.Response('{"status": "error", "error": "card_declined"}', 402);
});

final service = PaymentService(httpClient: mock); // inject the client
Enter fullscreen mode Exit fullscreen mode

Now you can assert that a 200 with status: success produces the success path, and a 402 produces a friendly error — no sandbox, no backend process, no test-card timing.

Strategy 4: Webhook Replay on the Backend

Payment verification should live on your backend, and the backend's confirmation should come from the gateway's webhook, not the app. To test this, replay the gateway's webhook payloads locally — Stripe's CLI, PayPal's sandbox webhook simulator, and Razorpay's test webhooks all let you fire synthetic events. This confirms your order-fulfillment logic (the part that actually matters) without a real transaction.

Strategy 5: Sandbox E2E on Device

Finally, run one full end-to-end pass in each gateway's sandbox on a real device — the flow where the user taps, the sheet opens, the sandbox card is used, and the success state renders. This catches integration issues the fakes cannot: entitlements, plugin configuration, and payment-sheet wiring. It costs you nothing but time.

Scripting the Scenarios That Actually Break

The fake is only as good as the scenarios it can produce. Build your fake around the states your gateway will really return, and script each one explicitly:

Scenario Fake behavior Assert in the UI
Payment succeeds shouldFail: false Success state, token forwarded to backend
Card declined shouldFail: true (declined) Friendly "card declined" message, retry allowed
Insufficient funds distinct error code Specific message, no retry spinner stuck
Network timeout throws TimeoutException "Check your connection", safe retry
User cancels returns cancelled Return to cart, no error screen
Malformed server response 500 + garbage body Generic error, backend notified

The detail that makes this valuable: each scenario must leave the UI in the right state — a retry possible, a spinner cleared, a cart preserved. Those states are the difference between a payment flow users trust and one they abandon at the first hiccup. Because the fake makes every scenario deterministic and instant, you can cover all six without ever waiting on a gateway.

How This Pattern Scales to Real Projects

This is not a toy pattern. On the projects where I have shipped checkout flows, the same three components — an interface, a fake, and a mock client — carried every gateway we supported: Stripe, PayPal, Razorpay, Google Pay, Apple Pay. Each gateway got its own real implementation behind the shared PaymentService interface, one fake shared by all of them, and one widget test suite that ran against the fake in under ten seconds. When a new gateway came in, the tests came for free, because the behavior contract was already defined.

That is the real argument for the interface: not fewer lines of code, but one test suite that validates every gateway's UI behavior without a single sandbox call. The sandbox still runs before release, but the daily regression safety net runs in CI on every commit, and it costs nothing.

Important Notes & Pitfalls

  1. Live keys in tests is the worst bug in this domain. Keep live keys in server-side environment variables, never in the Flutter app at all. Test builds should be physically unable to reach a live gateway.
  2. Sandbox cards are shared and rate-limited. The same test card works for everyone; gateways throttle sandbox abuse. Spread tests out, and never loop against the sandbox.
  3. The fake must be deterministic. Make the fake return the same result for the same input, or your tests flake. Script your failure states explicitly.
  4. Test the failure paths, not just the happy path. Card declined, network timeout, user cancellation, malformed server response. These are the paths real users hit, and the paths that break silently.
  5. Clear state between tests. Sandbox environments keep state across runs; reset orders and webhooks so tests do not depend on each other.
  6. Assert on behavior, not implementation. Test that the success snackbar appears, not that a specific internal method was called. Behavior tests survive refactors.

Testing Checklist

Before you call a payment integration done, check every box:

  • [ ] Unit tests for the payment service using the fake client (success, failure, cancel)
  • [ ] Widget tests assert the UI reacts to each payment result
  • [ ] Parsing tests use MockClient for backend responses
  • [ ] Webhook replay verifies order fulfillment on the backend
  • [ ] One sandbox E2E pass per gateway on a real device
  • [ ] No live keys reachable from any test or test build
  • [ ] Every failure path (declined, timeout, cancel) is covered and user-visible

FAQ

Is testing with a fake client enough? No — it covers the app's logic, but not the gateway integration itself. Combine it with a sandbox E2E pass.

Can I run these tests in CI? Yes. Fakes and MockClient run headlessly in CI with no network. The sandbox E2E pass stays manual (it needs a device).

Do fakes replace sandbox testing? No. They are complementary: fakes test your code, sandbox tests the gateway's integration with your code.

How do I test a webhook in local development? Use your gateway's local tunneling and replay tooling — Stripe's CLI, PayPal's webhook simulator, or Razorpay's test webhook endpoints — to fire synthetic events at a local server. This verifies your fulfillment logic end to end without a transaction.

What about recurring payments? Use the same layers, but remember sandboxes handle subscriptions too. Create a test subscription with a sandbox card, trigger renewal webhooks, and cancel it in the sandbox dashboard. The fake-payment pattern still applies — just with a subscribe() method on the same interface.

That's it — five layers of payment testing that never touch real money. The fake-payment pattern alone has saved me more CI headaches than any other testing decision in Flutter.

I have also written integrations for PayPal, Stripe, and UPI/Razorpay — comment below with your payment gateway and I'll cover it next.


*Gulshan Yad

Top comments (0)