Simplifying the E2E selectors hassle

Easy, type-safe, unique & hassle-free element test selectors using just TypeScript.

vojtoVojtech MašekCTO & Angular GDE | FlowUp

robot-watering-cypress-tree

Unique element identification for tests

When writing tests and ensuring they will continue to work even when the markup within an Angular component changes, one of the best options is eliminating the dependence on HTML markup via specificity selectors and using unique identifiers. You still need to maintain the checks for actual content and logic, but you will see a significant reduction in maintaining the specificity selectors. They may look as easy as putting the id attribute onto the HTML elements. This is reasonable, but there are established good practices to separate concerts of id attribute and test or automation specific identifiers. A good solution is to pick one of the common custom data attributes widely used in the testing community. The most common examples would be:

  • data-test-id

  • data-qa

  • data-cy

These attributes are even prioritized when using Chrome dev tools recorder.

Storing the test IDs

In an ideal world, you should store the IDs somewhere as constants to make sure both your app and tests use the same identifiers.

The easiest approach would be a simple object split into pages and components that could look like this example:

export const E2E_IDS = {   landing: {     loginButton: 'login-btn',   },   toolbar: {     logoutButton: 'logout-btn',   },   sidebar: {     sidebarEl: 'sidebar',     groupButton: 'group-button',     itemButton: 'item-button',   }, }

It is a good solution but we can already see some flaws:

  • There is no simple mechanism to prevent the same values of the selector.

    • We are not 100% sure everything is unique and mistakes can happen.

  • Selectors could get messy and would require some naming convention.

    • This introduces complexity to naming the selectors which again would require some check as we do not want this to be a manual process.

  • This object would end up as part of the final JS bundle shipped to production (if test IDs are kept in the prod build).

    • Not ideal as it will grow with test coverage and could weigh significant kBs in the initial page load.

These concerns can be addressed and elegantly solved with TypeScript types.

Making it all type-safe

We use TypeScript so why not take advantage of build checking if we didn’t make a typo in our element selector?

Let’s convert the object from the previous example into a type:

export type E2ETestIdMap = {   landing: {     loginButton: void,   },   toolbar: {     logoutButton: void,   },   sidebar: {     sidebarEl: void,     groupButton: void,     itemButton: void,   }, }

What did we just do here:

  • The runtime object was converted to a TS type, and now it does not exist outside of compilation (build) time.

  • The selector values were replaced by void.

    • Main reason is that we will never need a value as we will only use the keys of this object type.

  • Type ensures that there is always just one unique key per instance.

    • We can leverage this even further and say that this ensures that there is always one unique way to identify each nested key via a path to that particular key.

      • E.g. landing.loginButton

With this our selectors are:

  • Stored at a single place.

  • Shared between the main app and app-e2e test, but also accessible to all other importers like component libs.

Selectors naming & good practices

  • Top selector groups should be routed pages and reusable components.

  • It is beneficial if the last item in the chain name refers to the element type.

    • For example: xyzButton, passwordInput or toolbarElement you can also include more generic suffixes like someWrapper or someContainer to distinguish them in selectors.

  • Do not nest too much, nesting in 3-4 levels is a sign for splitting into reusable components or at least for a separate group.

Type-safe map of selectors

We can leverage the Turing completeness of TypeScript and write some helper types that will serve as a very handy mapper from the E2E IDs map to “dot notation” selectors. You don’t need to touch these types, but for the convenience of understanding what is happening there, I’ve included documentation comments. Feel free to copy-paste as is 😅.

