Writing End-to-End Tests with Cypress

Installing Cypress

Now that our store is up and running, we are going to install Cypress so that we can write end-to-end tests to test our Next.js front-end.

You can find our more about Cypress and how to install it on our docs site.

cd into the site/ directory and then install Cypress.

cd site
yarn add cypress --save-dev

Open up the package.json file and add the following scripts.

"scripts": {
        // ...
    "cypress:open": "cypress open",
    "cypress:run": "cypress run"
}

The entire "scripts" object should look like this:

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "analyze": "BUNDLE_ANALYZE=both next build",
    "lint": "next lint",
    "prettier-fix": "prettier --write .",
    "find:unused": "npx next-unused",
    "cypress:open": "cypress open",
    "cypress:run": "cypress run"
  },

Make sure you have the Next.js development server running in another terminal window or tab.

yarn dev

Next, run the cypress:open command to launch Cypress in another terminal window or tab.

yarn cypress:open

Cypress should launch and look like this:

Screen Shot 2021-12-16 at 10.09.25 AM.png

Cypress by default creates several example spec files for demonstration purposes. In the blue alert at the top click on the "No thanks, delete example files" link. This will delete all of the default spec files that Cypress installs upon first launching it.

Writing Our First Test

Create a new spec file within cypress/integration and called it home.spec.js

Screen Shot 2022-05-09 at 11.00.19 AM.png

Next we will create a describe() method like so:

describe("Home Page", () => {})

Now that our spec file is setup, we can begin to write our first test.

Before we do that however, let's take a minute to think through what we need to test. We should be asking ourselves some questions like, "What are the most important features of this application?" In the case of our dev swag store, one of the most important features is the ability to purchase swag. Before we can make a purchase, however, our products need to be displayed in our store from Shopify.

Let's write a test that confirms that all of our products are being displayed in our store.

Update home.spec.js with the following:

describe("Home Page", () => {
  it("displays all 3 products on the home page", () => {})
})

The first thing we need to do in our test is tell Cypress to navigate to the homepage of our application.

describe("Home Page", () => {
  it("displays all 3 products on the home page", () => {
    cy.visit("http://localhost:3000")
  })
})

Next, we will need to use cy.get() to get our products. Before we do that however, let's learn a Cypress best practice.

Testing Specific Attributes

Most people will typically use a CSS class or ID to get their elements like so:

cy.get(".btn-large")
cy.get("#hero")

While this is perfectly valid and will work, we do not actually recommend this. Why not? You see by using CSS classes or ID's you are tying your tests to things that are most likely going to change overtime. Classes and ID's are for design and layout, which are prone to change. If someone changes a class name or id your tests are going to break. To help make your tests less brittle and more future-proof we recommend you create special data attributes on your elements specifically for testing purposes.

We will be using the data-test attribute on our elements, like so:

<div data-test="product-tag">

Now that we have that covered, let's update one of the components displayed on our homepage with a data-test attribute. We want to confirm that the title and price being displayed on the homepage is correct. This data is being displayed by the ProductTag.tsx component.

Open that component up in your editor and paste the following.

import cn from 'clsx'
import { inherits } from 'util'
import s from './ProductTag.module.css'

interface ProductTagProps {
  className?: string
  name: string
  price: string
  fontSize?: number
}

const ProductTag: React.FC<ProductTagProps> = ({
  name,
  price,
  className = '',
  fontSize = 32,
}) => {
  return (
    <div className={cn(s.root, className)} data-test="product-tag">
      <h3 className={s.name}>
        <span
          className={cn({ [s.fontsizing]: fontSize < 32 })}
          style={{
            fontSize: `${fontSize}px`,
            lineHeight: `${fontSize}px`,
          }}
          data-test="product-name"
        >
          {name}
        </span>
      </h3>
      <div className={s.price} data-test="product-price">
        {price}
      </div>
    </div>
  )
}

export default ProductTag

Notice how we have added three of these data-test attributes:

<div className={cn(s.root, className)} data-test="product-tag">
<span
  className={cn({ [s.fontsizing]: fontSize < 32 })}
  style={{
    fontSize: `${fontSize}px`,
    lineHeight: `${fontSize}px`,
  }}
  data-test="product-name"
>
  {name}
</span>
<div className={s.price} data-test="product-price">
  {price}
</div>

Now that we have these in place, let's continue writing our test.

Testing Our Products

Update the home.spec.js file with the following:

describe("Home Page", () => {
  it("displays all 3 products on the home page", () => {
    cy.visit("http://localhost:3000")
    cy.get('[data-test="product-tag"]')
  })
})

