TypeScript · @playwright/test
E-commerce with Playwright
Login with test users, search/filter/sort products, cart & coupons, a validated checkout, a declined card, order history with cancel and return, a store-manager role and a buggy build.
App under test
🛍️ Nova Shop
- URL
- /shop
- User
- demo_user
- Password
- Demo@123
- Manager
- admin_user
- Coupons
- SAVE10 · FLAT20
- Declined card
- 4000 0000 0000 0002
Scenarios to automate
Work top to bottom — each one adds a new technique.
- 1
Valid & invalid login
Beginnerdemo_user / Demo@123 succeeds; wrong password and empty fields show login-error.
- 2
Locked-out user
Beginnerlocked_user gets “Sorry, this user has been locked out.”
- 3
Slow login
Intermediateslow_user takes ~3s — wait for the URL or product grid, never sleep.
- 4
Route guard
BeginnerVisiting /shop/cart signed out redirects to /shop.
- 5
Search, filter & sort
IntermediateAssert product-count text, no-results state, and that product-price values are sorted.
- 6
Cart badge & quantities
IntermediateAdd/remove from the grid, change qty in the cart, check line-total and totals maths.
- 7
Coupons
IntermediateSAVE10 = 10% off, FLAT20 = $20 off above $100, anything else is rejected.
- 8
Checkout validation
IntermediateSubmit empty to collect error-* messages; fix them one by one.
- 9
Declined card
Advanced4000 0000 0000 0002 shows payment-error; switch to Cash on delivery and succeed.
- 10
Order history
AdvancedOrder id on the success page must appear in orders-table after navigation.
- 11
Cancel, return & buy again
IntermediateCancel a Processing order in cancel-modal; request a return on a Delivered one (a reason is required); reorder-* refills the cart.
- 12
Store manager role
Advancedadmin_user lands on /shop/admin, ships and delivers orders and decides returns; demo_user sees access-denied there.
- 13
Bug hunt
Advancedbuggy_user has 5 seeded defects (sort, search, badge, tax, order total) — write tests that fail for it and pass for demo_user.
- 14
UI vs API
AdvancedPOST /api/shop/quote prices the same cart; assert subtotal, discount, tax and total match the cart page.
Sample Playwright suite
A working starting point that finds elements every way Playwright can — by role, label, text, id, name, CSS, XPath and data-testid — so you practice them all. Pick your language, switch to data-testid only to compare, or try your own in the Locator Playground.
import { test, expect } from '@playwright/test';
// Each line uses a different kind of locator on purpose — the comments name them.
test.beforeEach(async ({ page }) => {
await page.goto('/shop');
await page.getByLabel('Username').fill('demo_user'); // by its <label>
await page.getByLabel('Password').fill('Demo@123');
await page.getByRole('button', { name: 'Sign in' }).click(); // by role + accessible name
await expect(page).toHaveURL(/\/shop\/products/);
});
test('buys headphones with a coupon', async ({ page }) => {
await page.getByPlaceholder('Search products…').fill('headphones'); // by placeholder
await expect(page.getByTestId('product-card')).toHaveCount(1); // by data-testid
await page.getByTestId('product-card').filter({ hasText: 'Aurora Wireless Headphones' })
.getByRole('button', { name: 'Add to cart' }).click(); // chained: a button inside one card
await expect(page.getByTestId('cart-badge')).toHaveText('1');
await page.getByRole('link', { name: /^Cart with/ }).click(); // aria-label, matched with a regex
await page.getByLabel('Coupon code').fill('SAVE10'); // getByLabel also reads aria-label
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByTestId('discount')).toContainText('$13.00');
await page.getByRole('link', { name: 'Checkout' }).click();
await page.getByLabel('First name').fill('Ada');
await page.getByLabel('Last name').fill('Lovelace');
await page.locator('#email').fill('ada@example.com'); // CSS: id
await page.locator('input[name="phone"]').fill('5551234567'); // CSS: attribute
await page.getByLabel('Street address').fill('1 Analytical Way');
await page.locator('xpath=//input[@id="city"]').fill('Austin'); // XPath
await page.getByRole('combobox', { name: 'State' }).selectOption('Texas');
await page.getByLabel('ZIP code').fill('73301');
await page.getByLabel('Name on card').fill('Ada Lovelace');
await page.getByPlaceholder('4242 4242 4242 4242').fill('4242424242424242');
await page.getByPlaceholder('MM/YY').fill('12/30');
await page.getByLabel('CVV').fill('123');
await page.getByRole('checkbox', { name: /I agree to the terms/ }).check();
await page.getByRole('button', { name: /^Place order/ }).click(); // the button text ends with the total
// Placing the order takes ~2s — web-first assertions wait for it.
await expect(page.getByRole('heading', { name: 'Thank you for your order!' })).toBeVisible();
await expect(page.getByTestId('order-id')).toHaveText(/^NS-\d+$/);
});
test('sorts by price, low to high', async ({ page }) => {
await page.getByLabel('Sort by').selectOption('price-asc');
const prices = (await page.getByTestId('product-price').allTextContents())
.map((p) => Number(p.replace('$', '')));
expect(prices).toEqual([...prices].sort((a, b) => a - b));
});