Part 8: Book Details Test on Web

Timothy is now looking to add a second test. The team members collaborate and decide that a test to check the details of the book would be a good test to add next.

Book details page is currently implemented only on web. The Android app does not have the correct functionality yet. So Timothy wants to add this test only for web.

Starting book details test

The feature file for book details test is shown below:

Feature: Book Details

  Scenario: A user should be able to view book details
    When the user searches for a book named "testing"
    And the user selects "Agile Testing" from the search results
    Then the book details should be displayed

Now, the first step the user searches for a book named "testing" is already implemented.

We now need to implement the next step the user selects "Agile Testing" from the search results.

Since this is related to search results, we can add the step in the search-results.steps.ts file.

import { When } from '@wdio/cucumber-framework';

...

When(/^the user selects "([\w\s]+)" from the search results$/, async (bookName: string) => {
});

In this step, we would want to call a method to select a book from the search results page. It might be something like this:

...
When(/^the user selects "([\w\s]+)" from the search results$/, async (bookName: string) => {
  const searchResultsPage = await PageFactory.getInstance('search-results.page') as SearchResultsPage;
  await searchResultsPage.selectBookWithTitle(bookName);
});

Since we don't have a selectBookWithTitle method in SearchResultsPage, we will see errors in this code.

Let's first create an abstract method to select a book in the parent SearchResultsPage class.

export default abstract class SearchResultsPage {
  abstract getSearchResultTitles(): Promise<string[]>;
  abstract selectBookWithTitle(title: string): Promise<void>;
}

Now, let's implement this method in web SearchResultsPage class.

In this method, we want to get all the title elements and find the element with text that matches the required title. If we do not find an element with the required title, we can throw an error. The code is shown below.

...
async selectBookWithTitle(title: string): Promise<void> {
    const titleElements: WebdriverIO.Element[] = await findElements(bookTitleLocator);
    const book = titleElements.find(async element => {
      const actualTitle = await element.getText();
      return actualTitle === title;
    });

    if (!book) {
      throw Error('Book not found');
    }

    await book.click();
  }
...

Now, we would also need to fix the compilation error on Android SearchResultsPage class by implementing the selectBookWithTitle method.

Since the functionality is not yet ready on Android, Timothy does not want to add any implementation details. So for now, we can throw an error if this method is called.

...
  async selectBookWithTitle(title: string): Promise<void> {
    throw new Error('Method not implemented.');
  }
...

Now, let's look at the search-results.steps.ts file again.

We call the selectBookWithTitle() method by passing bookName. The value of bookName is obtained based on what's mentioned in the feature file. So in our case, it is Agile Testing.

But in our search results page, there are two books which have Agile Testing in their titles.

So which one will it select? Also, if the order of search results changes, then it might select a different book.

We would want to be able to select the correct book each time.

So how do we achieve that?

One way is to have the exact title in the feature file.

...
And the user selects "Agile Testing: A Practical Guide for Testers and Agile Teams" from the search results
...

But this make the test very difficult to read. We would like to leave it as Agile Testing instead of having a long title in the feature file.

So, what can we do to make it consistent? We could potentially use test data.

Test Data

We have a file called book-details.data.ts in the data directory.

This file contains data about book details. It contains a record of keys to book details. For example, we have a key Agile Testing and it's value represents the details of this book.

export type BookDetails = {
  title: string;
  description: string;
};

export const bookDetailsData: Record<string, BookDetails> = {
  'Agile Testing': {
    title: 'Agile Testing: A Practical Guide for Testers and Agile Teams',
    description: 'In Agile Testing, Crispin and Gregory define agile testing and illustrate the tester’s role with examples from real agile teams. They teach you how to use the agile testing quadrants to identify what testing is needed, who should do it, and what tools might help.',
  },
};

So we can update the search-results.steps.ts file to use bookDetailsData.

We can get the book details by referring to the data.

...
When(/^the user selects "([\w\s]+)" from the search results$/, async (bookName: string) => {
  const bookDetails = bookDetailsData[bookName];

  const searchResultsPage = await PageFactory.getInstance('search-results.page') as SearchResultsPage;
  await searchResultsPage.selectBookWithTitle(bookDetails.title);
});

Since the bookDetailsData has the full title of the book, now a correct book will be selected.

Step to check that the book details are displayed

Now let's look to implementing the step the book details should be displayed.

We can create a book-details.steps.ts file and add the step to it.

import { Then } from '@wdio/cucumber-framework';

Then(/^the book details should be displayed$/, async () => {
});

This step needs to get the information displayed in the BookDetails screen and perform some assertions. The team discusses and thinks that the book description should be checked as part of this assertion. So, we need to write a method to get the book description.

Since book description is present in the book details page, we could create book details page classes that represent this page on the UI.

Let's first create a parent book-details.page.ts file. This would be an abstract class with an abstract getDescription() method. This method needs to return the text description.

export default abstract class BookDetailsPage {
  abstract getDescription(): Promise<string>;
}

Now, let's create the web book-details.page.ts file and implement the getDescription method.

import { findElement } from '../../helpers/element.helper';
import BaseBookDetailsPage from '../parent/book-details.page';

const bookDescriptionLocator = '.book-description';

export default class HomePage extends BaseBookDetailsPage {
  async getDescription(): Promise<string> {
    const bookDescription = await findElement(bookDescriptionLocator);
    return bookDescription.getText();
  }
}

We can now update the book-details.steps.ts file to get book description.

...
Then(/^the book details should be displayed$/, async () => {
  const bookDetailsPage = await PageFactory.getInstance('book-details.page') as BookDetailsPage;
  const actualBookDescription = await bookDetailsPage.getDescription();
});

We now want to do assertions. We want to check that the actual book description contains the expected description. But where do we get the expected description from? We could use the test data but how do we know which book's details we need?

One approach could be to modify the feature file to explicitly mention the bookName again and then get information from the corresponding test data.

Then the "Agile Testing" book details should be displayed

But, instead of repeating the book name, can we share data between steps?

Sharing context between steps

We have a test-context.helper.ts file.

This TestContext class is a singleton that can be used to hold information during a scenario run. It has a map that can be shared between different steps. We're using tsyringe for dependency injection in this case.

import { singleton } from 'tsyringe';

@singleton()
export class TestContext {
  private readonly data: Map<string, any>;
  constructor() {
    this.data = new Map();
  }

  public addTestData(key: string, value: any): void {
    this.data.set(key, value);
  }

  public getTestData(key: string): any {
    return this.data.get(key);
  }
}

To share state, one step would need to add data to the TestContext and the other step would need to read the data.

In our case, the step that needs to add data to test context is the step of selecting a book. So, let's go back to the search-results.steps.ts and add the bookName to the TestContext.

We'll first get the TestContext object and then add data.

When(/^the user selects "([\w\s]+)" from the search results$/, async (bookName: string) => {
  const bookDetails = bookDetailsData[bookName];

  const testContext = container.resolve(TestContext);
  testContext.addTestData('bookName', bookName);

  const searchResultsPage = await PageFactory.getInstance('search-results.page') as SearchResultsPage;
  await searchResultsPage.selectBookWithTitle(bookDetails.title);
});

Now that the data is added to TestContext, in the next step, we can get this data and use it for assertions.

So, we can get the bookName from TextContext, get the book details from the test data and then use it for assertions. The entire book-details.steps.ts file is as shown below:

import { Then } from '@wdio/cucumber-framework';
import { container } from 'tsyringe';
import { bookDetailsData } from '../../src/data/book-details.data';
import { TestContext } from '../../src/helpers/test-context.helper';
import PageFactory from '../../src/pages/factory/page.factory';
import type BookDetailsPage from '../../src/pages/parent/book-details.page';

Then(/^the book details should be displayed$/, async () => {
  const testContext = container.resolve(TestContext);
  const bookName = testContext.getTestData('bookName') as string;
  const expectedBookDetails = bookDetailsData[bookName];

  const bookDetailsPage = await PageFactory.getInstance('book-details.page') as BookDetailsPage;
  const actualBookDescription = await bookDetailsPage.getDescription();

  expect(actualBookDescription).toContain(expectedBookDetails.description);
});

Run the web tests

We can now run the web tests and see that they pass.

Run Android tests

Now, if we run the Android tests, the book-details.feature also runs. But this feature is not implemented for Android. So the test fails.

We want to run the book-details.feature only on web and not on Android. How do we achieve this?

We can make use of tags for this.

Tag Expressions

Tag expressions only execute features or scenarios with tags matching the expression. You can find details about tag expressions in this link.

To solve the issue of book details running on Android, we can make use of tag expressions.

Let's add a @web tag to the book-details.feature since this test needs to run on web only.

@web
Feature: Book Details

  Scenario: A user should be able to view book details
    When the user searches for a book named "testing"
    And the user selects a book from the search results
    Then the book details should be displayed

Let's add both @android and @web tags to the search.feature since this feature needs to run on both platforms.

@android @web
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 be displayed

Now, we can add tagExpression to the cucumberOpts field in wdio-shared.conf.ts file. We can mention that the tagExpression is the platform for which we are running the test. So, when we are running the web tests, the value of tagExpression will be @web. When we run the android tests, the value with be @android.

...
  cucumberOpts: {
    require: ['tests/step-definitions/**/*.ts'],
    timeout: 60000,
    tagExpression: `@${getPlatform()}`
  },
...

This is a way we can tell cucumber to run only the tests with the @${platform} tag. So, when we run Android test, only the search.feature is run. When we run web tests, both the features are run.

The Android test run will now pass.

Recap