Test Driven Development with StencilJS: Refactoring to use Page Objects and beforeEach

In the previous tutorial in this series, we used a Test Driven Development approach to start building a time tracking application with Ionic and StencilJS.

At this point, we have created 1 E2E test and 6 unit/integration tests that cover testing just one very small part of our application: that new <app-timer> elements are added to the page when the add button is clicked. As you might imagine, as we build out the rest of the application we are going to have a lot of tests that contain a lot of code.

It is therefore important that we take an organised and maintainable approach to writing our tests. We are already running into a bit of repetition that we could improve with a refactor. At the moment, we have two main organisation issues that we could improve.

Using Page Objects to Provide Helper Methods

If we take a look at our E2E tests you might notice that we have a growing amount of code that is dependent on the exact structure of the application, for example:

const timerElementsBefore = await page.findAll('app-timer')
const addButtonElement = await page.find('app-home ion-toolbar ion-button')

and in the case of grabbing a reference to app-timer we even do this in multiple places:

const timerElementsAfter = await page.findAll('app-timer')

The problem here is that the way in which we need to grab references to these elements might change. For example, we might decide that the <ion-toolbar> doesn’t really work well for this application and instead we are going to remove it and just have the add button sit at the top of the page. If we were to do this, anywhere in any of our tests where we try to grab the add button with:

await page.find('app-home ion-toolbar ion-button')

…will fail. We would have to manually find and replace every instance of this app-home ion-toolbar ion-button selector to reflect the change we made. This is where a page object can become useful. Instead of littering our tests with repeated selectors, we can just define how to grab the add button (or anything else) in our page object once and then reference that wherever we need it.

The page object we create would look something like this:

import { E2EElement, E2EPage } from '@stencil/core/testing'

export class AppHomePageObject {
  getHostElement(page: E2EPage): Promise<E2EElement> {
    return page.find('app-home')
  }

  getAllTimerElements(page: E2EPage): Promise<E2EElement[]> {
    return page.findAll('app-timer')
  }

  getAddButton(page: E2EPage): Promise<E2EElement> {
    return page.find('app-home ion-toolbar ion-button')
  }
}

Once we have this page object created along with its helper methods, we can just reference them from our E2E tests:

describe('app-home', () => {
  const homePage = new AppHomePageObject();

  it('adds a timer element to the page when the add button is clicked', async () => {
    // ...snip
    const timerElementsBefore = await homePage.getAllTimerElements(page);
    // ...snip
});

A page object can also be useful for other common operations, like how to navigate to particular pages, which might change as you build out the application

Using beforeEach to Arrange Tests

If we take a look at the Arrange step of our tests (i.e. the beginning set up stage) we will see that we are repeating the same code a lot in our E2E tests:

const page = await newE2EPage()
await page.setContent('<app-home></app-home>')

and in our unit tests:

const { rootInstance } = await newSpecPage({
  components: [AppHome],
  html: '<app-home></app-home>',
})

This is the basic set up for the test. We use testing helpers that StencilJS provides to create new instances of our page/component to test against. It is important that we create new instances of the components we are testing for each test, because we don’t want one test having any impact on another test. You don’t want situations where a test succeeds only because a previous test got a component you are reusing into a particular state that allowed it to pass without really doing what we need it to.

However, although we do need fresh instances of the things we want to test, we don’t need to write out the same code for every single test. Instead, we can use the beforeEach method. This will run a block of code before each test is executed. This means that if we have 6 unit tests in a test suite it will run the beforeEach code and then the first test, then it will run the beforeEach code again before executing the second test, then it will run the beforeEach code again before executing the third test, and so on.

Let’s see what our E2E tests would look like if we refactored them to use beforeEach:

import { E2EPage, newE2EPage } from '@stencil/core/testing'
import { AppHomePageObject } from './app-home.po'

describe('app-home', () => {
  const homePage = new AppHomePageObject()
  let page: E2EPage

  beforeEach(async () => {
    page = await newE2EPage()
    await page.setContent('<app-home></app-home>')
  })

  it('renders', async () => {
    const element = await homePage.getHostElement(page)
    expect(element).toHaveClass('hydrated')
  })

  it('adds a timer element to the page when the add button is clicked', async () => {
    // Arrange
    const timerElementsBefore = await homePage.getAllTimerElements(page)
    const timerCountBefore = timerElementsBefore.length

    const addButtonElement = await homePage.getAddButton(page)

    // Act
    await addButtonElement.click()

    // Assert
    const timerElementsAfter = await homePage.getAllTimerElements(page)
    const timerCountAfter = timerElementsAfter.length

    expect(timerCountAfter).toEqual(timerCountBefore + 1)
  })

  it('can display timers that display the total time elapsed', async () => {})
})

Notice that now we just define a page variable at the top of our test suite that all of our tests will use, and then we just reset it before each test inside of a single beforeEach method. This saves us from needing to create a new E2E Page and setting its content inside of every single test manually.

We can also do the same for our unit tests:

import { AppHome } from './app-home'
import { newSpecPage, SpecPage } from '@stencil/core/testing'

describe('app-home', () => {
  let pageProperties: SpecPage

  beforeEach(async () => {
    pageProperties = await newSpecPage({
      components: [AppHome],
      html: '<app-home></app-home>',
    })
  })

  it('renders', async () => {
    const { root } = pageProperties
    expect(root.querySelector('ion-title').textContent).toEqual('Home')
  })

  it('has a button in the toolbar', async () => {
    const { root } = pageProperties

    expect(root.querySelector('ion-toolbar ion-button')).not.toBeNull()
  })

  it('should have an array of timers', async () => {
    const { rootInstance } = pageProperties

    expect(Array.isArray(rootInstance.timers)).toBeTruthy()
  })

  it('should have an addTimer() method that adds a new timer to the timers array', async () => {
    const { rootInstance } = pageProperties

    const timerCountBefore = rootInstance.timers.length

    rootInstance.addTimer()

    const timerCountAfter = rootInstance.timers.length
    expect(timerCountAfter).toBe(timerCountBefore + 1)
  })

  it('should trigger the addTimer() method when the add button is clicked', async () => {
    const { root, rootInstance } = pageProperties

    const addButton = root.querySelector('ion-toolbar ion-button')
    const timerSpy = jest.spyOn(rootInstance, 'addTimer')
    const clickEvent = new CustomEvent('click')

    addButton.dispatchEvent(clickEvent)

    expect(timerSpy).toHaveBeenCalled()
  })

  it('should render out an app-timer element equal to the number of elements in the timers array', async () => {
    const { root, rootInstance, waitForChanges } = pageProperties

    rootInstance.timers = ['', '', '']

    await waitForChanges()
    const timerElements = root.querySelectorAll('app-timer')

    expect(timerElements.length).toBe(3)
  })
})

The same basic idea is used here, we just set up a reference to a new spec page on the pageProperties variable which will contain all of the properties we might want to use in our test. This way, we can still continue using destructuring in our tests to grab the specific properties we want to work with for that test, for example:

const { root } = pageProperties

and

const { root, rootInstance, waitForChanges } = pageProperties

…will both still work fine in their respective tests. Of course, now that we have been messing around with our tests we should verify that they all still work by running npm run test:

Test Suites: 5 passed, 5 total
Tests:       17 passed, 17 total
Snapshots:   0 total
Time:        5.059 s
Ran all test suites.

All good! But now we are way more organised.

There are more methods that we can use to organise our tests and prevent repeating ourselves - there are additional methods like beforeAll or afterEach that we could make use of - but we will continue to refactor with additional strategies like that when and if they become necessary.

Check out my latest videos: