Skip to main content

E2E Testing with Playwright

Exercise D
⏱ 25 min🟡 Intermediate

Playwright is the modern standard for end-to-end browser testing. Copilot can generate page objects, locator strategies, and test fixtures — but it cannot see your actual UI. This step shows how to give Copilot the context it needs to produce useful E2E test scaffolding.

Practice repo for this stepExercise D

Work from master in copilot-testing-workshop. Use the solution checkpoints only if you need to catch up or compare your result after the exercise.

Participant goalFill in the test.fixme placeholders in checkout.spec.ts using Playwright. Cover login, add-to-cart, discount application, and the full checkout flow end-to-end.
Run commandnpx playwright test tests/e2e/checkout.spec.ts

Primary files

  • workshop-exercises/tests/e2e/checkout.spec.ts
  • workshop-exercises/src/ui/pages/LoginPage.tsx
  • workshop-exercises/src/ui/pages/StorePage.tsx

Recovery checkpoint

  • solutions
🙋Audience Poll

Which locator strategy do you trust most for long-term E2E test stability?

♿ARIA-first (getByRole/getByLabel)
🏷️Data attributes only
🎨CSS selectors for speed
🧭Case-by-case blend

Compare your choice to the page-object refactor challenge at the end.

E2E Test Architecture
🧪Test File
uses▸
📄Page Object
controls▸
🌐Playwright Browser
HTTP▸
🖥️Running App
▸
🗄️API / DB

Part A — Page Object Model with Copilot​

The Page Object Model (POM) keeps locators out of your tests. Copilot generates the boilerplate well; you provide the real selector knowledge.

⚠️ This app auto-logs-in on first load

WorkshopApp.tsx automatically signs in as alice@example.com if no token is stored and the user hasn't explicitly signed out. Clear localStorage (or set workshop-signed-out=true) at the start of any test that needs to see the login form — otherwise the app may skip straight to /store.

Step 1 — Generate a Login Page Object​

Use this prompt in Copilot Chat:

Generate a Playwright Page Object for a login page.
The page has: an email input (#email), a password input (#password),
a submit button (role=button, name="Sign in"), and an error alert (.error-message).
Use TypeScript. Include: fill(), submit(), getErrorMessage(), and waitForStore() methods.

Step 2 — Review the Generated Page Object​

// tests/e2e/pages/LoginPage.ts (Copilot-generated — review required)
import { type Page, type Locator } from '@playwright/test';

export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;

constructor(page: Page) {
this.page = page;
this.emailInput = page.locator('#email');
this.passwordInput = page.locator('#password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.locator('.error-message');
}

async goto() {
await this.page.goto('/login');
}

async fill(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
}

async submit() {
await this.submitButton.click();
}

async getErrorMessage(): Promise<string> {
await this.errorMessage.waitFor({ state: 'visible' });
return this.errorMessage.innerText();
}

async waitForStore() {
await this.page.waitForURL('/store');
}
}
🔍 Page Object review checklist:

Do selectors use getByRole / getByLabel before falling back to CSS classes?

  • Are role-based locators used where possible (more resilient to UI changes)?
  • Does waitForStore() use URL assertion rather than an arbitrary wait?
  • Are locators declared as readonly to prevent accidental reassignment?

Step 3 — Generate E2E Tests Using the Page Object​

Write Playwright tests for the login flow using the LoginPage page object.
Test: successful login redirects to /store, wrong password shows error,
empty email also shows an error (there is no client-side required-field validation —
submitting blank calls the login API and it responds with an error).
Use @playwright/test. Use test.beforeEach to clear localStorage, then navigate to /login.
// tests/e2e/login.spec.ts (Copilot-generated — review required)
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test.describe('Login flow', () => {
let loginPage: LoginPage;

test.beforeEach(async ({ page }) => {
// Prevent the app's auto-login from skipping past the login form
await page.addInitScript(() => {
window.localStorage.setItem('workshop-signed-out', 'true');
});

loginPage = new LoginPage(page);
await loginPage.goto();
});

test('successful login redirects to the store', async ({ page }) => {
await loginPage.fill('alice@example.com', 'workshop-password');
await loginPage.submit();
await loginPage.waitForStore();

await expect(page).toHaveURL('/store');
});

test('wrong password shows error message', async () => {
await loginPage.fill('alice@example.com', 'wrong-password');
await loginPage.submit();

const error = await loginPage.getErrorMessage();
expect(error).toMatch(/invalid credentials/i);
});

test('empty email shows an error', async () => {
// No client-side required-field validation exists — submitting blank
// still calls the login API, which rejects it.
await loginPage.fill('', 'some-password');
await loginPage.submit();

const error = await loginPage.getErrorMessage();
expect(error).toBeTruthy();
});
});

Part B — Playwright MCP Server Example​

When selector details are unclear, the Playwright MCP Servergives Copilot live browser context instead of forcing it to guess from code alone. This is useful when you want Copilot to inspect the running page, interact with the UI, and capture evidence before generating or repairing tests.

Step 0 — Install and enable the MCP server​

This repo already ships .vscode/mcp.json, pointing at npx @playwright/mcp@latest — npx downloads it on first run, so no separate npm install is needed. You do need to turn it on in your editor:

  1. Open Copilot Chat and switch to Agent mode (MCP tools are only available in Agent mode)
  2. VS Code should detect .vscode/mcp.json and prompt to start the playwright server — click Start (or run MCP: List Servers from the Command Palette and start it manually)
  3. Confirm it's live: the Chat tool picker (🔧 icon) should now list Playwright tools (browser_navigate, browser_click, browser_snapshot, etc.)
  4. Make sure the app is actually running (npm run dev) — the MCP server drives a real browser against http://127.0.0.1:3006, it doesn't read your source code
tip

First launch can take a few seconds while npx downloads the package — if the tool picker looks empty right after starting, wait a moment and reopen it.

Using the Playwright MCP server, open the login page in the running app.
Inspect the form fields and submit button, then click Sign in with invalid credentials.
Tell me:
1. the best role- or label-based locators for email, password, submit, and error states
2. whether the validation error is exposed as an alert, inline text, or field description
3. take a screenshot after the failed login so I can confirm the UI state
Why this matters

MCP browser automation lets Copilot navigate, click, and screenshot the real page. That is much better than inventing selectors from memory, especially for flaky E2E failures or accessibility-driven locator reviews.

Part C — Fixtures and Test Isolation​

Playwright's fixture system lets Copilot generate test-scoped setup and teardown. Ask for an authenticated fixture to avoid repeating login steps:

Create a Playwright fixture called 'authenticatedPage' that logs in as a test user
before each test and provides the authenticated page object.
The login API endpoint is POST /api/auth/login with { email, password } body.
// tests/e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

type AuthFixtures = {
authenticatedPage: ReturnType<typeof base.extend>['page'];
};

export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ page }, use) => {
// Log in via API to avoid UI login overhead in every test
const response = await page.request.post('/api/auth/login', {
data: { email: 'alice@example.com', password: 'workshop-password' },
});
expect(response.status()).toBe(200);

const { data } = await response.json();
await page.addInitScript((t) => {
localStorage.setItem('workshop-auth-token', t);
}, data.token);

await use(page);
},
});