/**  * Turns nested object into union of its keys arrays.  *  * @example  * PathsToStringProps<{  *   wololo: {  *     foo: {  *       x: '';  *       y: '';  *     };  *     bar: '';  *   };  * }> = ['wololo', 'foo', 'x'] | ['wololo', 'foo', 'y'] | ['wololo', 'bar']  */ type ObjectToPathsArraysUnion<T> = T extends object   ? {       [K in Extract<keyof T, string>]: [K, ...ObjectToPathsArraysUnion<T[K]>];     }[Extract<keyof T, string>]   : [];/**  * Turns nested object into union of its keys arrays.  *  * @example  * PathsToStringProps<{  *   wololo: {  *     foo: {  *       x: '';  *       y: '';  *     };  *     bar: '';  *   };  * }> = ['wololo', 'foo', 'x'] | ['wololo', 'foo', 'y'] | ['wololo', 'bar']  */ type ObjectToPathsArraysUnion<T> = T extends object   ? {       [K in Extract<keyof T, string>]: [K, ...ObjectToPathsArraysUnion<T[K]>];     }[Extract<keyof T, string>]   : []; /**  * Type equivalent of joining array of strings  *  * @examples  * Join<['foo', 'bar'], '.'> = 'foo.bar'  * Join<['Wololo', '!'], ''> = 'Wololo!'  */ type Join<T extends string[], D extends string> = T extends []   ? never   : T extends [infer F]   ? F   : T extends [infer F, ...infer R]   ? F extends string     ? `${F}${D}${Join<Extract<R, string[]>, D>}`     : never   : string; /**  * Type equivalent of joining array of strings  *  * @examples  * Join<['foo', 'bar'], '.'> = 'foo.bar'  * Join<['Wololo', '!'], ''> = 'Wololo!'  */ type Join<T extends string[], D extends string> = T extends []   ? never   : T extends [infer F]   ? F   : T extends [infer F, ...infer R]   ? F extends string     ? `${F}${D}${Join<Extract<R, string[]>, D>}`     : never   : string;

Usage itself is fairly straightforward. Just export the union of all possible strings and use your preferred separator (for us it’s just a dot as we like dot notation in selectors).

export type E2ETestId = Join<ObjectToPathsArraysUnion<E2ETestIdMap>, '.'>;

E2E test id directive

In Angular projects, a simple directive may be written offering better code suggestions and strong typing of IDs.

@Directive({    selector: '[e2eId]',   standalone: true, }) export class E2ETestIdDirective {   @Input({ required: true })   e2eId!: E2ETestIds; }

You may notice it does not do anything. Its only purpose is to tell Angular we are intentionally putting these e2eId attributes on elements and specify what type E2ETestIds we use for the selector suggestions.

If you want to leverage the native data attribute for Chrome to start picking element selectors automatically, you could also bind that value in a directive easily via HostBinding decorator.

@Directive({    selector: '[e2eId]',   standalone: true, }) export class E2ETestIdDirective {   @HostBinding('attr.data-qa')   @Input({ required: true })   e2eId!: E2ETestIds; }

Selector helper function for E2E tests

In the (Cypress) E2E test we can again leverage the type safety and create a simple helper to access the elements.

export const getSelectorByE2EId =    <TestId extends E2ETestId>(selector: TestId) =>      `[e2eId="${selector}"]` as const;

Having this helper we can now write simple type-safe checks like this one for a page title.

it('should display contact page title', () => {   cy.get(getSelectorByE2EId('pages.contact.title')).should(     'contain.text',     'Contact us!',   ); });

This cy.get(getSelectorByE2EId(‘...’)) could get lengthy so what you could do to improve DX further is create a custom Cypress command for get and chainable find.

declare global {  namespace Cypress {    interface Chainable<Subject> {      /**       * Get element by QA automation ID       */      getByQAId: (element: QAId) => Chainable<JQuery<HTMLElement>>; /**       * Find element within current root by QA automation ID       * If the root element does not exist, perform cy.get instead       */      findByQAId: (selector: QAId) => Chainable<JQuery<HTMLElement>>;    }  } } Cypress.Commands.add('getByQAId', selector => cy.get(getSelectorByE2EId(selector))); Cypress.Commands.add( 'findByQAId',  { prevSubject: 'optional' },  (subject, selector) => subject ? cy.wrap(subject).find(getSelectorByE2EId(selector)) : cy.get(getSelectorByE2EId(selector)), );

The usage in tests would get simplified to just:

cy.getByQAId('users.modal.window') .findByQAId('users.search')   .should( /* check */   );

Recapitulation

✅ Selectors are stored in one place.

✅ All selectors are unique.

✅ No additional bloat to JS bundles in the form of big selector object constant.

✅ Fully type-safe on both ends (element E2E ids & E2E tests element getters).

✅ Less maintenance as you are just extending one type-map by new selectors.

Can you feel the flow?

Drop us a note

hello@flowup.cz

Contact us using this form and we'll answer you via email ASAP. If you leave us your number in the message, we'll call you back. Looking forward to hearing from you!

We're based in Brno

Kopečná 980/43

Brno

602 00

Czech Republic

map