In this article, I will show you how you can adapt the Page Object Pattern in conjunction with a naming convention to save you a ton of time while writing your tests. First we start off with a naming convention and then how we can use Page Object Pattern to our advantage.
Have a look at the end result of the tests.
Be consistent with the naming of your Attribute
This is pretty much a no-brainer, but becomes very important the bigger the project is. If you utilize your naming properly, you will be able to abstract your tests in a very neat way. For instance, you can build selectors based on your component names and actions. What I often do is:
<button class="registration-button"
data-test="registration-page__button-start">Register</button>
or
<button class="open-settings"
data-test="settings-page__button-open">Settings</button>
so I follow these conventions all the time, depending on the project:
// it is just important that you are consistent with it and have a system in place
<page-name>__<element-type>-<semantic-action>
or even
<page-name>-<component-name>__<element-type>-<semantic-action>
or just
<component-name>__<element-type>-<semantic-action>
This helps me to unify selectors and I do not even need to look 👀 at my markup anymore. The first example button is starting the registration process, hence start
, the second button is opening a widget/components, hence open
. I would recommend that you take the time and define your semantic action and properly communicate them with your team. Good testing starts with a solid naming convention.
If you do this you can save so much time, have a look at the next section where I show how you can utilize this even more.
Split up the test into a reusable Structure
So, with everything in coding once you start to copy your code around you might better off writing a function (I mean fair enough if you just copy one logic from one place to another, and you are sure you will not need it more often than that.) Splitting up your code into semantic logical chunks makes a lot of sense, for maintainability. And if you are going to spend your time writing end-to-end tests, you will spend 10x more on it if you have a huge project, which does not share any logic.
There are two main patterns I have come across and which make more or less sense for certain apps. One is the Page Object Pattern and the other is the Function Object Pattern (at least I call it like this 😅). However, in this article I just show the Page Object Pattern, let me know if you are interested in a more function based approach. 😎
Page Object Pattern
Okay, so the Page Object Pattern is a way of semantically grouping page elements into classes. It is often frowned up on, because it can add a layer of complexity to a smaller project. However, classes do have their space, espacially if the project gets big. Let me show you how I utilize classes in a way to save you a ton of time and improve readability.
Let's say we have two components, which represent both a form, both can be opened and both have submitted buttons and error messages. So, without having seen the component, we already can picture what the test will need to do:
- Open the Component (if it is closed) or simply scroll to it
- fill out the required input with false data
- submit and evaluate if error message(s) are displayed
- fill out with correct data
- submit and evaluate the success message(s)
In fact, pretty much all form components do this! So we can abstract all of it into a base class like this:
/**
 * The Widget Test Interaction Page Object - abstract class!!
 */
