Starting with UI tests in Cypress

I realised that I haven’t talked about automation for a while, even though it’s a part of the job I enjoy just as much as coercing/tricking people into talking to each other. So I wrote a few small tests to show some of the basics I have learnt, and I’m documenting it so that future Bruce can feel nostaligic about it.

This post is half getting-started-with-cypress guide, half here’s-some-things-I-wrote. There’s nothing new or revolutionary here in terms of using Cypress, but I’m writing this for me as much as for anyone else, so that’s fine. (it’s fine, Bruce, shhh it’s fine…)

These tests navigate around my blog site and fill in the contact-me form, without mocks, because apparently I love to send myself spam emails. I’ve uploaded the project to a github repository so you should hypothetically be able to follow the instructions in the readme.md and run them on your machine if you like. (I don’t run ads on this blog, so the extra impressions will cheat no one but me.)

I seriously messed up my metrics writing these hahahahah

Why Cypress?

There are lots of tools and frameworks for test automation, and I’m not gunna say any one is better than another. Could use any number of ’em, but I limited my options by learning JavaScript instead of Java or Python, and to be honest I just enjoy using Cypress.

It’s quick to set up and relatively easy to get started with (I won’t say straight-up ‘easy’ because I’m still new enough to remember the pain and shame of not being able to work out “simple, easy” tools).

Getting Started

Honestly, I could tell you how to set up a project with Cypress on my machine, but you might be running Windows or Linux in which case it’d be useless, so here’s the install page. I use the package manager Yarn in this project.

The TL;DR is this:

  • Make a new project folder, or open the project you’re adding tests to in terminal
  • If it’s a new project, you need to initialise your package manager (ie run “npm init” or “yarn init”)
  • Install the Cypress package

I’d recommend going into the package.json file and adding a custom command to run your tests, like the example below:

{
  "name": "undeveloped-ui-cypress",
  "version": "1.0.0",
  "description": "example Cypress UI tests for undevelopedbruce",
  "main": "index.js",
  "author": "UndevelopedBruce",
  "license": "MIT",
  "private": false,
  "scripts": {
    "test": "cypress open" // HERE
  },
  "devDependencies": {
    "cypress": "^4.0.1"
  }
}

That way you can run the tests using the command “yarn test” instead of “yarn run cypress open”. Or “test:e2e” if you already have other tests in the project.

When you run Cypress for the first time in a new project, it sets up this file structure for you:

-your-project-folder
  - cypress
    -integration
      -examples <- your tests go in this folder
        test.js
        other-test.js
    - plugins
    - support
      - commands.js <- if you need to write a custom command, it goes here
      - index.js

When I first started writing tests, the hardest part for me was understanding where to put the files. I know that sounds silly, but no one tells you!! When you go to tutorials, they usually explain what code to write, and maybe the snippets have file names attached, but otherwise you have no idea whether a new option is going in the package.json, in a config file, or in some other weird place you don’t yet know exists because it’s your first time using this tool. Luckily, Cypress has a great page about Writing and Organising Tests. xD

Writing a Test

This is how I structure test files:

describe('Feature under test', () => {
    it('Behaviour One', () => {
        // DO A THING
        // EXPECT A RESULT
    })
    it('Behaviour 2', () => {
        // DO A THING
        // EXPECT A RESULT
    })
    it('Behaviour 3', () => {
        // DO A THING
        // EXPECT A RESULT
    })
})

This way the tests appear in the report like this, and you can quickly tell what scenarios are being tested, and what user paths are affected by a failure:

Contact Form.
 Shows error on invalid form send (4593ms).
Sends a valid form (3800ms).

Side note, you will mostly find examples that use a slightly different syntax, like this one from the Writing Your First Test guide:

describe('My First Test', function() {
  it('Does not do much!', function() {
    expect(true).to.equal(true)
  })
})

This is tots fine. I just prefer arrow functions because they look cleaner.

Cypress Commands 101

Cypress has a bunch of nice built-in commands for interacting with the user interface, finding elements and asserting on them. A few of the ones I use most are listed here:

cy.visit("this.url.com")
cy.get("#element-by-id") or cy.get(".element-by-class")

You can chain commands together, so if you want to get an element and then take action on it, you just add the actions on the end:

cy.get("#input-field").type("some text")
cy.get(".button").click()
cy.get("#there-are-many-of-me").contains("target the element with this text in")
// chain them as much as you need:
cy.get("#thing").click().type("hello!")

You can also assert on them in a bunch of ways!

cy.get("#some-text").should("contain", "bruce is the best")
cy.get("#page-title").should("have.css", "font-family", "lato-medium")
cy.get("#best-element-ever").should("be.visible")

So putting some of those things together, you could write something like this:

describe('Amazon Nav', () => {
    it('Has white text', () => {
        cy.visit("https://amazon.co.uk");
        cy.get(".nav-a")
            .contains("Today's Deals")
            .should("have.css", "color", "#ccc")
    })
})

I’m not sure why you’d want a test for that though. xD

The Tests

So before I could write some code, I had to decide what cases to cover. There isn’t a whole lot of interaction in my blog site, and I can’t really add cool things to it since it’s a standard wordpress site I didn’t write the code for. These are some scenarios I decided on after a couple of minutes clicking around:

  • Content loads
  • Site navigation between pages works
  • Sending valid ‘contact Bruce’ enquiry is successful
  • Sending invalid ‘contact Bruce’ enquiry is unsuccessful

Navigation

This was the first working draft of a test that starts off at the blog homepage and navigates to each of the pages in the navbar in turn. There is a lot of repetition here – in fact, I wrote the first scenario and then copy-pasted it into some new test cases and changed the details. It finds and clicks on the item in the primary menu that contains a piece of text, checks that the new url contains the right page info, then asserts that the title of the new page contains the same piece of text from the first line.

describe('Navigation tests', () => {
    it('Navigates to About Bruce', () => {
        cy.visit("/")
        cy.get("#primary-menu").contains("About").click()
        cy.url().should("include", "/about-me")
        cy.get(".entry-title").should("contain", "About")
    })
    it('Navigates to Dungeons and Testing', () => {
        cy.get("#primary-menu").contains("Dungeons").click()
        cy.url().should("include", "/dungeons-and-testing")
        cy.get(".entry-title").should("contain", "Dungeons")
    })
    it('Navigates to Contact Bruce', () => {
        cy.get("#primary-menu").contains("Contact").click()
        cy.url().should("include", "/contact")
        cy.get(".entry-title").should("contain", "Contact")
    })
})

This is bad.

Well, I mean it’s works, but the same thing can be achieved in far fewer lines by making a custom command. This is one of the things I love about Cypress – how simple it is to make your own commands. The example above is perfect for this, since the only differences between each case are the name of the menu item and the text that’s appended to the url after navigation. (In fact, I could have made them both the same variable since the item name is always contained in the url – but this might not be true for other pages I add in the future.)

Custom commands go in the commands.js file in the support folder, and you write them like this:

Cypress.Commands.add('nameOfCommand', (options you wanna pass to the command) => {
        cy.whatever("you").want.this("command", "to do")
})

In the current example, I want the command to take in two options and then do all the navigation and checking in one go. What I ended up with was:

Cypress.Commands.add('pageNav', (name, append) => {
    cy.get("#primary-menu").contains(name).click()
    cy.url().should("include", append)
    cy.get(".entry-title").should("contain", name)
})

This means I can now use “cy.pageNav()” as a command in my tests! The following is the same test, using my new command:

describe('Site Loading and Navigation', () => {
    it('Navigates to About Bruce', () => {
        cy.visit("/")
        cy.pageNav("About", "/about-me")
    })
    it('Navigates to Dungeons and Testing', () => {
        cy.pageNav("Dungeons", "/dungeons-and-testing")
    })
    it('Navigates to Contact Bruce', () => {
        cy.pageNav("Contact", "/contact")
    })
})

