Bonaventure OgetoBy Bonaventure Ogeto|

End to End Payment Tests with Playwright

In Playwright: navigate to your checkout page, click the pay button to open the Paystack Inline popup, wait for the iframe to load, switch context to the iframe, fill in the test card number (4084084084084081), CVV (408), expiry, and PIN, submit, then assert your success state. Use page.frameLocator() to interact with the Paystack iframe. Run against your Paystack test mode deployment.

Playwright Test for Paystack Inline Checkout

// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';

test('complete payment with Paystack test card', async ({ page }) => {
  // Navigate to the checkout page
  await page.goto('http://localhost:3000/checkout?product=1');

  // Click the pay button to open Paystack popup
  await page.click('button[data-testid="pay-button"]');

  // Wait for Paystack iframe to appear
  const paystackFrame = page.frameLocator('iframe[name="checkout"]');
  await paystackFrame.locator('input[placeholder="Card Number"]').waitFor({ timeout: 10000 });

  // Fill in test card details
  await paystackFrame.locator('input[placeholder="Card Number"]').fill('4084 0840 8408 4081');
  await paystackFrame.locator('input[placeholder="MM/YY"]').fill('05/30');
  await paystackFrame.locator('input[placeholder="CVV"]').fill('408');

  // Click Pay button inside the popup
  await paystackFrame.locator('button', { hasText: 'Pay' }).click();

  // Enter PIN if prompted
  const pinInput = paystackFrame.locator('input[placeholder="Enter PIN"]');
  if (await pinInput.isVisible({ timeout: 3000 }).catch(() => false)) {
    await pinInput.fill('0000');
    await paystackFrame.locator('button', { hasText: 'Confirm' }).click();
  }

  // Wait for redirect to success page
  await page.waitForURL('**/payment/success**', { timeout: 30000 });
  await expect(page.locator('h1')).toHaveText('Payment Successful');
});

test('shows error message on declined card', async ({ page }) => {
  await page.goto('http://localhost:3000/checkout?product=1');
  await page.click('button[data-testid="pay-button"]');

  const paystackFrame = page.frameLocator('iframe[name="checkout"]');
  await paystackFrame.locator('input[placeholder="Card Number"]').waitFor({ timeout: 10000 });

  // Use decline test card
  await paystackFrame.locator('input[placeholder="Card Number"]').fill('4084 0800 0000 0409');
  await paystackFrame.locator('input[placeholder="MM/YY"]').fill('05/30');
  await paystackFrame.locator('input[placeholder="CVV"]').fill('408');
  await paystackFrame.locator('button', { hasText: 'Pay' }).click();

  // Expect decline message in the popup
  await expect(paystackFrame.locator('text=Do Not Honor')).toBeVisible({ timeout: 10000 });
});

Learn More

Key Takeaways

  • Playwright can interact with the Paystack Inline iframe using page.frameLocator() — the iframe is cross-origin but Playwright handles this.
  • Always use test mode (sk_test / pk_test keys) and Paystack test cards in E2E tests — never run E2E tests against a live account.
  • Add sufficient waitForSelector timeouts — Paystack's popup is loaded asynchronously and may take 1-3 seconds to render.
  • After payment, verify the post-payment redirect URL and the database state — not just the URL change.
  • Avoid brittle selectors: prefer Paystack's stable input placeholders ("Card number", "CVV") over internal class names that may change.
  • Run E2E tests in CI with a headless browser using playwright install in your CI setup step.

Frequently Asked Questions

Why can't Playwright fill the card number field in Paystack's popup?
The Paystack popup is loaded inside an iframe. You need to use page.frameLocator() to switch context into the iframe before interacting with inputs. If frameLocator does not work, check that you are targeting the correct iframe using its name or src attribute — open Playwright's trace viewer to see the DOM structure.
How do I identify the correct frameLocator selector for the Paystack iframe?
Inspect the page with browser dev tools while the Paystack popup is open. Find the iframe element and check its name, id, or src attribute. Common selectors: iframe[name="checkout"], iframe[src*="paystack.com"]. Use whichever is stable — avoid index-based selectors.
Should Playwright E2E tests run on every PR or only on main branch merges?
E2E tests are slow — typically 30-120 seconds per test. Run them on PRs targeting main, but not on every feature branch push. A common pattern: unit and integration tests on every push, E2E tests only on PR to main or on nightly schedules.
How do I handle Paystack 3DS popups or redirect pages in Playwright?
For Paystack test mode 3DS (card 507850785078507812), a PIN or OTP input appears inside the Paystack iframe. Wait for it conditionally (check if visible before filling) as not all cards trigger 3DS. For external 3DS redirects, wait for navigation with page.waitForNavigation() then fill the OTP on the bank's page.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon