Future-proof Playwright tests by choosing good selectors
Jeff Landfried
End-to-end (E2E) tests can be a double edged sword. They are great at providing a big-picture view of whether your app is behaving correctly or not, but there is a reason that they are placed at that top of the testing pyramid. They’re slow to run, brittle, and costly to maintain. Oftentimes it’s not the major app refactor that causes test breakage. It’s the little things: the class names for your CMS’ generated links changed when performing a module update, the “See More” buttons at the bottom of your content teasers all got changed to say “Read More”, your “Add to Cart” button got nested in another div… these are all seemingly small changes that can cause big problems for E2E tests.
Through careful design of both your app and your tests, you can avoid having truly harmless changes slow your delivery pipeline due to E2E failures. There are a few approaches that you can take, some of which may veer from commonly preached “best practices”.
Text based locators can cause problems
Best practice documentation around the web often says to interact with your app like your user does, and this often means using locators like this:
await page.getByRole('button', { name: 'View Cart' }).click();This looks good, and feels good, because you see a thing on the screen and you click it?
Unfortunately, it does come with some pitfalls. What happens when you’re testing on a mobile device that hides the text for a cart button and just displays a cart SVG? Do you need different sets of tests for each language supported by your app? What about when Marketing wants to change the text from “View Cart” to “See What’s in Your Shopping Bag?”. These are all scenarios that lead to additional test development being needed, when (at least in some cases) it could be avoided.
data-testid is your friend
In practice, it’s very helpful for test engineers to have dedicated identifiers for interactive elements that exist solely for the purpose of test tool interaction. This is where data-testid comes in. data-testid is not an actual part of the HTML spec, instead it’s a common convention to use this custom attribute to identify an item in a way that remains consistent for testing.
Compared to DOM-based selectors
Using data-testid for selectors in our automated tests allows us to reference custom HTML attributes whose sole purpose is to provide reference for testing. Developers can change the markup around an element, or the attributes on the element itself, and those changes will not impact cause a locator using data-testid to fail.
An example:
<!-- Original page title markup -->
<div>
<h1 data-testid="page-title">Foo</h1>
</div>
<!-- Updated page title markup -->
<section>
<h1 data-testid="page-title" id="page-heading">
<a>Foo</a>
</h1>
</section>For both of the above examples, you can use the same simple locator:
page.locator("[data-testid='page-title']");Compared to user facing attributes
User facing attributes may be considered best practice for locators, but they are not as adaptable as data-testid. There are a lot of reasons that user facing attributes (ex: button text) may change: Marketing may want to try different calls to action, the text may be generated dynamically, or the text may get translated. Using data-testid locators, the text can be changed at will without causing test breakage.
An example
<!-- English Button -->
<button data-testid="say-hi">Hello</button>
<!-- Spanish Button -->
<button data-testid="say-hi">Hola</button>And when the text of the English version changes, it’s no problem:
<!-- Updated English Button -->
<button data-testid="say-hi">Howdy</button>Other options
It’s great when we have full control over the markup generated by the system under test, but that’s often not the case. What do we do then?
Use standard unique attributes
When we don’t have access to create data-testid attributes within the system under test, we can look for other more common attributes that we can use in our locators. If the id attribute is used effectively in the application, you may be able to use a simple locator like this to achieve the same effect as data-testid.
page.locator('#say-hi');One caveat about this is that you will need to ensure that the id is consistent. Sometimes frameworks will generate unique IDs, which may make them unreliable for use as a locator. If the id appears to be programmatically generated (ex: post-3, or button-GeyQ4tbS7) then it’s probably not safe to rely on it for our locators. Otherwise, this is a great alternative to data-testid.
Use Playwright’s locator helpers with user-facing attributes
It’s not as resilient as a unique identifier, but it’s often better than relying on specific DOM hierarchy, or display-only attributes like classes. Playwright offers a lot of helpers to locate elements using common conventions. Using a locator like Page.getByRole(“button”, {name: “Say Hi”}) can still be a great option. This is not as resilient as relying on unique identifiers, so scenarios like copy changes and multilingual functionality will likely disrupt the tests, but these can often be easy to resolve.
Since these locators aren’t necessarily unique, you may need to jump through a few additional hoops to access these locators in an actionable way. For example, if we want to be sure that we click a button in the header, we may need to do something like this:
const header = page.locator('#page-header');
await header.getByRole('Button', { name: 'Say Hi' }).click();Or, if we just want to click any button with that text that is on the page, we can do something like this:
await page.getByRole('button', { name: 'Say Hi' }).first().click();Wrapping up
Playwright’s locators will let you find just about anything on the page. They go beyond basic CSS selectors and allow you to filter, use xpath, use visibility options, and more. These different options all exist for a reason, and there’s a time and place for having really gnarly locators that just have to get the job done. But, if we start by looking for unique attributes like data-testid and id first, and then trying to rely on Playwright’s built in locator helpers with simple user facing parameters, it’s possible to build test steps that are easier to reuse, and tests that are easier to maintain, while reducing the number of test failures that you see in CI that are related to trivial changes like markup or copy.