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'exportclassAppHomePageObject{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 =newAppHomePageObject();it('adds a timer element to the page when the add button is clicked',async()=>{// ...snipconst 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 =awaitnewE2EPage()await page.setContent('<app-home></app-home>')
and in our unit tests:
const{ rootInstance }=awaitnewSpecPage({
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 =newAppHomePageObject()let page: E2EPage
beforeEach(async()=>{
page =awaitnewE2EPage()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()=>{// Arrangeconst timerElementsBefore =await homePage.getAllTimerElements(page)const timerCountBefore = timerElementsBefore.length
const addButtonElement =await homePage.getAddButton(page)// Actawait addButtonElement.click()// Assertconst 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 =awaitnewSpecPage({
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 =newCustomEvent('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 =['','','']awaitwaitForChanges()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.