E2E Testing with Playwright
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.
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.
Primary files
workshop-exercises/tests/e2e/checkout.spec.tsworkshop-exercises/src/ui/pages/LoginPage.tsxworkshop-exercises/src/ui/pages/StorePage.tsx
Recovery checkpoint
solutions
Which locator strategy do you trust most for long-term E2E test stability?
Compare your choice to the page-object refactor challenge at the end.
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.
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');
}
}
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
readonlyto 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:
- Open Copilot Chat and switch to Agent mode (MCP tools are only available in Agent mode)
- VS Code should detect
.vscode/mcp.jsonand prompt to start theplaywrightserver — click Start (or run MCP: List Servers from the Command Palette and start it manually) - Confirm it's live: the Chat tool picker (🔧 icon) should now list Playwright tools (
browser_navigate,browser_click,browser_snapshot, etc.) - Make sure the app is actually running (
npm run dev) — the MCP server drives a real browser againsthttp://127.0.0.1:3006, it doesn't read your source code
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
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';
Never hardcode test passwords — use process.env.TEST_PASSWORD
- Ensure
.env.testis in.gitignore - Use a dedicated test user account — never credentials for real users
- Token storage via
addInitScriptis 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/
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
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)
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 — useexpect.poll(...)or an auto-retryingexpect(locator)assertion instead of a one-shot read-and-compare
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')andpage.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');
}
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
<div role="alert">— confirm Copilot identifies it as an alert, not a generic.error-messageclass - 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
testandexpectfrom your newfixtures.tsinstead of@playwright/test - Replace the test's
pagefixture parameter withauthenticatedPage - 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 &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