Part 5 - Search Results Test on Web
Now let's start writing the test for the search results scenario that we have in our search.feature
file.
Feature: Search Books
Scenario: A user should be able to search for books
When the user searches for a book named "testing"
Then the search results should display "testing" books
We can start adding code in the search.steps.ts
file to be able to search for books.
Locators
We would first need to find the locators of the elements that we want to interact with.
We can open the inspector and see that the text box has a class name search-box
.
Searching books
So now, in the search.steps.ts file, we can add write code to enter values in the search box and then press Enter
to search for the books.
The code looks like below.
import { When } from '@wdio/cucumber-framework';
When(/^the user searches for a book named "(\w+)"$/, async (searchTerm: string) => {
const searchBoxLocator = '.search-box';
const searchBox = await driver.$(searchBoxLocator);
await searchBox.setValue(searchTerm);
await driver.keys('Enter');
});
A note about async await
Asynchronous programming is a technique that allows our programs to be responsive even while running some other tasks. More details are present in this MDN doc.
Async functions always return a Promise. Await can be used inside async functions.
Await can temporarily pause the execution of the asynchronous function until some other promise is fulfilled or rejected.
In our code above, we use awaits for each step so that the promise is fulfilled before we proceed. Without these awaits, in the example above, there could be a possibility that the code hits Enter
before setting the value in the search box.
Here is short video on How does async/await work with Typescript? by Doug Stevenson that explains some of the async/await concepts.
Responsibilities
Currently, the logic for searching for a book is present in the search.steps.ts file. Should the step definition have the logic of how to search for a book?
Probably not. The responsibility of how
to perform an action could lie with a class that represents the page on the UI.
So, the responsibility of how to search for a book could lie with the class that represents this Home page on UI.
So, let's first create a folder pages/web
and create a class home.page.ts
that represents the home page on the UI.
The home.page.ts
file could now have a function searchBooks
that has the code for how to search for books. Let's move the code from step definition to the home page class.
export default class HomePage {
async searchBooks(searchTerm: string): Promise<void> {
const searchBoxLocator = '.search-box';
const searchBox = await driver.$(searchBoxLocator);
await searchBox.setValue(searchTerm);
await driver.keys('Enter');
}
}
Now in the search.steps.ts
step definition file, we can create an instance of the WebHomePage class and call the searchBooks
method.
import { When } from '@wdio/cucumber-framework';
import HomePage from '../../src/pages/web/home.page';
When(/^the user searches for a book named "(\w+)"$/, async (searchTerm: string) => {
const homePage = new HomePage();
await homePage.searchBooks(searchTerm);
});
Page Object Pattern
What we did above is an example of Page Object Pattern.
This pattern states that:
Classes represent page or UI components
Methods in these classes are the services that are offered by the page or UI component
In our case, the home page offers the service of searching for books. So, the home page class contains the searchBooks
method.
The page also contains "how" to perform those actions.
Now, a lot of posts say that the methods in the page classes should return the next page that is opened. So that would mean that the searchBooks
method should return an object of the SearchResultsPage
(since that's the page that is opened after searching for books.
There is a talk by Titus Fortner called Page Objects: You're doing it wrong where he advices against return page objects in the methods to prevent tight coupling between pages. I'm also leaning towards not returning page objects from these methods. So, we will leave the method with Promise<void>
as the return type.
Element helper
Let's take a look again at the searchBooks
method in the home page.
We are trying to find the searchBox before entering values in it.
const searchBox = await driver.$(searchBoxLocator);
Sometimes, if the network conditions are slow, there is a chance that this can't find the element. So, it might be a good idea to explicitly wait for the element to be displayed before trying to set values. We can add the wait as below and set some value as the timeout value:
const searchBox = await driver.$(searchBoxLocator);
await searchBox.waitForDisplayed({ timeout: 2000 });
Waiting for an element to be displayed might be something that is required by many different methods. We could extract this as a common helper function.
We can first create a class called helpers/element.helper.ts
.
We can then move the common code of waiting for an element to be displayed in this helper. Also, instead of hardcoding the timeout, we can use the constants WaitTime
.
WaitTime constants is as below:
export const enum WaitTime {
Small = 2000,
Medium = 5000,
Large = 10000,
}
And the element.helper.ts
can now use the WaitTime constant and have a generic method to findElement
.
import { WaitTime } from '../constants/wait-time';
export async function findElement(locator: string, timeout = WaitTime.Small) {
const element = await driver.$(locator);
element.waitForDisplayed({ timeout });
return element;
}
The home.page.ts
class can now make use of the findElement
helper method. So the code would look like this:
import { findElement } from '../../helpers/element.helper';
const searchBoxLocator = '.search-box';
export default class HomePage {
async searchBooks(searchTerm: string): Promise<void> {
const searchBox = await findElement(searchBoxLocator);
await searchBox.setValue(searchTerm);
await driver.keys('Enter');
}
}
Adding step for checking the search results
Let's say that we want to get all the titles on the search results page and assert that they contain the search term.
So, we would need a method to get the titles from the search results. The responsibility of getting the titles could be with a class that represents the search results page on the UI.
So, let's first create a search-results.page.ts
class in the pages/web
folder.
This class should have a method getSearchResultTitles
to get all the titles. The class will currently look like this.
export default class SearchResultsPage {
async getSearchResultTitles(): Promise<string[]> {
}
}
There will be errors since we are not currently returning the array of titles.
Now, we want to find all the title elements and then get their text. Finding all elements with a locator can also be a generic function that is used in different places. So, we can enhance element.helper.ts
to have another method to findElements
.
What can also create a function to waitForElement
to avoid code duplication.
The code in element.helper.ts
can look like this.
import { WaitTime } from '../constants/wait-time';
export async function findElement(locator: string, timeout = WaitTime.Small) {
await waitForElement(locator, timeout);
return driver.$(locator);
}
export async function findElements(locator: string, timeout = WaitTime.Small) {
await waitForElement(locator, timeout);
return driver.$$(locator);
}
async function waitForElement(locator: string, timeout = WaitTime.Small): Promise<void> {
const element = await driver.$(locator);
await element.waitForDisplayed({ timeout });
}
Now, we can add code in the search-results.page.ts
to get all the titles from the page.
We will need to iterate over all the title elements to getText
. This would give us an array of Promise<string>
(since getText is an async function). To get an array of string
, we will have to wait for all the promises to be resolved.
The code is as shown below:
import { findElements } from '../../helpers/element.helper';
const bookTitleLocator = '.book-title';
export default class SearchResultsPage {
async getSearchResultTitles(): Promise<string[]> {
const titleElements: WebdriverIO.Element[] = await findElements(bookTitleLocator);
const titlePromises = titleElements.map(async element => element.getText());
return Promise.all(titlePromises);
}
}
Now, in the search-results.steps.ts
, we can get all the titles and check that the titles contain the search term.
import { Then } from '@wdio/cucumber-framework';
import SearchResultsPage from '../../src/pages/web/search-results.page';
Then(/^the search results should display "(\w+)" books$/, async (expectedTitle: string) => {
const searchResultsPage = new SearchResultsPage();
const actualTitles = await searchResultsPage.getSearchResultTitles();
expect(actualTitles.length).toBeGreaterThan(0);
expect(actualTitles.every(title => title.toLowerCase().includes(expectedTitle))).toBe(true);
});
Running the tests
Let's run the web test run command and check if the tests pass. If they pass, the reports will be green.
Test the test
It's also important to make the test fail on purpose to check if it will really fail when it needs to. Let's change the number of expected titles to a large number. We can see that the test fails as expected.
Video reporter
When there are failures, it could be useful to have some screen grabs or videos for debugging. We can add video reporter to wdio-web-chrome-conf.ts
.
We can mention that the screen captures can be stored in the artifacts
directory.
import * as video from 'wdio-video-reporter';
...
config.reporters?.push(
[
video,
{
saveAllVideos: false,
videoSlowdownMultiplier: 5,
videoRenderTimeout: 5,
outputDir: 'artifacts',
},
]
);
With this, when the tests fail, we will be able to see the screen recording in the artifacts directory.
Current code structure
We have feature files that have corresponding step-definitions.
The step-definitions contain "what" needs to be done and assertions.
We have page classes that represent the pages on the UI. The pages contain "how" to perform the actions.
We have helpers for common helper functions that could be reused in multiple places.