Dai Codes

A software engineerʼs blog

Menu
Back
A photograph of a pair of spectacles on the open pages of a diary

Writing succinct Cypress page objects using a Proxy

Reducing boilerplate and repetition when writing simple page objects for Cypress tests

I've recently been working on a Cypress test suite that had a bunch of simple page objects defined using the following pattern:

// Contrived example: A page object with a bunch of getters
// that call cy.get() with a related testid when accessed:
export const GreetingPage = {
get greetingFormNameInput() {
return cy.get('[data-testid="greeting-form-name-input"]');
},
get greetingFormSubmitButton() {
return cy.get('[data-testid="greeting-form-submit-button"]');
},
get personalGreetingOutput() {
return cy.get('[data-testid="personal-greeting-output"]');
},
};

When adding a new locator to this object, I'd copy-paste an existing one, edit the value of the data-testid selector, then edit the getter name to be a camelCase conversion of the value of the target data-testid.

Repetitively typing something annoys me. Even though it was a small repetition, and it's defined in two different formats (camelCase and kebab-case), having to provide the testid value twice per locator just didn't feel right. Surely there is a way of providing a list of valid testids just once, in one place?

Enter the Proxy

We can use JavaScript Proxy objects to create dynamic getters that we can run whenever any property access is attempted on an object. When called, the getter function of the Proxy object is passed a reference to the underlying proxied object, plus the name of the property being accessed. This means we can at runtime inspect that property name, and programmatically transform it to kebab-case, then use that value in a cy.get call.

So the first step I took was to create a po-utils.ts file in the support folder of the Cypress suite with the following content:

// A simple function to convert a string
// from camelCase to kebab-case
const toKebabCase = (str: string) =>
str
.replace(/([a-z])([A-Z]+)/g, '$1-$2')
.toLowerCase();

// A Proxy that uses the above function to convert an accessed
// property name from camelCase to kebab-case, then call cy.get
// using the kebab-case string in a data-testid selector, and
// return the result:
export const PO_PROXY = new Proxy(
{},
{
get(_, name) {
const testId = toKebabCase(name.toString());
return cy.get(`[data-testid="${testId}"]`);
},
}
);

Now every page object in the suite can just be initialised to this Proxy, cast as an appropriate type. For example:

import { PO_PROXY } from './po-utils';

export const GreetingPage = PO_PROXY as {
greetingFormNameInput: Cypress.Chainable<JQuery<HTMLElement>>;
greetingFormSubmitButton: Cypress.Chainable<JQuery<HTMLElement>>;
personalGreetingOutput: Cypress.Chainable<JQuery<HTMLElement>>;
};

Now we're only declaring the valid testids once: As camelCase properties in this type.

However, there's still a bit of repetition in the defined return type that we can clear up a bit.

A bit of typescript magic

To remove that typing repetition, we can define the valid testids as a union type, then use that union in an indexed type:

import { PO_PROXY } from './po-utils';

type GreetingPageObjectProperties =
| 'greetingFormNameInput'
| 'greetingFormSubmitButton'
| 'personalGreetingOutput';

export const GreetingPage = PO_PROXY as {
[testid in GreetingPageObjectProperties]: Cypress.Chainable<JQuery<HTMLElement>>;
};

Great! Now when we want to add a new locator to this page object, we just need to add the new testid to the GreetingPageObjectProperties union type.

But.

Because the project in question conventionally used kebab-case testids, I still found it frustrating that I couldn't simply copy-paste the testids from the source HTML to this union type, but had to manually reformat them to camelCase after pasting them in.

Fortunately, TypeScript again came to the rescue, thanks to inferred template literal types, which lets us express at the type level the conversion of a string from kebab-case to camelCase.

Specifically, back in our support/po-utils.ts file, we can add the following types:

// A generic type that represents the camelCase version
// of the given kebab-case string literal type
export type KebabToCamelCase<S extends string> =
S extends `${infer T}-${infer U}`
? `${T}${Capitalize<KebabToCamelCase<U>>}`
: S;

// A generic PageObject type that uses the above to
// define an object containing camelCase properties
// for the kebab-case string literals given as a
// union for the TestIds type parameter
export type PageObject<TestIds extends string> = {
[testId in KebabToCamelCase<TestIds>]: Cypress.Chainable<JQuery<HTMLElement>>;
};

These generic types can be used in our page object definition as follows:

import { PageObject, PO_PROXY } from './po-utils';

export type GreetingPageTestIds =
| 'greeting-form-name-input'
| 'greeting-form-submit-button'
| 'personal-greeting-output';

export const GreetingPage = PO_PROXY as PageObject<GreetingPageTestIds>;

Now we can just copy-paste the kebab-case testids from the HTML into the GreetingPageTestIds union type verbatim. And here's the result, showing the typing works and intellisense in the IDE can offer us auto-completion suggestions:

A screenshot showing autocompletion suggestions in VS Code for the GreetingPage page object
Typing and intellisense working with the page object proxy.

Lovely.

An example project showing this pattern can be found in my cypress-testid-proxy GitHub repo.

Support

If you've found my blog useful, please consider buying me a coffee to say thanks:

Discuss

Comments or questions? Join the conversation on Twitter: