Answer
Scenario: Testing Third-Party Payment Gateway Redirects
The Challenge
Payment gateways like Stripe, PayPal, Razorpay redirect users to a third-party domain. Your test needs to handle:
- ✓The redirect away from your app
- ✓Filling payment details on the external page
- ✓The redirect back to your confirmation page
Approach 1 — Mock the Payment Gateway (Recommended for CI)
TypeScript
import { test, expect } from '@playwright/test';
test('checkout completes with mocked payment', async ({ page }) => {
// Intercept the payment initiation API
await page.route('/api/checkout/initiate', route =>
route.fulfill({
status: 200,
json: {
paymentId: 'test-payment-123',
redirectUrl: 'http://localhost:3000/payment-mock',
},
})
);
// Intercept payment verification
await page.route('/api/checkout/verify', route =>
route.fulfill({
status: 200,
json: { status: 'success', orderId: 'ORD-2024-001' },
})
);
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay $99.99' }).click();
// Simulate redirect back after payment
await page.goto('http://localhost:3000/payment-success?payment_id=test-payment-123&status=success');
await expect(page).toHaveURL(/order-confirmed/);
await expect(page.getByRole('heading')).toContainText('Order Confirmed');
});
Approach 2 — Stripe Sandbox with Test Cards
TypeScript
test('Stripe payment with test card', async ({ page }) => {
await page.goto('/checkout');
await page.getByRole('button', { name: 'Proceed to Payment' }).click();
// Handle Stripe redirect (opens Stripe-hosted checkout)
const [stripePage] = await Promise.all([
page.context().waitForEvent('page'),
page.getByRole('link', { name: 'Pay with Stripe' }).click(),
]);
await stripePage.waitForLoadState('domcontentloaded');
// Fill Stripe test card details
const cardFrame = stripePage.frameLocator('[name="card-number-element"]');
await cardFrame.locator('input').fill('4242424242424242');
const expiryFrame = stripePage.frameLocator('[name="card-expiry-element"]');
await expiryFrame.locator('input').fill('1226');
const cvcFrame = stripePage.frameLocator('[name="card-cvc-element"]');
await cvcFrame.locator('input').fill('123');
await stripePage.getByRole('button', { name: 'Pay' }).click();
// Wait for redirect back to your app
await page.waitForURL('**/order-confirmed');
await expect(page.getByRole('heading')).toContainText('Order Confirmed');
});
Approach 3 — Verify Payment Initiation (Without Gateway)
TypeScript
test('checkout initiates payment with correct data', async ({ page }) => {
// Capture the payment initiation request
const [request] = await Promise.all([
page.waitForRequest(req =>
req.url().includes('/api/checkout/initiate') && req.method() === 'POST'
),
page.getByRole('button', { name: 'Pay $99.99' }).click(),
]);
// Verify correct amount sent to gateway
const body = JSON.parse(request.postData()!);
expect(body.amount).toBe(9999); // In cents
expect(body.currency).toBe('USD');
expect(body.orderId).toBeTruthy();
});
Approach 4 — Block Gateway and Test Callback URL
TypeScript
test('payment failure handling', async ({ page }) => {
// Block gateway and directly test the callback
await page.route('**/payment-gateway.com/**', route => route.abort());
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay' }).click();
// Simulate a failed payment callback
await page.goto('/payment-failed?error=card_declined&order_id=ORD-001');
await expect(page.getByRole('heading')).toContainText('Payment Failed');
await expect(page.getByText('Card was declined')).toBeVisible();
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();
});
Best Practice for Payment Testing
- ✓Use provider test modes — Stripe/PayPal/Razorpay all have sandbox environments
- ✓Mock for unit/regression — fast, reliable, no network dependency
- ✓Sandbox E2E — test the full flow in staging with sandbox credentials
- ✓Never test with real cards — even in staging
- ✓Store gateway keys in CI secrets — never in code
