How we automated generating and storing selectors for Cypress

By Dmitry Kryaklin, Technical Director of Special Projects

Introduction

In 2021, Bravado grew at a rapid pace: the technical team, the codebase, the complexity of our project, etc. The reliability requirements grew as well. We knew that if we want to increase the speed of development and delivery, we have to adopt a modern solution for tests automation.

By that time, the team already had experience writing different kinds of tests. The current speed of adding new tests left much to be desired, and we wanted to try something new, which could significantly improve the situation. At the end of the year, we decided to adopt the Cypress framework.

Problem

Initial integration with Cypress went smoothly. But when we started writing the first tests in Cypress, we ran into a problem: how we should store selectors for elements. The problem is that selectors for elements are just strings, and we wanted to make sure we have only one source of truth.

We started to create a lot of constants and tried to reuse them in both tests and components. But this solution was slow and created a lot of hassle. So we decided we will just copy selectors in tests. Yes, we lost the advantage of having a single source of truth. But now we can move forward faster.

After some time we ended up having several big objects with a list of selectors for most of the pages:

export const FEED_CLASSES = {
  feedTab: '[data-test=posts_header_filter]',
  poolTitle: '.war-room-post-poll',
  postItem: '[data-test=posts_list_preview]',
  postItemPinnedClass: '.war-room-post-preview-presenter--pinned',
  postItemNewPostIconClass: '.war-room-post-preview-presenter__new-post-icon',
  postItemLinkClass: '.war-room-post-preview-presenter__link',
  postCategory: '[data-test=post_preview_topic]',
  feedIconMobile: 'a[href="/war-room/posts/tab/my_feed"]',
  academyIconMobile: '.user-menu-mobile-main-menu > [href="/academy"]',
  academyIconWeb: '[href="/academy"] > .bravado-right-navigation__item-text',
  jobsIconWeb: '[href="/jobs"] > .bravado-right-navigation__item-text',
  jobsIconMobile: '.user-menu-mobile-main-menu > [href="/jobs/profile"]',
  logIniconMobile: 'a[href="/war-room/posts/tab/my_feed?dynamic_modal=sign-in"]',
  raiting: '[data-test=post_preview_rating]',
  upvoteButton: '[data-test=post_preview_vote_up]',
  downvoteButton: '[data-test=post_preview_vote_down]',
};

We understood that this solution is unstable, the selectors are not standardized, it is very difficult to maintain and we cannot know which selectors are still relevant or not. And more importantly, it was a nightmare to reuse them in different tests. So we needed a better solution if we want to continue to expand tests coverage.

Solution

As technical director, my duties included, among other things, the integration and adoption of Cypress among our teams. I was worried that even with Cypress we couldn't write tests as fast as we wanted. And I started looking for a solution.

After several days of researching various solutions, I could not find the one that was right for us. But I started to realize what exactly I want to achieve: instead of copy-paste selector strings from the main codebase to tests by hand, I wanted to extract all selectors from our codebase automatically so we can reuse them in our specs.

Then we could wrap extracted data in a special wrapper to create JavaScript objects with full TypeScript support. Which would allow us to write tests in a declarative style and to get all benefits from proper program language, like auto completion and type checking. And in theory might allow us to validate tests even without running them, thanks to TypeScript.

e.g. Let's say have a component Header with the template:

<template>
  <div class="header">
    <template v-if="user">
      <button data-test="signin-button">Sign In</button>
    </template>
    <template v-else>
      <span data-test="username-label">Hello, {{ user.username }}!</span>
      <button data-test="logout-button">Logout</button>
    </template>
  </div>
</template>

In an ideal world I wanted to get something like that:

import Header from '/cypress/components/Header.test.ts';

Header.SigninButton().click();
Header.UsernameLabel().click();
Header.LogoutButton().click();

After a discussion with the team, everyone agreed that the idea sounded promising and I decided to build POC. I needed to implement three components on different parts of our app in order to achieve what I wanted:

1. Write a parser for our components that will extract templates and generate JavaScript objects for selectors;

2. Write a special wrapper layer between generated selectors and our specs;

3. Modify the application to add a marker for DOM elements when mounting components and Cypress can find our components;

Parser

Since we are using Nuxt as the main framework (Vue underhood). Every component is standardized by internal agreements and linters. Every component has unique name. The templates inside every component are valid HTML and we can easily parse them. I've used node-html-parser to get a list of nodes with the data-test attribute.

Some nodes were other components so I also wanted to find the relation between components in order to build chain selectors e.g.:

Header.NestedComponent().click();
ConfirmDialog.ConfirmDialogFooter.closeButton().click();

I would like to keep this article short and skip the details of the implementation of this parser. When the code was ready I was able to generate JSON for each of the 700 components with the required information. Unfortunately, it was useless without a wrapper layer.

Wrapper Layer

The plan was to create a function that will receive two parameters: component selector (name) and list of nested nodes and return us a function that we can call and get Cypress.Chainable object.

Here is very simplified version:

export const BravadoComponent = function AbstractComponentWrapper<T extends Record<string, IBravadoComponent>>(
  this: IAbstractComponentWrapper,
  selector: string,
  nested?: T,
) {
  const cyWrapper = (): Cypress.Chainable => {
    return cy.get(selector);
  };

  return Object.assign(cyWrapper, { selector }, nested);
} as unknown as { new <T>(selector: string, nested?: T): IBravadoComponent & T };

As you can see there is nothing special. But it gives us the full power of TypeScript. TypeScript allows us to use autocomplete and type checking. Moreover, we can validate tests even before running them. In the full version, there are more APIs that still evolving and we adding new functionality constantly.

In order to use our parsed information for each component parser generate ComponentName.test.ts file. Here is an example:

// here is our wrapper layer with related interface
import { IBravadoComponent, BravadoComponent } from '../utils/BravadoComponent';

// fully generated dependencies if the component has nested components
import { IModal, _Modal } from './Modal.test';
import { IBravadoText, _BravadoText } from './BravadoText.test';

// here is a list of nodes extracted from component template
export interface IConfirmDialog {
  '_Modal': IBravadoComponent & IModal;
  '_BravadoText': IBravadoComponent & IBravadoText;
  '_confirmModal': IBravadoComponent;
}

// this generator function trat wraps wrapper layer, we use this generated in other components in order to create a link between components
export const _ConfirmDialog = (selector?: string): IBravadoComponent & IConfirmDialog => {
  return new BravadoComponent<IConfirmDialog>(selector || `[data-test-root-confirmdialog="ConfirmDialog"]`, {
    // for components we use generator function
    '_Modal': _Modal(``),
    '_BravadoText': _BravadoText(``),
    // for regular HTML nodes we use wrapper layer with passing selector
    '_confirmModal': new BravadoComponent(`[data-test="confirm-modal"]`),
  });
};

// this entity we are going to use inside specs
export const ConfirmDialog = _ConfirmDialog();

In order to import those test components in our specs parser script also generates an index.ts file with all generated test components:

export { Logo } from './Logo.test';
export { UnusedComponent } from './UnusedComponent.test';
export { AddToCalendar } from './AddToCalendar.test';
export { AnnoucementModal } from './AnnoucementModal.test';
export { AnnoucementModalContent } from './AnnoucementModalContent.test';
export { AudioPlayer } from './AudioPlayer.test';
export { Author } from './Author.test';
...

Finally, at this point we can start writing some code in our specs using generated test components:

import * as root from 'components/generated';

// equivalent of cy.get('[data-test-root-aboutme="AboutMe"]').should('be.visible');
root.AboutMe().should('be.visible');

// equivalent of cy.get('[data-test-root-academyarticle="AcademyArticle"] [data-test-root-bravadoimage="BravadoImage"]').should('have.length', 5);
root.AcademyArticle._BravadoImage().should('have.length', 5);

// equivalent of cy.get('[data-test-root-aboutyouform="AboutYouForm"] [data-test-root-bravadotextinput="BravadoTextInput"]').type('Hello, world!');
root.AboutYouForm._BravadoTextInput().type('Hello, world!');
And it's fully supported by TypeScript!

Here you can see how it looks like in action:

Application adjustments


Last (but not least) missing piece: we need to make sure our application while rendering components will add components names to root elements. In order to do that I decided to use Vue.mixin. When any component will be mounted on the page it will trigger the next function:

    vue.mixin({
      mounted() {
        try {
          const el = this.$el;
          if (!process.env.isProd && el && el.setAttribute && el.className) {
            const name = this.$options.name;

            if (name && !el.className.includes('v-portal')) {
              el.setAttribute(`data-test-root-${name}`, name);
            }
          }
        } catch (e) {}
      },
    });

As you can see we run this code only in the test and development environment. Moreover, we generate unique data keys since one DOM element can be root for several components.

Results

Because this is just a POC, we decided to adopt these tools only for one team. It is worth mentioning that the adaptation went very quickly because the new approach did not require any additional knowledge. After the first month of integration, we noticed the following improvements (compare with other teams):

  • Reuse of code;
  • The speed of adding new tests;
  • The reliability of tests;
  • Code maintenance time;

Here is a part of real test in our codebase:

    root.WarRoomPostForm._title().type(POST_TITLE);

    root.BravadoEditor().type(POST_CONTENT);
    root
      .BravadoEditor()
      .contains(USER.username)
      .click();

    root.WarRoomPostTopic().click();
    root.WarRoomTopic().first().click();

    root.WarRoomPostTopicModal._saveButton().click();

    root.WarRoomPostForm._saveButton().click();

    root.Notice._messageBlock().should('be.visible');

Considering the above, we believe this POC is was successful. And we have already begun to plan the next part of the improvements for the 1.0 version.

Here are a few ideas that we want to implement in the near future:

  • Automatically regenerate components on every commit;
  • Run TS check for tests before every commit;
  • Deeply integration with Cypress;
  • Various API improvements;

We would like to release this as an open-source project in near future. So please stay with us and wait for the update :)

PS. special thanks to my team who supported this project, shared ideas, and helped with testing and API suggestions.