Most teams already own their tests, they just own them as prose: a numbered list in a spreadsheet or a ticket that says open the login page, type the email, click Sign in, see the welcome message. Turning that list into a Playwright Test file is mostly translation, and a good translation makes three choices. Elements are named the way a user would name them (by role, label or visible text), each step that reports something gets an assertion that waits for the page, and no line of the file sleeps. Then you prove the result is worth keeping by breaking the page on purpose and watching the test go red. This guide covers each choice, plus the places where the written steps leave a hole that a guess must not fill. The only raw material needed is a short document of manual test steps.
Start with the steps you really have
Take a small case. The steps say: open the login page; type maria@example.org into the email field; click the Sign in button; see the text Welcome back, Maria. Four steps, two of them observe something (a page opened, a message appeared), one only types, one only clicks.
A first draft of the translation, with the choices explained below, looks like this:
import { expect, test } from '@playwright/test';
test('registered user reaches the welcome message', async ({ page }) => {
// 1. open the login page
await page.goto('/login');
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
// 2. type the email
await page.getByLabel('Email').fill('maria@example.org');
// 3. press the button
await page.getByRole('button', { name: 'Sign in' }).click();
// 4. welcome message
await expect(page.getByText('Welcome back, Maria')).toBeVisible();
});
Notice what it contains. The address is relative, since the base URL lives in the project config and not in every test. Each comment carries the step number, so a failure leads back to a row in the original document. The typing step has no assertion, because the written step states no result. An assertion invented for it would either fail a correct page or be chosen so it always passes.
Choice one: find elements the way a person names them
The Playwright documentation recommends prioritising user-facing attributes and explicit contracts when locating elements, and puts getByRole at the top of its list of built-in locators (Playwright locators). The role locator mirrors what a screen reader or a user takes the element to be, a button or a checkbox for instance, and the docs advise adding the accessible name too, so that the locator points at exactly one element. For a form control with a label, getByLabel is the dedicated tool, and for passive text, getByText. The same page adds that text locators fit elements you cannot interact with, while buttons, links and inputs are better found by role.
The reason is stability. The best-practices page contrasts a locator built from a chain of CSS classes with a role locator and explains that the DOM changes easily: a designer renames a class and the test breaks even though nothing the user sees has changed (Playwright best practices). The page also says tests should avoid relying on implementation details that users never see, such as the CSS class of an element. A manual step already speaks that language. When a step says click the Save button, the role and name are right there in the sentence.
Two details matter when you translate. First, locators are strict: acting on a locator with several matches throws instead of picking one (Playwright locators). If a page has both a Save button and a Save as draft button, the name Save matches both, and the shorter name then needs pinning down, for example with the exact option (written from the getByRole reference, which this guide did not save as an excerpt). Second, when the step points at one item among look-alikes, such as one product among many, narrow by content with filter and look for the control within that item, instead of reaching for an index that silently changes meaning when the list is reordered.
Choice two: assertions that wait
Browsers do not finish rendering at the moment of a click. A message may take a moment to appear after the request returns. Playwright handles this in two places. Before an action, it runs a set of checks (visible, stable, receiving events, enabled) and holds back until all pass (Playwright actionability). For checking results, it offers web-first assertions: expect(locator).toBeVisible(), toHaveText, toHaveURL and the rest re-fetch the element and re-test it until the condition is met or the timeout ends, and the default assertion timeout is 5 seconds (Playwright assertions). Those assertions are asynchronous, so they must be awaited.
The best-practices page shows what goes wrong when this is skipped. The same reasoning applies to a sleep: flaky tests often trace back to guessing a duration instead of naming the state. It marks a one-shot boolean read of isVisible() wrapped in a plain expect as the wrong form: the page is read once, immediately, and the test never waits (Playwright best practices). The assertions documentation says the same about non-retrying assertions in general: most pages show information asynchronously, so they can produce a flaky test, and auto-retrying ones are preferred (Playwright assertions).
For a translator of manual steps, this answers the most common awkward line, the step that tells the tester to hold on a little until the dashboard shows up. That sentence is a human workaround for not knowing when the page is ready. The state being awaited is the real step: an assertion on the address with toHaveURL, or on a heading with toBeVisible. If the wait could be long, a single assertion can take a larger timeout option. No call should sleep, and a click should not be pushed through with force: true: a blocked click means an overlay or a disabled control is in the path, so assert that it has cleared.
Choice three: one test per scenario
The best-practices page asks for isolation: each test should run independently, with its own storage, cookies and data, so that one failure does not cascade (Playwright best practices). When a manual document holds several scenarios (valid login, wrong password, empty email), write one test for each, named after what it proves. A shared start, such as opening the login page, belongs in test.beforeEach. The same page admits that a little duplication is fine when tests are simple, because clarity beats cleverness.
A test that logs in and then checks the profile page and then checks the order history is three scenarios glued together. The first assertion to fail hides the rest, and nobody knows whether the later parts work.
When a step names something you do not have
Manual steps are written by people who can see the screen, so they contain phrases like press the little gear at the corner or open the second menu. A locator for that cannot be derived from the sentence. The tempting move is to invent an id, a class name or a position, and the test then either breaks at once or, worse, clicks the wrong element and passes. The better move is to leave a visible gap: declare the test with test.fixme, write the steps that are known, and at the unclear step add a note such as // TODO: the gear has no name in the steps; ask for its accessible name. A skipped test with a TODO is honest. A test with an invented selector looks finished and is not.
The same applies to expected text. Copy the message exactly as written in the steps, including curly quotes and dashes, because a normalised version either fails against the real page or no longer checks what the author meant. Text that third parties control, such as a map widget or a payment frame, does not belong in your test: the best-practices page says to test only what you control and to answer such requests with the network API, using page.route to return a fixed response (Playwright best practices). One more case to watch is a browser dialog, an alert or a confirm: attach the listener first and click second, otherwise the dialog is closed before your test sees it.
The check that can fail
A green test proves nothing until you have seen it red. Apply the following to each new test:
- Change one expected string in the test, for example
Welcome back, MariatoWelcome back, Mariya, and run it. It should fail on that assertion after the assertion timeout, with the message naming the locator. - Restore the string, then break the page instead: remove the button, rename its label, or point the test at a route that returns an error. Each break should turn exactly the step that depends on it red.
- Look at each assertion and imagine the broken page. If it would read exactly like the working one, the assertion is empty. A frequent case is asserting that a heading is visible when the heading is part of the layout and appears whatever the form does.
- Search the file for timed pauses,
force: trueand CSS chains. Expect zero hits.
The second step matters most, because it covers the translation itself and not only the text of one expectation. If the test stays green with the button removed, the click step is reaching a different element than the one the sentence described.
When this does not apply
Written browser steps turning into Playwright Test files in TypeScript is the single subject. It does not cover API-only tests, visual comparison, mobile apps, or tests whose steps depend on data you cannot reset between runs. Locator names and defaults in the documentation can change between Playwright versions, so confirm them against the version you use; the facts here come from the pages cited, read on 30 October 2026, and none of the code in this guide was run against a real application. Role and label locators only work as well as the page's accessible markup: a page whose buttons have no accessible name forces a fallback such as a test id, and a team that adds one should do so deliberately and record why. Finally, a passing test shows that the steps still hold on the page, not that the steps were the right ones to write.
One tool for the translation step
The playwright-test-from-steps skill takes written steps together with the page details you know (route, labels, button names, messages) and returns a single TypeScript test file. It locates controls by role, label or text, adds an awaited assertion wherever a step reports a result, swaps a requested pause for an assertion on the awaited state, and reproduces expected text character for character. If a step points at a control that was never described, it flags the test as fixme with a note rather than making up a locator, and if the steps promise nothing visible on the page, it declines to write a test at all.
Read this post as Markdown: /blog/playwright-tests-from-manual-steps.md · Atom feed.