export { expect } from '@playwright/test';
⚠️ Security review:

Never hardcode test passwords — use process.env.TEST_PASSWORD

  • Ensure .env.test is in .gitignore
  • Use a dedicated test user account — never credentials for real users
  • Token storage via addInitScript is safe; avoid storing in test output or logs

Part D — CI Integration​

Ask Copilot to generate a GitHub Actions workflow for Playwright:

Generate a GitHub Actions workflow that runs Playwright E2E tests on pull requests.
Include: install dependencies, build the app, start the server in the background,
run playwright tests, upload the HTML report as an artifact on failure.
# .github/workflows/e2e.yml (Copilot-generated — review required)
name: E2E Tests

on:
pull_request:
branches: [main]

jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '22.12'
cache: 'npm'

- run: npm ci
- run: npx playwright install --with-deps chromium

- name: Start API and web dev servers
# This app is a live Express API + React frontend, not a static
# build — E2E tests need both servers running, matching local dev.
run: npm run dev &

- name: Wait for servers to be ready
run: npx wait-on http://127.0.0.1:3006 http://127.0.0.1:4000/api/health

- name: Run E2E tests
run: npx playwright test

- name: Upload test report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Expected output
Running 3 tests using 1 worker

✓ login.spec.ts:14:3 › Login flow › successful login redirects to the store (1.2s)
✓ login.spec.ts:22:3 › Login flow › wrong password shows error message (0.8s)
✓ login.spec.ts:29:3 › Login flow › empty email shows an error (0.6s)

3 passed (3.2s)

Part E — The Real Exercise: Checkout Flow​

tests/e2e/checkout.spec.ts has four test.fixme placeholders covering the full store journey — login, add to cart, apply a discount, and complete checkout. Use this prompt in Copilot Chat:

Fill in the test.fixme placeholders in tests/e2e/checkout.spec.ts using Playwright.

Context files:
- #file:tests/e2e/checkout.spec.ts
- #file:src/ui/pages/LoginPage.tsx
- #file:src/ui/pages/StorePage.tsx