Now let's run Cypress to make sure everything is working so far.

Screen Shot 2022-05-09 at 11.08.12 AM.png

Click on the home.spec.js file to launch Cypress and run our test.

Screen Shot 2022-05-09 at 11.08.26 AM.png

So far so good.

We currently have three products and so when we use cy.get('[data-test="product-tag"]') Cypress will return all of our products. We only want the first one, so we can use the cy.eq() to grab it.

cy.get('[data-test="product-tag"]').eq(0)

Then, we will want to get the product name and product price to confirm they are displaying correctly within this element, like so:

describe("Home Page", () => {
  it("displays all 3 products on the home page", () => {
    cy.visit("http://localhost:3000")
    cy.get('[data-test="product-tag"]')
      .eq(0)
      .within(() => {
        cy.get('[data-test="product-name"]').should("contain", "Star Wars")
        cy.get('[data-test="product-price"]').should("contain", "$25.00 USD")
      })
  })
})

By using .within() we are limiting the scope of the subsequent cy.get() commands. Otherwise, cy.get() will search through the entire document. Since we know that the product name and price are children of the "product tag" we can limit the scope in which Cypress looks to find these elements.

Screen Shot 2022-05-09 at 11.10.10 AM.png

Now that we know our first product is displayed properly, let's confirm the other two. We can simply copy and past the first part of our test for the first product and modify the product name and title for the other products.

describe("Home Page", () => {
  it("displays all 3 products on the home page", () => {
    cy.visit("http://localhost:3000")
    cy.get('[data-test="product-tag"]')
      .eq(0)
      .within(() => {
        cy.get('[data-test="product-name"]').should("contain", "Star Wars")
        cy.get('[data-test="product-price"]').should("contain", "$25.00 USD")
      })

    cy.get('[data-test="product-tag"]')
      .eq(1)
      .within(() => {
        cy.get('[data-test="product-name"]').should("contain", "SQL")
        cy.get('[data-test="product-price"]').should("contain", "$25.00 USD")
      })

    cy.get('[data-test="product-tag"]')
      .eq(2)
      .within(() => {
        cy.get('[data-test="product-name"]').should("contain", "Code")
        cy.get('[data-test="product-price"]').should("contain", "$25.00 USD")
      })
  })
})

Screen Shot 2022-05-09 at 11.11.50 AM.png

Great all of our products are displaying the correct name and price!

Testing the Header

Let's now write some tests for the header of our application. We will write some tests to make sure that the links go to the correct pages and the search bar returns the correct results.

First, create a new spec file in cypress/integration called header.spec.js and add the following.

describe("Header", () => {})

Screen Shot 2022-05-09 at 11.12.35 AM.png

First, let's write a test to make sure that the links in the header go to the correct pages.

describe("Header", () => {
  it("links to the correct pages", () => {})
})

We can add our data-test attributes to these links within the components/common/Navbar/Navbar.tsx component.

import { FC } from 'react'
import Link from 'next/link'
import s from './Navbar.module.css'
import NavbarRoot from './NavbarRoot'
import { Logo, Container } from '@components/ui'
import { Searchbar, UserNav } from '@components/common'

interface Link {
  href: string
  label: string
}

interface NavbarProps {
  links?: Link[]
}

const Navbar: FC<NavbarProps> = ({ links }) => (
  <NavbarRoot>
    <Container clean className="mx-auto max-w-8xl px-6">
      <div className={s.nav}>
        <div className="flex items-center flex-1">
          <Link href="/">
            <a className={s.logo} aria-label="Logo" data-test="logo">
              <Logo />
            </a>
          </Link>
          <nav className={s.navMenu}>
            <Link href="/search">
              <a className={s.link} data-test="nav-link-search">
                All
              </a>
            </Link>
            {links?.map((l) => (
              <Link href={l.href} key={l.href}>
                <a className={s.link} data-test="nav-link-home-page">
                  {l.label}
                </a>
              </Link>
            ))}
          </nav>
        </div>
        {process.env.COMMERCE_SEARCH_ENABLED && (
          <div className="justify-center flex-1 hidden lg:flex">
            <Searchbar />
          </div>
        )}
        <div className="flex items-center justify-end flex-1 space-x-8">
          <UserNav />
        </div>
      </div>
      {process.env.COMMERCE_SEARCH_ENABLED && (
        <div className="flex pb-4 lg:px-6 lg:hidden">
          <Searchbar id="mobile-search" />
        </div>
      )}
    </Container>
  </NavbarRoot>
)

export default Navbar