I should also move the cy.visit(“/”) into a before statement so that Cypress opens the site to the base url before the tests run. This means if I reorganise the tests later, I don’t have to move it into another test case. Since this is a super simple example, I’ll leave it in for now though.

That reminds me – cy.visit(“/”) navigates to the base url with a / on the end. The base url can be defined by putting one of these lil blighters in a cypress.json file in the root of the project:

{
    "baseUrl": "https://undevelopedbruce.com"
}

Saves a bit of repetition.

Filling a form

The next tests are for the ‘Contact Bruce’ form, and there are two journeys under test:

  1. The user types valid inputs into all the fields and submits the form
  2. The user types an invalid input and attempts to submit the form

I would also have written a test for “The user fails to type anything into a field and attempts to submit the form”, but there isn’t actually anything in the DOM to assert against since the verification text that appears comes from the ‘required’ html5 property. This is built into the browser, and doesn’t show up in the site html. I’m sure there are ways around it, and I’ll probably come back to this problem in the future when I have a bit more time.

Image shows an empty "Name" field with a message saying "Please fill this field."
This lil quotey friend isn’t actually there (well, it is, but I can’t assert on it)

It’s pretty straightforward to fill in forms with Cypress – you target the input you want to type in, and there is a .type() command to write whatever you like in there. You can also use specific keys by putting them in curly braces in the string like so: “Bruce{enter}”. Cypress will type Bruce and then press enter. It’s probably not necessary in the test below, but it’s how I behave when filling in forms so that’s how I wrote the tests. xD

describe('Contact Form', () => {
    it('Shows error on invalid form send', () => {
        cy.visit("/contact")
        cy.get("#g2-name").type("BruceBot 2000{enter}")
        cy.get("#g2-email").type("beans@bob{enter}")
        cy.get("#contact-form-comment-g2-comment").type("banana rama{enter}")
        cy.get(".pushbutton-wide").click()
        cy.get(".form-error-message").should("contain", "requires a valid email")

    })
    it('Sends a valid form', () => {
        cy.visit("/contact")
        cy.get("#g2-name").type("BruceBot 2000{enter}")
        cy.get("#g2-email").type("undevelopedbruce@gmail.com{enter}")
        cy.get("#contact-form-comment-g2-comment").type("banana{enter}")
        cy.get(".pushbutton-wide").click()
        cy.get(".contact-form-submission").should("contain", "BruceBot 2000")
    })
})

Again, this would be improved by moving the visit actions into a beforeAll() so that it’s not typed twice in the same file. I could also make another custom command that takes in the info I want to type into each input, then does the actions above so that I could test them all with a line each like:

cy.contactForm("BruceBot2000{enter}", "email@invalid{enter}", "message{enter}");
cy.contactForm("Bruce{enter}", "validemail@gmail.com{enter}", "hi{enter}");
...

This would have the advantage that if the form changes, or a class or id name is changed, then the test only needs to be updated in one place. On the other hand, abstracting code away – especially into a separate file – can make the code less readable to new people coming to work on it, or yourself coming back in a few weeks’ time. It still has to be really clear what the test is actually doing – if the above example was written like this:

cy.funcFive("Beans", "Bananas", "Biscuits")

then it would be nigh impossible to know at a glance what it’s doing. At the end of the day, the important things are to go into it knowing what scenarios you want to cover, and make it as easy as possible for colleagues or future-you to understand the code so you can make changes.

End

There y’are. Although I wouldn’t say this is any more advanced than JavaScript I’ve worked on in the past so it doesn’t seem like I’ve learnt much in the last year, I feel like I understand the whys and hows a lot better. I could definitely improve on planning automated tests – it would have taken me less time overall to write the tests above if I’d thought about the DRY (don’t repeat yourself) principle in the planning stage, instead of afterwards. It was obvious from the beginning which sets of actions I’d be repeating, so I could have defined those during the planning stage after some exploring on the site.

Learning is the most valuable outcome of any effort though, so I’ll consider that a success. xD

P.S. I’m drawing a lot for another project of mine at the moment, so apologies for the lack of illustrations in this post ❤ I’ll do twice as many in the next one!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.