Migrating from Playwright
You don't have to migrate. Keep your specs; write new tests, or the ones that break most often, in plain English next to them. Every recorded test becomes a Playwright spec again.
Coming There is no automatic import of Playwright specs yet (it's planned). Today you rewrite a spec as a test by hand, using the table below; it's usually shorter than the spec.
What maps to what
| Playwright | In a test file |
|---|---|
test("Checkout works", …) | name: Checkout works in the frontmatter; one test per .test.md file |
page.goto("/pricing") | start: /pricing, or a step Go to /pricing |
getByRole("button", { name: "Save" }).click() | Click "Save", or exactly: Exact: click role=button[name="Save"] |
getByLabel("Email").fill(email) | Fill "Email" with {{data.email}}, or Exact: fill label="Email" with {{data.email}} |
selectOption("Europe/London") | Select "Europe/London" in "Time zone" |
keyboard.press("Enter") | Exact: press Enter |
expect(heading).toHaveText(…) | Expect: the page heading is "Welcome" |
expect(page).toHaveURL(/dashboard/) | Expect: the URL contains /dashboard |
expect(locator).toBeVisible() | Expect: "Saved" is visible |
expect(rows).toHaveCount(5) | Expect: the orders table shows 5 orders, or Exact: expect css=.order-row count 5 |
test.step / helper functions | a flow (kind: flow) included with Use: flows/login.test.md |
storageState / global setup login | an auth profile: auth: admin logs in once and reuses the saved session |
process.env.PASSWORD | {{secret.PASSWORD}}, declared with the domains it may be typed into |
test data, faker | data: and {{unique.email}}, {{faker.name}} |
page.route() mocks | Coming Network mocking isn't available yet |
anything else | a ```ts code step, kept verbatim (runs through the generated spec, not healed) |
A worked example
A spec:
import { expect, test } from "@playwright/test";
test("A new project is saved", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("[email protected]");
await page.getByLabel("Password").fill(process.env.SHOP_PASSWORD!);
await page.getByRole("button", { name: "Log in" }).click();
await page.getByRole("button", { name: "Create project" }).click();
await expect(page.getByRole("dialog", { name: "New project" })).toBeVisible();
await page.getByLabel("Project name").fill("Q3 roadmap");
await page.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("status")).toContainText("Project created");
});The same test in plain English. The locators, waits and assertions come from the first run's recording:
---
name: A new project is saved
start: /login
---
1. Fill "Email" with [email protected]
2. Fill "Password" with {{secret.SHOP_PASSWORD}}
3. Click "Log in"
4. Click "Create project"
5. Expect: a dialog titled "New project" is open
6. Fill "Project name" with Q3 roadmap
7. Click "Create"
8. Expect: a message says "Project created"npx optestra init # keeps your Playwright config and specs as they are
npx optestra author tests/create-project.test.md
npx optestra run
npx optestra generate # the Playwright spec again, in tests/.optestra/init never touches an existing Playwright config or spec, and doctor warns if your config would also pick up the generated specs in tests/.optestra/, with the one-line fix. The generated spec uses role and label locators, a test.step per English step, and learned waits instead of timeouts: see Playwright export.
What changes, and what doesn't
- Selectors stop being your job: a changed button is healed and shown to you for review, instead of breaking the test.
- Each
Expect:line is compiled into a real check and tested so it can fail. - Things with no English equivalent stay code: a
```tsstep runs your Playwright code as written (but isn't healed). - Network mocking (
page.route) has no equivalent yet. Coming