First, let's confirm that clicking on the store logo will go to the home page.

describe("Header", () => {
  it("links to the correct pages", () => {
    cy.visit("http://localhost:3000")
    cy.get('[data-test="logo"]').click()
    cy.location("pathname").should("eq", "/")
  })
})

Screen Shot 2022-05-09 at 11.16.14 AM.png

baseUrl and Cypress Custom Commands

Instead of having to write cy.visit('http://localhost:3000') to tell Cypress to go to the home page, we can tell Cypress our applications baseUrl and then simply use cy.visit('/') instead.

Within the cypress.json file in the root of the repo, add the following:

{
  "baseUrl": "http://localhost:3000"
}

Screen Shot 2021-12-16 at 11.16.26 AM.png

Now you can update our test like so:

describe("Header", () => {
  it("links to the correct pages", () => {
    cy.visit("/")
    cy.get('[data-test="logo"]').click()
    cy.location("pathname").should("eq", "/")
  })
})

Our test is still passing

Screen Shot 2022-05-09 at 11.17.31 AM.png

Next, instead of having to write cy.get('[data-test="logo"]') we can simplify this as well with a custom Cypress command.

Add the following to the cypress/support/commands.js file.

Cypress.Commands.add("getBySel", (selector, ...args) => {
  return cy.get(`[data-test=${selector}]`, ...args)
})

Screen Shot 2021-12-16 at 11.19.04 AM.png

Now we can update our test like so:

describe("Header", () => {
  it("links to the correct pages", () => {
    cy.visit("/")
    cy.getBySel("logo").click()
    cy.location("pathname").should("eq", "/")
  })
})

And our test is still passing.

Screen Shot 2022-05-09 at 11.18.13 AM.png

Now let's do the same for the other links in the header.

describe("Header", () => {
  it("links to the correct pages", () => {
    cy.visit("/")
    cy.getBySel("logo").click()
    cy.location("pathname").should("eq", "/")

    cy.getBySel("nav-link-search").click()
    cy.location("pathname").should("eq", "/search")

    cy.getBySel("nav-link-home-page").click()
    cy.location("pathname").should("eq", "/search/frontpage")
  })
})

When we re-run our test we see that it is failing, why?

Screen Shot 2021-12-16 at 11.28.45 AM.png

If we look closely at our application, we can see that those links do not appear in the header. This is because Cypress by default opens the viewport to "1000x660" which is too narrow. One of the breakpoints in our Next.js application is hiding these links when it is 1000px wide. We can modify the default viewport size by adding the following to the cypress.json file.

{
  "baseUrl": "http://localhost:3000",
  "viewportHeight": 1000,
  "viewportWidth": 1280
}

Now our test should be passing

Screen Shot 2022-05-09 at 11.23.25 AM.png

Let's write a test to make sure that the search bar in the header returns the correct results

Add the following new test inside of header.spec.js

describe("Header", () => {
  it("links to the correct pages", () => {
    cy.visit("/")
    cy.getBySel("logo").click()
    cy.location("pathname").should("eq", "/")

    cy.getBySel("nav-link-search").click()
    cy.location("pathname").should("eq", "/search")

    cy.getBySel("nav-link-home-page").click()
    cy.location("pathname").should("eq", "/search/frontpage")
  })

  it("the search bar returns the correct search results", () => {
    cy.visit("/")
  })
})

beforeEach() hooks

You will notice that within each test, we have to specifically tell Cypress where to navigate in our app before our test can be executed. We can remove this duplication by using a beforeEach() hook. This hook will run any code we put inside of it before each test is run, hence the name beforeEach()

describe("Header", () => {
  beforeEach(() => {
    cy.visit("/")
  })

  it("links to the correct pages", () => {
    cy.getBySel("logo").click()
    cy.location("pathname").should("eq", "/")

    cy.getBySel("nav-link-search").click()
    cy.location("pathname").should("eq", "/search")

    cy.getBySel("nav-link-home-page").click()
    cy.location("pathname").should("eq", "/search/frontpage")
  })

  it("the search bar returns the correct search results", () => {})
})

Now Cypress will visit the home page before each and every test in this file.

Within components/common/Searchbar/Searchbar.tsx update the <input> element with the following data-test attribute data-test="search-input":

import { FC, memo, useEffect } from 'react'
import cn from 'clsx'
import s from './Searchbar.module.css'
import { useRouter } from 'next/router'

interface Props {
  className?: string
  id?: string
}

