Playwright Compat Demo
A small React tutorial that demonstrates the in-browser Playwright-compatible test runner. The spec file is shaped like real @playwright/test code so students can later copy the same ideas into a Node/Vite project.
Run a Playwright-Style Todo Test
Learning objective: After this step you will be able to recognize the basic shape of a Playwright end-to-end test for a React app: navigate to the app, find elements the way a user would, perform actions, and assert what changed.
This demo uses the tutorial system’s Playwright-compatible runner. It does not start a real browser process on the backend, but the test file is intentionally close to normal @playwright/test:
import { test, expect } from '@playwright/test';
test('adds a todo item', async ({ page }) => {
await page.goto('/');
await page.getByRole('textbox', { name: /todo item/i }).fill('Milk');
await page.getByRole('button', { name: /add todo/i }).click();
await expect(page.getByText('Milk')).toBeVisible();
});
The preview iframe acts like the browser page. The test talks to it through Playwright-style APIs such as page.getByRole, locator.fill, locator.click, and expect(locator).toBeVisible().
Task
- Open
tests/todo.spec.js. - Click Test in the Live Preview toolbar and confirm the Playwright-style spec passes.
- Read the selectors and compare them to the labels, button text, and list items in
src/App.jsx. - Optional: add the final assertion from the solution to check that the input is cleared after adding a todo.
- Click Test My Work to run instructor checks that inspect the app and the test file itself.
In a real local project, the same spec would live under tests/ and run with npx playwright test. Here, package.json and playwright.config.js are included as reference files so students can see the normal project shape.
function App() {
const [items, setItems] = React.useState([]);
const [text, setText] = React.useState('');
function addTodo() {
const trimmed = text.trim();
if (!trimmed) return;
setItems([...items, trimmed]);
setText('');
}
return (
<main className="todo-shell">
<section className="todo-panel">
<p className="eyebrow">Playwright demo</p>
<h1>Todo Lab</h1>
<div className="todo-form">
<label htmlFor="todo-input">Todo item</label>
<div className="todo-row">
<input
id="todo-input"
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="Buy milk"
/>
<button onClick={addTodo}>Add todo</button>
</div>
</div>
<ul aria-label="Todo list" className="todo-list">
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</section>
</main>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7fb;
color: #1f2937;
}
.todo-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.todo-panel {
width: min(100%, 560px);
background: white;
border: 1px solid #d9dee8;
border-radius: 8px;
padding: 28px;
box-shadow: 0 18px 40px rgba(31, 41, 55, 0.08);
}
.eyebrow {
margin: 0 0 8px;
color: #4b5563;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
h1 {
margin: 0 0 24px;
font-size: 2rem;
}
label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
.todo-row {
display: flex;
gap: 10px;
}
input {
flex: 1;
min-width: 0;
border: 1px solid #b8c0cc;
border-radius: 6px;
padding: 10px 12px;
font: inherit;
}
button {
border: 0;
border-radius: 6px;
padding: 10px 14px;
background: #2563eb;
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.todo-list {
margin: 24px 0 0;
padding-left: 24px;
}
.todo-list:empty {
display: none;
}
.todo-list li {
margin: 8px 0;
}
import { test, expect } from '@playwright/test';
test('adds a todo item', async ({ page }) => {
await page.goto('/');
await page.getByRole('textbox', { name: /todo item/i }).fill('Milk');
await page.getByRole('button', { name: /add todo/i }).click();
await expect(page.getByRole('listitem')).toHaveCount(1);
await expect(page.getByText('Milk')).toBeVisible();
});
test('clears the input after adding', async ({ page }) => {
await page.goto('/');
const input = page.getByRole('textbox', { name: /todo item/i });
await input.fill('Bread');
await page.getByRole('button', { name: /add todo/i }).click();
await expect(input).toHaveValue('');
await expect(page.getByRole('listitem')).toHaveText('Bread');
});
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:5173',
},
});
{
"scripts": {
"dev": "vite",
"test:e2e": "playwright test"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@vitejs/plugin-react": "^5.0.0",
"vite": "^7.0.0"
}
}
Assert That a Student Test Fails
Learning objective: After this step you will be able to use an instructor check that expects one student-authored Playwright test to fail.
This step intentionally gives you a buggy Todo app: it adds items, but it does not clear the input afterward.
A good end-to-end test for the missing behavior should fail against this buggy app. That is useful in TDD and regression testing because a test that already passes against broken code may not be testing the right thing.
Task
- Open
buggy/tests/todo.spec.js. - Click Test in the Live Preview toolbar.
- Confirm that
adds a todo itempasses andclears the input after addingfails. - Click Test My Work. The instructor check passes only if the named student test fails.
function App() {
const [items, setItems] = React.useState([]);
const [text, setText] = React.useState('');
function addTodo() {
const trimmed = text.trim();
if (!trimmed) return;
setItems([...items, trimmed]);
// Bug for the lesson: the app should clear the input here, but does not.
}
return (
<main className="todo-shell">
<section className="todo-panel">
<p className="eyebrow">Buggy app</p>
<h1>Todo Lab</h1>
<label htmlFor="todo-input">Todo item</label>
<div className="todo-row">
<input
id="todo-input"
value={text}
onChange={(event) => setText(event.target.value)}
placeholder="Buy milk"
/>
<button onClick={addTodo}>Add todo</button>
</div>
<ul aria-label="Todo list" className="todo-list">
{items.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</section>
</main>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7fb;
color: #1f2937;
}
.todo-shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
.todo-panel {
width: min(100%, 520px);
background: white;
border: 1px solid #d9dee8;
border-radius: 8px;
padding: 28px;
}
.eyebrow {
margin: 0 0 8px;
color: #4b5563;
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
}
.todo-row {
display: flex;
gap: 10px;
margin-top: 8px;
}
input {
flex: 1;
min-width: 0;
border: 1px solid #b8c0cc;
border-radius: 6px;
padding: 10px 12px;
font: inherit;
}
button {
border: 0;
border-radius: 6px;
padding: 10px 14px;
background: #2563eb;
color: white;
font: inherit;
font-weight: 700;
cursor: pointer;
}
import { test, expect } from '@playwright/test';
test('adds a todo item', async ({ page }) => {
await page.goto('/');
await page.getByRole('textbox', { name: /todo item/i }).fill('Milk');
await page.getByRole('button', { name: /add todo/i }).click();
await expect(page.getByRole('listitem')).toHaveText('Milk');
});
test('clears the input after adding', async ({ page }) => {
await page.goto('/');
const input = page.getByRole('textbox', { name: /todo item/i });
await input.fill('Milk');
await page.getByRole('button', { name: /add todo/i }).click();
await expect(input).toHaveValue('');
});