Playwright · Reliable checkout and backoffice tests
The 60-second model#
Playwright Test supplies a runner, typed fixtures, projects, workers, assertions and reports. A Locator describes a query; actions perform actionability checks; web-first assertions retry expected browser state. Isolated contexts do not isolate shared merchant/payment records.
The job lists Selenium, Cypress and Playwright as examples. The code here is a practical Playwright/TypeScript preparation path, not evidence that OMNO uses this exact framework.
Locator decisions#
| Tool | Choose when | Gotcha |
|---|---|---|
| getByRole with accessible name | Button/link/control has semantic identity | Visible text and accessible name can differ |
| getByLabel | Form has associated labels | Placeholder is not equivalent to a label |
| getByText | Assert user-visible content | Exact matching/scoping avoids unrelated text |
| getByTestId | Explicit stable test contract | Test-ID existence does not validate business status |
| Stable CSS ID | Intentionally unique DOM contract | Long positional chains are brittle |
| filter / nested locators | Select the correct payment row then its action | .first() can hide ambiguity |
// Scoped test body with page/expect; hypothetical merchant backoffice.
const row = page.getByRole('row').filter({ hasText: 'pay-owned-123' });
await expect(row).toHaveCount(1);
await row.getByRole('button', { name: 'View details', exact: true }).click();
await expect(page.getByTestId('payment-status')).toHaveText('CAPTURED');
Strict operations need a unique match. Locators re-resolve against the DOM when used; they are not frozen element handles. Choose by actual DOM inspection and user semantics, not an imagined selector.
Complete basic checkout test#
import { test, expect } from '@playwright/test';
test('sandbox checkout displays a captured result', async ({ page }) => {
await page.goto('/sandbox/checkout');
await page.getByLabel('Amount').fill('12.50');
await page.getByLabel('Currency').selectOption('GEL');
await page.getByRole('button', { name: 'Pay', exact: true }).click();
await expect(page.getByTestId('payment-status')).toHaveText('CAPTURED');
await expect(page.getByTestId('payment-amount')).toHaveText('GEL 12.50');
});
Requires a configured baseURL, authenticated isolated merchant/session and a provider-approved sandbox method preconfigured by the fixture. No real customer/card data. It proves the exercised UI path and displayed result; authoritative backend/financial effects need appropriate separate checks.
Actionability, assertion and navigation#
A click typically waits for a unique element that is visible, stable, enabled and receiving events. Filling also requires editability. These checks do not promise PSP processing is complete. page.goto() normally waits for load, not every asynchronous application update. Avoid force unless the test intentionally needs its defined bypass behavior.
// Wrong for eventual UI state: one snapshot followed by a generic assertion.
// expect(await page.getByTestId('payment-status').innerText()).toBe('CAPTURED');
await expect(page.getByTestId('payment-status')).toHaveText('CAPTURED');
await expect(page.getByRole('button', { name: 'Refund' })).toBeEnabled();
await expect(page.getByRole('alert')).toBeVisible();
await expect(page).toHaveURL(/\/payments\//);
Test timeout bounds overall execution; assertion/action/navigation timeouts bound relevant operations. Set budgets from observed legitimate latency and within the test budget. Increasing every timeout does not repair revoked auth or a missed event.
Register the response before triggering payment#
// Scoped test body: capture request identity, then assert its response status.
const paymentResponse = page.waitForResponse(r =>
new URL(r.url()).pathname === '/sandbox/payments' &&
r.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Pay', exact: true }).click();
const response = await paymentResponse;
expect(response.status()).toBe(202);
await expect(page.getByTestId('payment-status')).toHaveText('CAPTURED', { timeout: 15_000 });
Listening after click can miss a fast response. Match method/path and payment/correlation identity when same-path requests overlap. Filtering only for 200 can turn a fast 500 into an opaque timeout; often match identity and assert status afterward. The 202 response still does not prove completion.
Browser mock versus integration#
// UI-only rendering: no real provider/API/DB correctness is proven.
await page.route('**/sandbox/payments/pay-demo', route => route.fulfill({
status: 200, contentType: 'application/json',
body: JSON.stringify({ id: 'pay-demo', status: 'DECLINED', reason: 'Not approved' }),
}));
await page.goto('/payments/pay-demo');
await expect(page.getByRole('alert')).toContainText('Not approved');
route.fulfill supplies a browser response; route.continue allows/modifies a real request; route.abort simulates transport failure. Register before navigation. Service workers can affect interception. The request fixture's direct HTTP calls are not mocked by page.route.
Use mocked UI tests for hard-to-produce display/error states. Use real authorized sandbox integration for the actual API/provider contract and financial effects. Both layers have value; don't call one a substitute for the other's evidence.
Popup, challenge and export patterns#
// Scoped test body. Actual provider challenge UX varies.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Open sandbox challenge' }).click();
const popup = await popupPromise;
await expect(popup.getByRole('heading', { name: 'Sandbox authentication' })).toBeVisible();
await popup.getByRole('button', { name: 'Approve test challenge' }).click();
await expect(page.getByTestId('payment-status')).toHaveText('CAPTURED');
// Scoped test body. Import test/expect; only synthetic redacted data exported.
const pending = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export payments' }).click();
const download = await pending;
expect(download.suggestedFilename()).toMatch(/\.csv$/);
await download.saveAs(test.info().outputPath('sandbox-payments.csv'));
An iframe challenge needs an appropriate frameLocator rather than popup handling; inspect the actual integration. Download filename alone does not validate report contents, currency totals or permissions. Do not store sensitive reports indiscriminately as artifacts.
Framework comparison and diagnosis#
| Option | Useful consideration |
|---|---|
| Playwright | Integrated runner/fixtures/assertions/network/trace tools; strong TS workflow |
| Selenium | WebDriver ecosystem and multiple language integrations; runner/support choices vary |
| Cypress | Its command queue/retry model and browser tooling; not ordinary Playwright async syntax |
| API tools | Fast contract/business combinations, but do not prove rendering/accessibility |
Choose by existing stack, supported platforms, team skill and maintenance. For failures use test.step, HTML report, traces, screenshots and correlated logs. npx playwright show-trace path/to/trace.zip opens a trace; npx playwright test --debug supports focused diagnosis. Traces can contain sensitive payloads/session details.
Interview answer and references#
30 seconds: “I scope stable semantic locators to the correct payment, use web-first assertions for asynchronous state and register response/event waits before actions. I separate actionability, transport and business completion. Browser mocks test UI handling; real API/sandbox checks cover integration. Data and session isolation matter as much as selector stability.”
90-second answer: a checkout test flakes in CI
I preserve the trace and identify which operation waits. If the response happened before the listener, I fix ordering. If the UI is still pending, I inspect status API and processing evidence rather than sleep. If authentication was revoked by another worker, I fix account allocation. If multiple Pay buttons match, I scope the correct checkout/payment. I compare CI resources/browser/configuration only after checking data and timing hypotheses. A retry can capture diagnostics but is not a fix. I validate the targeted repair under the original parallel conditions and keep secret-bearing traces restricted.
Memory hook: locate precisely, listen early, assert the outcome. References: Locators, assertions, network, actionability. Next: framework/isolation.
Quick practice#
What does a payment UI success label prove?
- It proves an observed display in that exercised browser state
- Final service/provider/ledger evidence needs appropriate separate validation.
Pitfall: A heading proves the customer's funds settled.
Is a Locator itself a successful wait?
- It describes a query, not proof of business state
- Actions apply actionability and locator assertions retry their expected observation.
Pitfall: expect(locator).toBeTruthy validates the UI.
Why prefer role or label locators?
- They reflect stable accessible semantics where the UI supports them
- Test IDs or stable CSS IDs are valid explicit contracts when appropriate.
Pitfall: Blindly banning CSS or masking ambiguity with first().