Testing Payment Gateways in Flutter Without Real Money
DEV Community

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 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; } 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 pay({required String itemId, required String amount}) async { if (shouldFail) return const PaymentResult.failure('card_declined'); return PaymentResult.success('tok_fake_12345'); } } 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 _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() ... } 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 }); 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 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'); } } } 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 โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 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 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

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.