class FormPageObject {
 defaultOpenSelector = '';
 defaultSubmitUrl = '';
 defaultSubmitButtonSelector = '';
 defaultInterceptName = '';
 defaultSelectorForStringInputChange = '';
 defaultCloseSelector = '';
 defaultMessageSelector = '';
 /**
  * Opens the widget
  */
 open(
  selector = this.defaultOpenSelector,
  options = {},
  inputSelector = ''
 ) {
  return cy
   .$(selector, options, inputSelector)
   .click({
    force: true,
   })
   .scrollIntoView({ offset: { top: -100, left: 0 } });
 }
 /**
  * Close the  widget
  */
 close(
  selector = this.defaultCloseSelector,
  options = {},
  inputSelector = ''
 ) {
  return cy.$(selector, options, inputSelector).click({
   force: true,
  });
 }
 /**
  * The intercept will register with the default name and url and will look for the next request
  * hence you can wait for it. By default it is expecting that the response will work.
  * You can also force a success true
  */
 interceptSubmit(
  forceSuccess = false,
  forceFailure = false,
  shouldSucceed = false,
  inputUrl = '',
interceptName = this.defaultInterceptName,
 ) {
  const url = inputUrl ?? this.defaultSubmitUrl;
  cy.intercept('POST', url, (req) => {
   req.continue((res) => {
    if (forceSuccess) {
     res.body.success = true;
    } else if (forceFailure) {
     res.body.success = false;
    } else if (!res.body.success && shouldSucceed) {
     expect(
      res.body.success,
      'It was not possible to change the the widget and it was expected that it would be possible'
     ).to.be.true;
    }
    return res;
   });
  }).as(interceptName);
 }
 updateStringInput(
  input = '',
  selector = this.defaultSelectorForStringInputChange
 ) {
  let newString = '';
  if (input === '') {
   return cy.stringInput(selector, input);
 }
 submit(
  selector = this.defaultSubmitButtonSelector,
  options = {},
  inputSelector = ''
 ) {
  cy.$(selector, options, inputSelector).click({
   force: true,
  });
 }
 submitValidator(req, messageSelector = this.defaultMessageSelector) {
  cy.isRequestValid(req);
  const correctClass = req.response.body.success
   ? 'alert-success'
   : 'alert-danger';
  cy.checkForClass(
   correctClass,
   messageSelector,
   `The submit request returned ${req.response.body.success}, but the message had not this class ${correctClass}`
  );
 }
}
I abstracted the code and used some of my own helper methods like cy.$
or cy.stringInput
. (If you are interested how I build my helpers methods let me know in the comments) The good thing about this abstract class is that now we can use it like this:
class FormComponentA extends FormPageObject {
 defaultOpenSelector = 'form-componentA__button--open';
 defaultSubmitUrl = /regexForSubmitUrl/;
 defaultSubmitButtonSelector = 'form-componentA__button--submit';
 defaultInterceptName = 'formComponentASubmit';
 defaultSelectorForStringInputChange = 'form-componentA__input-text--type';
 defaultMessageSelector = 'form-componentA__message'
}
class FormComponentB extends FormPageObject {
 defaultOpenSelector = 'form-componentB__button--open';
 defaultSubmitUrl = /regexForSubmitUrl/;
 defaultSubmitButtonSelector = 'form-componentB__button--submit';
 defaultInterceptName = 'formComponentBSubmit';
 defaultSelectorForStringInputChange = 'form-componentB__input-text--type';
 defaultMessageSelector = 'form-componentB__message'
// this component needs some custom interaction, e.g. a slider
 useSlider(percentage = 0, selector = 'default-selector-for-slider') {
  cy.moveSlider(selector, percentage);
 }
}
Now we can write the actual test logic like this :
describe('test-for-component-A', () => {
 it('should fail and display error message', () => {
  const component = new FormComponentA();
  component.open();
 Â
// e.g. update name input
  component.updateStringInput('Max');
Â
// set up the interceptor and force it to fail
  component.interceptSubmit(false, true);
Â
// hit the submit button
  component.submit();
 Â
// wait for the request and evaluate the response
  cy.wait(component.defaultInterceptName).then((req) => {
   component.submitValidator(req);
  });
 });
 it('should succeed and display success message', () => {
  const component = new FormComponentA();
  component.open();
  // set up the interceptor and force it to succeed
  component.interceptSubmit(true);
component.updateStringInput('Max');
  component.submit();
  cy.wait(component.defaultInterceptName).then((req) => {
   component.submitValidator(req);
  });
 });
});
Our tests are now very good readable 😇. I think anyone can now understand what the test is actually doing. Readable code is the first step of creating maintainable code! The cool thing is, if we want to create a test for FormComponentB
we can very quickly do so!
Coming back to my earlier statement
You should use a solid naming convention for your tests attributes can really help you utilize the power of this approach.
Because you can also do stuff like this in your base class(es):
 constructor(componentName, interceptRegex) {
  this.defaultOpenSelector = `.${componentName}__button-open`;
  this.defaultSubmitUrl = interceptRegex;
  this.defaultSubmitButtonSelector = `.${componentName}__button--submit`;
  this.defaultInterceptName = `${componentName}Intercept`;
  this.defaultSelectorForStringInputChange = `.${componentName}__input-text--type`;
  this.defaultMessageSelector = `.${componentName}-message`;
 }
So now you do not even need to write a new class and can use the abstract FormClass
directly. 🔥🔥🔥
const component = new FormPageObject('form-componentB');
LET ME KNOW 🚀🚀🚀
- Do you need help, with anything written above?
- What will be your first Image? 😄
- Do you think I can improve - then let me know
- Did you like the article? 🔥
Top comments (0)