const Searchbar: FC<Props> = ({ className, id = 'search' }) => {
  const router = useRouter()

  useEffect(() => {
    router.prefetch('/search')
  }, [router])

  const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
    e.preventDefault()

    if (e.key === 'Enter') {
      const q = e.currentTarget.value

      router.push(
        {
          pathname: `/search`,
          query: q ? { q } : {},
        },
        undefined,
        { shallow: true }
      )
    }
  }

  return (
    <div className={cn(s.root, className)}>
      <label className="hidden" htmlFor={id}>
        Search
      </label>
      <input
        id={id}
        className={s.input}
        placeholder="Search for products..."
        defaultValue={router.query.q}
        onKeyUp={handleKeyUp}
        data-test="search-input"
      />
      <div className={s.iconContainer}>
        <svg className={s.icon} fill="currentColor" viewBox="0 0 20 20">
          <path
            fillRule="evenodd"
            clipRule="evenodd"
            d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
          />
        </svg>
      </div>
    </div>
  )
}

export default memo(Searchbar)

Now we can .get() the search bar like so:

// header.spec.js

it('links to the correct pages', () => {
// ...

it('the search bar returns the correct search results', () => {
    cy.getBySel('search-input').eq(0)
  })
tip

This search component is actually in the DOM twice, one for desktop and one for mobile. By using .eq(0) we are grabbing the one for desktop.

only()

Since, we now have two tests in this file, Cypress will always run both tests each time we save. We can use .only() to have it only run a single one like so:

it.only("the search bar returns the correct search results", () => {
  cy.getBySel("search-input").eq(0)
})

Screen Shot 2022-05-09 at 11.26.36 AM.png

Let's try to search for the "Star Wars" by typing in the word "star"

it.only("the search bar returns the correct search results", () => {
  cy.getBySel("search-input").eq(0).type("star")
})

We can also simulate pressing the enter key like so:

it.only("the search bar returns the correct search results", () => {
  cy.getBySel("search-input").eq(0).type("star{enter}")
})

Screen Shot 2022-05-09 at 11.29.01 AM.png

Now let's confirm that the correct product is displayed in the search results. We can borrow some of the same code we used in our home.spec.js test.

it.only("the search bar returns the correct search results", () => {
  cy.getBySel("search-input").eq(0).type("star{enter}")

  cy.get('[data-test="product-tag"]').within(() => {
    cy.get('[data-test="product-name"]').should("contain", "Star Wars")
    cy.get('[data-test="product-price"]').should("contain", "$25.00 USD")
  })
})

Screen Shot 2022-05-09 at 11.29.42 AM.png

Testing the Shopping Cart

Let's write one more test that confirms that our shopping cart is working.

Create a new file within cypress/integration called shopping-cart.spec.js and add the following:

describe("Shopping Cart", () => {
  it("users can add products to the cart", () => {
    cy.visit("/")
  })
})

Screen Shot 2022-05-09 at 11.30.25 AM.png

First, we will need to click on a product to add it to the cart.

describe("Shopping Cart", () => {
  it("users can add products to the cart", () => {
    cy.visit("/")
    cy.getBySel("product-tag").eq(0).click()
  })
})

Screen Shot 2022-05-09 at 11.30.59 AM.png

Next, we will need to click on the "Add to Cart" button.

describe("Shopping Cart", () => {
  it("users can add products to the cart", () => {
    cy.visit("/")
    cy.getBySel("product-tag").eq(0).click()
    cy.get('[aria-label="Add to Cart"]').click()
  })
})

Notice how we are getting the element by using the aria-label attribute.

Screen Shot 2022-05-09 at 11.31.26 AM.png

Now we just need to confirm that our shopping cart has our item in it.

describe("Shopping Cart", () => {
  it("users can add products to the cart", () => {
    cy.visit("/")
    cy.getBySel("product-tag").eq(0).click()
    cy.get('[aria-label="Add to Cart"]').click()
    cy.get('[aria-label="Cart items: 1"]').contains("1")
  })
})

Screen Shot 2022-05-09 at 11.31.51 AM.png

Conclusion

In this lesson, we learned how to install Cypress and how to write several different tests to ensure that our store is working as expected. We also learned how to customize the Cypress test runner within the cypress.json file as well has how to use custom Cypress commands.

Practice

Now would be a great time for you to practice writing more Cypress tests. Here are some ideas for tests you could write.

  • Write a test that confirms the shopping cart can contain multiple items.
  • Write a test that confirms your images have the correct alt tags.
  • Write a test that confirms the “Accept Cookies” popup displays on the bottom of the page.
  • Write a test that confirms the language selector in the footer, navigates to the correct page.