Important app behavior:
- The app auto-logs-in as alice@example.com on first load unless localStorage
has workshop-signed-out=true or a token already set. Set workshop-signed-out=true
via page.addInitScript in beforeEach so tests reliably see the login form.
- Login: #email, #password inputs, button role "Sign in", error shown via role="alert".
Successful login navigates to /store (not /dashboard).
- StorePage: Add buttons have data-testid="add-{productId}", cart shows in
data-testid="cart-items", promo input has placeholder "e.g. SAVE10", total is
data-testid="checkout-total", pay button is data-testid="pay-btn".
- Login credentials: alice@example.com / workshop-password.

Implement, in order:
1. user can log in and see the store — login, assert redirect to /store, assert product grid
visible
2. user can add an item to the cart — login, click an Add button, assert cart-items updates
3. user can apply a discount code — login, add item, enter SAVE10, click Apply, assert total
decreases
4. user completes checkout and sees confirmation — full golden path: login → add to cart →
pay → confirmation screen

Replace test.fixme with test. Use getByRole/getByTestId locators, not CSS classes.

Then run:

npx playwright test tests/e2e/checkout.spec.ts
npm run test:e2e
Expected output
Running 4 tests using 1 worker

✓ checkout.spec.ts:19:3 › Store checkout flow › user can log in and see the store (249ms)
✓ checkout.spec.ts:30:3 › Store checkout flow › user can add an item to the cart (268ms)
✓ checkout.spec.ts:39:3 › Store checkout flow › user can apply a discount code (389ms)
✓ checkout.spec.ts:57:3 › Store checkout flow › user completes checkout and sees confirmation (305ms)

4 passed (1.8s)
🔍 Two things Copilot's first draft commonly gets wrong here:

Forgetting the workshop-signed-out localStorage flag — without it, the app's auto-login races the test past the login form entirely

  • Reading UI state (like checkout-total) immediately after a click that triggers an async update — use expect.poll(...) or an auto-retrying expect(locator) assertion instead of a one-shot read-and-compare
🏋️Hands-on Challenge
⏱10 min

The test above uses CSS class .error-message as a locator. Use Copilot to refactor the LoginPage to use ARIA-based locators instead, then verify the tests still pass.

HintWhich Playwright locator to use?
  • Consider page.getByRole('alert') for error messages — it's accessible and resilient
  • Use page.getByLabel('Email') and page.getByLabel('Password') for form inputs
  • Role-based locators break only if ARIA semantics change, not on CSS refactors
SolutionRefactored Solution
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
🏋️Bonus: Put It All Together
⏱15 min

Parts B, C, and D show patterns (MCP-assisted locator discovery, fixtures, CI) without you actually using them. Pick up all three here — optional, time-permitting.

1. Use the Playwright MCP server for real​

The Playwright MCP server is already configured in .vscode/mcp.json. Run Part B's prompt against the real login page (http://127.0.0.1:3006/login) instead of just reading it. Report back what Copilot finds.

HintWhat to check your results against
  • Locators should match LoginPage.tsx: #email, #password, a submit button named "Sign in"
  • The error is rendered as &lt;div role="alert"&gt; — confirm Copilot identifies it as an alert, not a generic .error-message class
  • Compare the screenshot Copilot captures against what you see manually in the browser

2. Create the fixture and actually use it​

Create tests/e2e/fixtures.ts using Part C's corrected pattern, then refactor one test in checkout.spec.ts to use authenticatedPage instead of calling loginAsAlice(page).

HintWhat changes in the test
  • Import test and expect from your new fixtures.ts instead of @playwright/test
  • Replace the test's page fixture parameter with authenticatedPage
  • Delete the now-unneeded loginAsAlice(page) call at the top of that test
  • Run it — should still pass, just skip the UI login round-trip

3. Find one more issue in the CI workflow​

Part D's workflow is now fixed to start real dev servers and wait for them — but it still has at least one gap. Find it.

HintHint
  • What happens to the npm run dev &amp; background process and the two dev servers when the job ends — is anything explicitly cleaned up?
  • Is the Playwright HTML report uploaded on every run, or only on failure — would you want to see it on a passing run too sometimes?
SolutionOne reasonable answer

Background jobs are killed automatically when the runner's VM is torn down at the end of the job, so explicit cleanup isn't required in GitHub Actions — but it's worth calling out in review since it would matter in a longer-lived environment (e.g. a self-hosted runner). For the report: uploading only if: failure() is a reasonable default to keep artifact storage down, but change it to if: always() if you also want traces from flaky-but-passing runs.

Key Takeaways​

  • Copilot generates POM boilerplate well — you supply the real selectors
  • Prefer role-based locators (getByRole, getByLabel) over CSS classes
  • Use Playwright fixtures for shared setup; avoid repeating login in every test
  • Never hardcode credentials — use environment variables and test-only accounts
  • Upload Playwright HTML reports as CI artifacts to diagnose failures quickly
Done with this step? Mark it to track your progress.