To avoid dying in the E2E test. Design techniques to turn “liabilities” into “assets” with Playwright
"The test is perfect." With that in mind, I deployed it on Friday night, and the moment the screen turned white, my heart stopped. The cause was that when the front end switched to the new API response format, rendering continued with the old cache remaining in the browser, causing JS to die.
The test code was passing. However, it was only working in an "ideal environment" and could not imitate the behavior of real browsers at all. What I was writing wasn't a shield to protect the product, it was just a ritual of self-satisfaction.
1. Why “CSS selector direct writing” is a direct flight to hell
This is the first trap that beginners fall into. `page.locator('.btn-primary-01')`. While convenient at first glance, this is a "ticking time bomb" that will cause an avalanche of tests to collapse as the UI changes.
The reason you should use User-facing locators (such as `get_by_role`) as recommended by Playwright is not just because they make your code cleaner. This is because it allows consistency between the perspective of ``how the user operates'' and accessibility.
# Bad example: Implementation dependent. Instant death if CSS class name changes
page.locator(".css-12345").click()
# Good example: Close to user experience. Robust to follow the accessibility tree
page.get_by_role("button", name="Login").click()
`
If you use `get_by_role`, the test will pass regardless of whether the button's appearance changes or the style breaks as long as there is an element with the role of "login". This is the first step to writing tests that are resistant to change.
2. Don't make POM (Page Object Model) "just a class"
POM is more than just a code organization technique. ``Page state abstraction''. Anyone can do it simply by arranging locators. The key is to hide page transitions and asynchronous waits.
from playwright.sync_api import Page, expect
class LoginPage:
def __init__(self, page: Page):
self.page = page
# Locator is evaluated lazily. Even if you define it here, no communication will occur.
self.username = page.get_by_label("username")
self.password = page.get_by_label("password")
self.submit = page.get_by_role("button", name="Login")
def login(self, user: str, pass_: str):
self.username.fill(user)
self.password.fill(pass_)
self.submit.click()
# Keep the caller clean by defining the post-transition state here
self.page.wait_for_url("**/dashboard")
`
The key to this design is to not make the test script aware of "waiting for transition". By `LoginPage` encapsulating the knowledge that ``If you log in, go to the dashboard,'' test code becomes dramatically easier to read.
3. Flaky Test is not caused by the environment but by the way you wait
``Sometimes it crashes'' is the biggest shame for engineers. As soon as you write `page.wait_for_timeout(3000)`, you are guaranteed to lose.
Playwright's `expect` internally has a mechanism called Web-first assertions, which automatically repeats retries until the target element appears. Relying on `--retries` is for amateurs who don't understand this powerful mechanism.
# Not good: forcing yourself to wait
page.wait_for_timeout(5000)
expect(page.locator(".toast")).to_have_text("success")
# Good: Leave it to Web-first assertions
# Automatically retries and waits until timeout
expect(page.locator(".toast")).to_have_text("success", timeout=10000)
`
90% of the reasons for test failures are due to ``trying to manipulate elements before they are found.'' Instead of waiting for the DOM to update, wait until it's in the state you expect. This shift in thinking will eliminate unstable testing.
4. Test automation “priority matrix”
Automating everything through individual development is too cost-effective. I decide which tests to throw away and which tests to keep based on the following criteria.
| Priority | Target function | Reason |
|---|---|---|
| High | Login/Payment/Data storage | Core functions that will kill your business if they break |
| Medium | Form input/major transitions | Fundamentals of user experience |
| Low | Detailed UI animation | Catastrophic maintenance costs |
My operating rules:
1. Do not introduce Visual Regression Testing: Tests that fail due to slight differences in font rendering are just a source of stress.
2. CI time is within 5 minutes: If it exceeds this, either parallelize the tests or eliminate the tests in the first place.
3. Don't chase coverage: As long as important flows are covered, it doesn't matter if coverage is 60% or 100%.
Conclusion: Tests are not “chains that bind you”
The purpose of writing E2E tests is not to slow down development, but to break the code with confidence.
If you've failed a test and are busy revising it every day, stop and think about it. Does the test really protect user value? Or are you just stuck with past specifications?
Test code can be your best ally or your biggest hindrance. I want test suites to be designed wisely, maintained sloppily, and above all, have enough leeway to be able to smile and fix a test when it fails.
Now, which tests should I delete and which ones should I rewrite?