A read-only test can run a thousand times and leave the world exactly as it found it. Point it at a dashboard, assert the chart renders, done. Run it again tomorrow and nothing about yesterday's run affects the result.
The moment a test creates a record, that stops being true. It leaves evidence behind: a row in a table, a name in a list, an email address now taken. That evidence is what breaks run number two, and it's why most teams have a suite that reads confidently and writes almost nothing.
Features that add, change, and remove records are worth testing precisely because they're the ones that mutate state a user cares about. They just need a different authoring discipline than a read-only check.
Every scenario owns its own record
The tempting structure looks like a story: scenario one creates the customer, scenario two edits it, scenario three deletes it. It reads beautifully in a feature file and it fails badly in practice.
Two reasons. Scenarios don't run in a guaranteed order, and when you dial up execution parallelism in the run composer they may run at the same time, so scenario three can start hunting for a record scenario one hasn't finished creating. Worse, when scenario one fails, scenarios two and three fail too, for reasons that have nothing to do with what they were written to check. You get three red scenarios, one actual bug, and a triage session spent on the two that were never broken.
The rule that avoids all of it: every scenario creates the record it needs, acts on it, and asserts on it. Creating a record is cheap. Coupling scenarios together is expensive, and it gets more expensive as the suite grows.
That does mean setup work repeats across scenarios, which is the right trade. Where the repetition gets slow, do the setup through your API or a factory rather than by driving the UI, which the test data management guide covers in detail. Reserve the browser for the behavior you're actually testing.
One trap with run-scoped unique values
You need unique values so that today's run doesn't collide with yesterday's on a unique constraint, and {{unique}} handles that. It resolves to a fresh stamp per run and stays stable for the whole run, which is what lets a later step find what an earlier step created.
Read that property carefully, because it has a consequence people hit on their second CRUD scenario. Every occurrence in a run resolves to the same value. So if two scenarios in the same run both create Customer {{unique}}, they are both creating a record with an identical name, and they will collide with each other exactly the way two runs would have.
Differentiate per scenario:
# Scenario A
When I create a customer named "Acme A {{unique}}"
# Scenario B
When I create a customer named "Acme B {{unique}}"
Same run stamp, distinct records, no collision in either direction.
Create: assert the record, not the toast
A green "Customer created" banner proves the UI fired an event. It does not prove a row committed. Optimistic UIs render the success state before the write lands, and a failed background request can leave a user looking at a confirmation for a record that doesn't exist.
Verify the round trip. Reload the page, or navigate away and come back, then find the record where a user would find it:
Scenario: A new customer is saved and appears in the list
Given I am signed in and on the Customers page
When I click "New customer"
And I fill in "Company name" with "Acme A {{unique}}"
And I fill in "Contact email" with "ops+{{unique}}@example.com"
And I click "Save"
Then I see a confirmation that the customer was saved
When I reload the Customers page
Then the customers list contains a row for "Acme A {{unique}}"
The reload is the whole point of the last two steps. Without it you've tested that a form submits, which is a genuinely weaker claim than that a customer exists.
Verify: find your row, not the first row
Your record is not alone in that list. There's seed data, other users' records, and rows from every previous run that never got cleaned up. A locator that grabs the first row is a coin flip that gets less accurate as the table fills.
Scope to the value you created. Because the unique stamp is stable within the run, the name you typed in the create step is a handle you can use to find exactly your row and act inside it:
const row = page.getByRole('row', { name: /Acme A/ });
await expect(row).toHaveCount(1);
await row.getByRole('button', { name: 'Edit' }).click();
Scoping the action through the row is what keeps the Edit click on the right record. This is the same discipline as resilient locators generally: anchor to something meaningful, never to position.
Update: assert what changed and what didn't
The usual update test asserts the new value shows up. That catches the obvious failure and misses the expensive one, which is an edit that quietly clears a field nobody looked at.
Assert both sides. Change the phone number, then check the phone number is new and the company name is untouched. Then reload and check again, because an update that renders correctly and never persists looks identical to one that worked until the next page load.
Delete: proving absence is the hard part
Negative assertions are where data-mutating suites flake, because "not there yet" and "not there at all" look the same at the instant you check. A test that deletes a row and immediately asserts the row is gone will pass on a fast machine and fail on a loaded CI box, which is the classic shape of a test people learn to re-run instead of trust.
Use an auto-retrying assertion rather than a check-once-and-hope, and let it wait for the absence to be real:
await row.getByRole('button', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByRole('row', { name: /Acme A/ })).toHaveCount(0);
toHaveCount(0) retries until the row is genuinely gone or the timeout expires, which is the same web-first assertion behavior that makes positive checks reliable. A fixed sleep followed by a count is the version that flakes.
Three more things worth building into delete scenarios:
- Delete a record the same scenario created. Never point a delete test at a shared fixture, because the first run passes and every run after it fails on a record that no longer exists.
- Treat the confirmation dialog as part of the flow. Most destructive actions have one, and a test that doesn't handle it silently deletes nothing while still going green on a weak assertion.
- Know whether delete is soft. If the row is only flagged as deleted, it may vanish from the default list and still exist in the database. Assert on what the user sees, and if the archived state matters, make that its own scenario.
Where these suites are allowed to run
Read-only suites can point almost anywhere. Data-mutating suites cannot, and this is the operational rule that matters most.
Point them at an environment you're permitted to dirty. A suite that creates, edits, and deletes records has no business running against production, and the destructive scenarios are the ones to hold back first when a suite gets pointed at a shared or customer-facing environment. Splitting them into their own group makes that easy to enforce: run the read-only group anywhere, run the write group only where cleanup and collisions are acceptable.
Cleanup itself is worth doing, and the approaches are covered here, from teardown hooks to disposable environments. One caution: don't let cleanup become load-bearing for correctness. If a scenario only passes when the previous run's teardown succeeded, you've rebuilt the coupling problem in a less visible place.
Start with the record you'd hate to lose
Pick the entity at the center of your product: the order, the invoice, the customer, the booking. Write three independent scenarios against it, each creating its own record with a distinct unique value, and each verifying through a reload rather than a toast. Create it and confirm it persisted. Edit one field and confirm the others survived. Delete it and confirm it's actually gone.
That's the lifecycle covered, with no scenario depending on another. Get early access and describe those three in plain language, or read how features and groups keep the write-heavy ones separated from the suite you run everywhere.