Tackling asynchronous integration testing in Senses

It’s been well over a year since any public updates about Senses 2 have been posted. In the mean time we have worked hard and we were able to release Senses 2 to Android, iOS and really soon, web.

In this blog, I’ll describe how we’ve set up our integration tests. We rely heavily on our integration tests as part of our CI/CD process and with multiple deployments and millions of log ins per day you can imagine the importance integration tests have for us.

What’s an integration test?

In our app every team is responsible for a small part of the application. Integration tests are tests that check if two or more independent parts work well together. This is maybe best explained by a gif where this goes wrong:

In this blog I will discuss browser based integration tests. We have many front-end components that need to interact correctly with each other. For example we have a user preference component that determines which features are shown on the dashboard.

Those familiar with the testing pyramid might argue that this sounds more like end-to-end testing, but that’s not true since we are mocking our back-end and solely focusing on the interaction of front-end components.

How do we run/write integration tests?

We want to test all our front-end components on a wide range of browsers and mobile devices. To accomplish that we have chosen WebdriverIO v5, which also has a mature ecosystem of plugins.

Because it is based on Selenium, it is slower than newer competitors such as Cypress.io, but the range of browsers and devices we can test on is much wider and it supports web components.

Challenges with integration tests ?

Our previous integration test setup had some difficulties we needed to solve:

1. We did not have a way to wait for all the asynchronous components on a page
2. Our Page Objects were inconsistent
3. We did not have firm rules and best practices enforced
4. Some browsers are unstable
5. We did not know which tests were a little flaky and which are flatout unstable
6. Execution of our test suite took a long time

The building blocks of browser-based integration tests ?

To write integration tests you need a couple of things:
1. A test file (also called a spec file) where you run your tests. For example, retrieve element ‘my-button’ on the DOM, then click it and I expect something to happen.
2. To keep your code DRY and readable it is wise to use an abstraction of the DOM for each page you are going to test. This is called a Page Object

I will introduce both in the next sections:

Page and Component Objects
From a strictly technical perspective, what we call Page Objects and Component Objects, both belong to the Page Object Pattern which is one of the best ways to keep your test code DRY.

In our set-up a Page Object represents a full page and a Component Object represents a Feature (such as the Bank Account Component) or a UI Component (such as the Navigation bar).

Each Page Object consists out of one or more Component Objects: a Dashboard Page has Bank Account Component and a Navigation Bar.

Spec files
The Page Objects are used in spec files which are actually run by WDIO, see the official docs for an example.

Tackling concurrency / timing issues ⌚

Before we continue it’s important to stress the asynchronous nature of our app. On a given page, we have Web Components (loaded async), Angular 7 components (potentially async) and AngularJS components in a compatibility layer (loaded async).

To give an example: on our banking dashboard we want to make sure that navigation, bank accounts (async), credit cards (async) and personal loans (async) are all done loading.

We need to make sure that every component on a page is rendered before we kick off our tests. To do so we require every Component Objects to implement a rendermethod that signifies the component to be fully rendered. This render Promise resolves when the last DOM node is rendered.

Pro tip: use 6x CPU slowdown in Dev Tools to determine which element renders last.

This is how the Component Object looks in its most simple form:

// ./objects/features/bank-account.ts
export class BankAccountObject {
  // internal id
  componentName = 'bank-account';

  // this is the root element: <my-bank-account />
  element = 'my-bank-account';

  // this is a (deep) element that renders latest 
  elementToRender = '#async-fetch-bank-account-balance';

  render = async () => {
    // retrieve 'my-bank-account #async-fetch-bank-account-balance'
    const checkRenderedElement = await $(`${this.element} ${this.elementToRender}`);

    // make sure that the element exists on the DOM within 20s
    await checkRenderedElement.waitForExist(20000);
  };
}

When we want to make sure that the page is fully loaded we group all these Promises together in an array on the Page Object. The Page Object itself has a pageWaitUntilRendered method which calls Promise.all() with the array of Promises.

// ./objects/pages/dashboard-page.ts
import { BankAccountObject } from '../objects/features';
import { NavigationObject } from '../objects/ui-components';

export class DashboardPageObject {
  // the root element for the page <dashboard-page />
  element = 'dashboard-page';
  // all registered features and UI components
  features = [
      new BankAccountObject(), 
      new NavigationObject(),
      // ... other components on the page
  ];
  // url for the page
  url: string = '/dashboard';

  async waitUntilRendered(): Promise<any> {
    try {
      await this.verifyRenderRequirements();

      const promises = this.features.map(feature => feature.render);

      return Promise.all(promises);
    } catch (e) {
      return Promise.reject(e);
    }
  }
  
  verifyRenderRequirements() {
    if (this.features.length === 0) {
      return Promise.reject(`${this.element} has no registered features or page render requirements`);
    }
  }
}

In our spec file, we await the page.waitUntilRendered() method after which we can safely assume that all components are rendered and we can kick off the tests.

// ./spec/dashboard-page.spec.ts
import { dashboardPage } from '../objects/pages/';

describe('Dashboard page', () => {
  it('should render all features', async () => {
    await browser.url(dashboardPage.url);
    const isRendered = await dashboardPage.waitUntilRendered();

    // at this point, the page is rendered and we can safely kick off all our tests
    expect(isRendered).toBe(true);
    // more tests!
  })
});

Making Page Objects and Component Objects scalable

In the previous section we already touched on some of the methods that allow us to wait for a page to be fully loaded. We want to prevent each Page Object and Component Object to implement these methods individually. That would potentially lead to diverging implementations.

We decided that it is wise to abstract the main use case. This allows for a better developer experience and more standardization. This keeps the architecture scalable and maintainable.

To create this abtraction we provide the base classes PageObjectBase and ComponentObjectBase:

// ./objects/base/page-object-base.ts
export interface IComponentObject {
  [key: string]: any;
  isRendered(): Promise<any>;
}

export abstract class PageObjectBase {
  element: string;
  features: IComponentObject[];
  url: string;

  async waitUntilRendered(): Promise<any> {
    try {
      await this.verifyRenderRequirements();

      const promises = this.features.map(feature => feature.render);

      return Promise.all(promises);
    } catch (e) {
      return Promise.reject(e);
    }
  }
  
  verifyRenderRequirements() {
    if (this.features.length === 0) {
      return Promise.reject(`${this.element} has no registered features or page render requirements`);
    }
  }
}

Using these base classes we can refactor our previous examples to simple implementations:

// ./objects/pages/dashboard-page.ts
import { BankAccountObject } from '../features';
import { NavigationObject } from '../ui-components';
import { PageObjectBase } from '../base';

class DashboardPageObject extends PageObjectBase {
  element = 'dashboard-page';
  features = [new BankAccountObject(), new NavigationObject()];
  url: string = '/dashboard';
}

export const DashboardPage = new DashboardPageObject();
// ./objects/features/bank-account.ts
import { ComponentObjectBase } from '../base';

export class BankAccountObject extends ComponentObjectBase {
  componentName = 'bank-account';
  element = 'my-bank-account';
  elementToRender = '[data-test="async-fetch-bank-account-balance"]';
}

The nice thing is that the team responsible for maintaining the test architecture can extend these base classes with more advanced features such as:

  • Prefixing a parent selector to other selectors which is useful if you have multiple pages in the DOM and thus multiple instances of (UI) component
  • Adding configuration to Component Objects to indicate visibility on certain viewports
  • Runtime exclusion of registered features on pages which is useful after certain flows.
    Example: if a user indicates preference to hide creditcards, they are hidden from dashboard. And the dashboard page should not wait for the credit card to load
  • Runtime addition of extra promises to wait for
    Example: we need to make a POST request before kicking off the tests

You can find the extended base classes using these links: Extended Page ObjectExtended Component Object.

About the author

Patrick Sevat
Senior Frontend Developer

Patrick is crazy about JS/TS and has the small ambition to transform Rabobank from a bank with an IT department to a tech company with a banking license.

Related articles

Tips for writing Page Objects and Component Objects

  • 16 September 2021
  • 1 min
By Patrick Sevat

Optimize your git clone / fetch strategy for CI pipelines

  • 15 September 2021
  • 1 min
By Patrick Sevat