Introduction

Your code: it works! You've created some tests and put them in a suite; they run; they pass. Sometimes you need to debug and make some changes, but you're handling that like a champ. But sometimes you think to yourself "Gosh, it sure would be nice if I didn't have to do so much tedious typing. I have to make updates in so many places whenever there is a change!" Enter the concept of abstraction.

"What do you mean by abstraction?"

Abstraction is the general term used to describe the process of making smaller, more readable chunks of code out of larger, more complex code. In the realm of test automation, you're trying to break your code into modules that can be reused in other places, thus allowing you to update one thing in one place to affect multiple tests.

"OK, so I get it… kind of."

Imagine someone telling you that they cooked the best spaghetti they've ever eaten. Sounds intriguing, right?

"So, how do you make it?" you ask.

"I made sure to pick out pasta that was cut on bronze dies — it'll be rougher than pasta cut on steel ones, thus giving it more surface area. That increased surface area leads to starchier water. See, starch is your friend for pasta and subsequent sauces because…" says your friend.

"Wait, hold on a sec, my eyes glazed over. Have we started boiling anything yet?"

"Oh, definitely not. I want to make sure the salinity of my water is high, but not too high. Then…"

"Please just give me the short version."

"Start boiling 'good' noodles. Start heating sauce. Move noodles to sauce a few minutes before what the box says. Keep stirring until sauce clings to noodles."

Making spaghetti can be really complicated! But we can abstract out the detailed processes of buying the right spaghetti, boiling it in the correct vessel, or getting the sauce consistency correct, into their own modules to more easily understand what's going on. So, just as one doesn't need all of a recipe's intricate notes all the time, you too need not see all the exact inner workings of a test upfront. 

"OK, this kind of makes sense. Perhaps a code example?"

We're going to use an imaginary app for ordering ice cream, along with the Cypress framework to demonstrate some of these concepts. Note that this tutorial uses Cypress's selector attribute system instead of CSS selectors.

Let's pretend we want to write a very simple test — click on an ice cream flavor, then verify its description. Your test might look like this:

describe('my first test', ()=> {
    beforeEach(() => {
        cy.login()
    })  

    it('click ice cream and verify description', () => {
        cy.get('[data-cy="detailsButton_Peanut Butter"]').click()
        cy.get('[data-cy=itemDescription').should('contain', 'Our original vanilla ice cream mixed with the finest of smooth, creamy peanut butter. Choosy people choose this.')
    })
})

Not too bad, right? It builds, runs, and passes, but it's kinda hard to quickly see what's being done inside the test. What can we do to make this easier to read? Well, we could move the [data-cy="detailsButton_Peanut Butter"] to a constant in the test block, but that just adds another line to the test that we'll need to skip past to actually read what's going on. Instead, let's move it out into its own object outside of the describe() block.

const bugsysButtons = {
peanutButterDetails: '[data-cy="detailsButton_Peanut Butter"]'

Now we can refer to that in our test, and it will look like this:

    it('click ice cream and verify description', () => {
        cy.get(bugsysButtons.peanutButterDetails).click()
        cy.get('[data-cy=itemDescription').should('contain', 'Our original vanilla ice cream mixed with the finest of smooth, creamy peanut butter. Choosy people choose this.')
    })

Great! It's now very easy for us to see we're targeting a button, and that button is for the details of the peanut butter ice cream. The itemDescription attribute can also be moved into its own object.

"Neat! But can we go further?"

How about we get rid of the whole cy.get().click() command and instead just say clickThing()? We've laid the groundwork for this already by separating out the details and itemDescription button attributes into their own objects. Now would be a great time to add a custom command to the /support/commands.ts file.

Note: Because the app uses Typescript we have to follow certain rules that are different from vanilla javascript + Cypress. In this case, we have to have this declare namespace Cypress block instead of simply having only a Cypress.Commands.add block.

declare namespace Cypress {
  interface Chainable {
    /**
     * Custom command to click on a thing.
     * @example cy.clickThing('[data-cy=thing]')
     */
    clickThing(thing: string): Chainable<Element>
  }
}

Cypress.Commands.add('clickThing', (thing) => {
    cy.get(thing).click()
})

So now our test looks even simpler and easier to read. As you can see, the itemDescription has been moved to its own object.

    it('click ice cream and verify description', () => {
	    cy.clickThing(bugsysButtons.peanutButterDetails)
	    cy.get('textElements.iceCreamDescription').should('contain', 'Our original vanilla ice cream mixed with the finest of smooth, creamy peanut butter. Choosy people choose this.')
    })

Now, what if we had another command to verify some text exists? We can move this verification into its own command and, just as we've done with the data-cy attributes, move the description text to its own object. It's very common to move strings into their own object or file so that any future changes to those strings need only be updated in a single place.

declare namespace Cypress {
  interface Chainable {
    /**
     * Custom command to click on a thing.
     * @example cy.clickThing('[data-cy=thing]')
     */
    clickThing(thing: string): Chainable<Element>

    /**
     * Custom command to verify text exists.
     * @example cy.verifyTextExists(element, text)
     */
     verifyTextExists(element: String, text: String): Chainable<Element>
  }
}

Cypress.Commands.add('clickThing', (thing) => {
    cy.get(thing).click()
})

Cypress.Commands.add('verifyTextExists', (elementToVerify, text) => {
    cy.get(elementToVerify).should('contain', text)
})

And our test now looks like this:

    it('click ice cream and verify description', () => {
        cy.clickThing(bugsysButtons.peanutButterDetails)
        cy.verifyTextExists(textElements.iceCreamDescription, iceCreamDescriptions.peanutButter)
    })

Recap

So what have we learned? So far we've:

  1. Made HTML attributes easier to read
  2. Moved long strings out of the test code and into their own space
  3. Created custom commands for repeatability

That's great and all, but let's discuss some of the drawbacks.

We noted above that because this particular example uses Typescript, we had to do something different (read: more complicated) in the commands file than what the basic custom commands documentation says to do. If you didn't know about this, you'd have to spend precious time figuring this out, not to mention there is, overall, more typing and lines of code required.

Also, and perhaps more importantly, consider that when a test fails, you might have to dig through the test itself, then the command, then the object, and then the value of the object. This can then be complicated further if you stored your objects in their own files in an effort to de-clutter your test file. In other words, the further you abstract, the more tedious debugging can get. Try to strike a balance of abstraction with readability; only you can decide where that line exists.

Similarly, your functions might get really wordy, as in, in order for you to understand what's going on with your function you might have to type out something like function verifyDescriptionOfIceCreamIsNotDuplicatedInOtherIceCreams(). Though descriptive, it 1) isn't going to be repeated very much; 2) can take a while to read; 3) can be tedious to type out. If you run into this kind of scenario it may be worth trying to divide the function up into smaller chunks. Along these same lines, be careful how you name things. You might find that similar functions also have similar names, which can get confusing, especially if your app becomes more complex.

Other notes

Cypress has its own alias system that you could use as an alternative to improve your test readability.

Cypress also does not really recommend you fill your commands file with extremely simple commands like those given here as examples. Things such as the login flow in the beforeEach() block would be a good chunk to put into the commands, whereas they specifically say not to fill it with things like a simple .click().