Skip to main content

Best Practices

This guide aims to share our best practices that help you write performant and resilient tests.

Use resilient selectors

Using selectors that are resilient to changes in the DOM, you'll have a less or even no tests failing when the for example a class is removed from an element.

Classes can be applied to multiple elements and should be avoided if possible unless you deliberately want to fetch all elements with that class.

// 👎
await $('.button')

All these selectors should return a single element.

// 👍
await $('aria/Submit')
await $('[test-id="submit-button"]')
await $('#submit-button')

Note: To find out all the possible selectors WebdriverIO supports, checkout our Selectors page.

Limit the amount of element queries

Every time you use the $ or $$ command (this includes chaining them), WebdriverIO tries to locate the element in the DOM. These queries are expensive so you should try to limit them as much as possible.

Queries three elements.

// 👎
await $('table').$('tr').$('td')

Queries only one element.

// 👍
await $('table tr td')

The only time you should use chaining is when you want to combine different selector strategies. In the example we use the Deep Selectors, which is a strategy to go inside the shadow DOM of an element.

// 👍
await $('custom-datepicker').$('#calendar').$('aria/Select')

Prefer locating a single element instead of taking one from a list

It isn't always possible to do this but using CSS pseudo-classes like :nth-child you can match elements based on the indexes of the elements in the child list of their parents.

Queries all table rows.

// 👎
await $$('table tr')[15]

Queries a single table row.

// 👍
await $('table tr:nth-child(15)')

Use the built-in assertions

Don't use manual assertions that do not automatically wait for the results to match as this will cause for flaky tests.

// 👎
expect(await button.isDisplayed()).toBe(true)

By using the built-in assertions WebdriverIO will automatically wait for the actual result to match the expected result, resulting in resilient tests. It achieves this by automatically retrying the assertion until it passes or times out.

// 👍
await expect(button).toBeDisplayed()

Lazy loading and promise chaining

WebdriverIO has some tricks up it's sleeve when it comes to writing clean code as it can lazy load the element which allows you to chain your promises and reduces the amount of await. This also allows you to pass the element as a ChainablePromiseElement instead of an Element and for easier use with page objects.

So when do you have to use await? You should always use await with the exception of the $ and $$ command.

// 👎
const div = await $('div')
const button = await div.$('button')
await button.click()
// or
await (await (await $('div')).$('button')).click()
// 👍
const button = $('div').$('button')
await button.click()
// or
await $('div').$('button').click()

Don't overuse commands and assertions

When using expect.toBeDisplayed you implicitly also wait for the element to exist. There isn't a need to use the waitForXXX commands when you already have an assertion doing the same thing.

// 👎
await button.waitForExist()
await expect(button).toBeDisplayed()

// 👎
await button.waitForDisplayed()
await expect(button).toBeDisplayed()

// 👍
await expect(button).toBeDisplayed()

No need to wait for an element to exist or be displayed when interacting or when asserting something like it's text unless the element can explicitly be invisible (opacity: 0 for example) or can explicitly be disabled (disabled attribute for example) in which case waiting for the element to be displayed makes sense.

// 👎
await expect(button).toBeExisting()
await expect(button).toHaveText('Submit')

// 👎
await expect(button).toBeDisplayed()
await expect(button).toHaveText('Submit')

// 👎
await expect(button).toBeDisplayed()
await button.click()
// 👍
await button.click()

// 👍
await expect(button).toHaveText('Submit')

Dynamic Tests

Use environment variables to store dynamic test data e.g. secret credentials, within your environment rather than hard code them into the test. Head over to the Parameterize Tests page for more information on this topic.

Lint your code

Using eslint to lint your code you can potentionally catch errors early, use our linting rules to make sure that some of the best practices are always applied.

Don't pause

It can be tempting to use the pause command but using this is a bad idea as it isn't resilient and will only cause for flaky tests in the long run.

// 👎
await nameInput.setValue('Bob')
await browser.pause(200) // wait for submit button to enable
await submitFormButton.click()

// 👍
await nameInput.setValue('Bob')
await submitFormButton.waitForEnabled()
await submitFormButton.click()

Async loops

When you have some asynchronous code that you want to repeat, it is important to know that not all loops can do this. For example, the Array's forEach function does not allow for asynchronous callbacks as can be read over on MDN.

Note: You can still use these when you do not need the operation to be synchronous like in shown in this example console.log(await $$('h1').map((h1) => h1.getText())).

Below are some examples of what this means.

The following will not work as asynchronous callback are not supported.

// 👎
const characters = 'this is some example text that should be put in order'
characters.forEach(async (character) => {
await browser.keys(character)
})

The following will work.

// 👍
const characters = 'this is some example text that should be put in order'
for (const character of characters) {
await browser.keys(character)
}

Keep it simple

Sometimes we see our users map data like text or values. This often isn't needed and is often a code smell, check the examples below why this is the case.

// 👎 too complex, synchronous assertion, use the built-in assertions to prevent flaky tests
const headerText = ['Products', 'Prices']
const texts = await $$('th').map(e => e.getText());
expect(texts).toBe(headerText)

// 👎 too complex
const headerText = ['Products', 'Prices']
const columns = await $$('th');
await expect(columns).toBeElementsArrayOfSize(2);
for (let i = 0; i < columns.length; i++) {
await expect(columns[i]).toHaveText(headerText[i]);
}

// 👎 finds elements by their text but does not take into account the position of the elements
await expect($('th=Products')).toExist();
await expect($('th=Prices')).toExist();
// 👍 use unique identifiers (often used for custom elements)
await expect($('[data-testid="Products"]')).toHaveText('Products');
// 👍 accessibility names (often used for native html elements)
await expect($('aria/Product Prices')).toHaveText('Prices');

Another thing we sometimes see is that simple things have an overcomplicated solution.

// 👎
class BadExample {
public async selectOptionByValue(value: string) {
await $('select').click();
await $$('option')
.map(async function (element) {
const hasValue = (await element.getValue()) === value;
if (hasValue) {
await $(element).click();
}
return hasValue;
});
}

public async selectOptionByText(text: string) {
await $('select').click();
await $$('option')
.map(async function (element) {
const hasText = (await element.getText()) === text;
if (hasText) {
await $(element).click();
}
return hasText;
});
}
}
// 👍
class BetterExample {
public async selectOptionByValue(value: string) {
await $('select').click();
await $(`option[value=${value}]`).click();
}

public async selectOptionByText(text: string) {
await $('select').click();
await $(`option=${text}]`).click();
}
}

Executing code in parallel

If you do not care about the order in which some code is ran you can utilise Promise.all to speed up the execution.

Note: Since this makes the code harder to read you could abstract this away using a page object or a function, although you should also question if the benefit in performance is worth the cost of readability.

// 👎
await name.setValue('Bob')
await email.setValue('bob@webdriver.io')
await age.setValue('50')
await submitFormButton.waitForEnabled()
await submitFormButton.click()

// 👍
await Promise.all([
name.setValue('Bob'),
email.setValue('bob@webdriver.io'),
age.setValue('50'),
])
await submitFormButton.waitForEnabled()
await submitFormButton.click()

If abstracted away it could look something like below where the logic is put in a method called submitWithDataOf and the data is retrieved by the Person class.

// 👍
await form.submitData(new Person('bob@webdriver.io'))

Welcome! How can I help?

WebdriverIO AI Copilot