TDD with StencilJS: Creating a Time Tracking Ionic Application
This is part of a series, check out the other parts below:
Test Driven Development (TDD) is a process for creating applications where the tests for the code are written before the code exists. The tests are what "drive" which functionality you implement. We are going to use that process to start building a time tracking application using Ionic and StencilJS. Although we are using StencilJS which uses Jest for testing by default, most of the concepts in this tutorial could be readily applied to other frameworks as well.
Outline
Source codeA Brief Introduction to TDD
I've written about unit testing, E2E testing, and TDD at length before. I don't want to spend too long rehashing that here, but I will just give a brief overview of what this is all about.
There is no "one" way to approach automated tests, and it is also an area where a lot of people have strongly held views about how things should be tested and how to define terms. For the sake of simplicity, we will be using the same distinction in terms that the StencilJS documentation uses: we will only consider E2E tests that test your application or certain features as a whole (i.e. by simulating actual clicks/interactions in a browser environment), and unit tests which test the logic of individual chunks of code. Some of the "unit tests" we create might also be considered by a lot of people to be "integration tests" if they don't focus on one very isolated and specific function, but we will not be making that distinction here. This tutorial is much more about just jumping in and writing the tests rather than testing philosophy.
The basic approach we will be using to implement TDD in our application is as follows:
- Write an E2E test that tests a use case we want to implement (e.g. the user can add a timer)
- Watch the test fail (this should happen as the functionality to satisfy the test does not exist yet)
- Use the failure error to determine what functionality needs to be added
- Write a unit test to tests the functionality you are about to add
- Watch the unit test fail
- Use the unit test failure to determine the code to be added to the application
- Check that the unit test now passes
- Check if the E2E now passes
- If the E2E test is still failing, go back to Step 4 to add another unit test so that you can add more of the functionality required (most of the time, an E2E test will require multiple unit tests until they are satisfied)
- If the E2E test now passes, go back to Step 1 to work on a new use case
You can keep repeating this process for all of the features you want to develop in the application. The basic idea is that we use E2E tests to describe how we want the application to work, those E2E tests will help define what our unit tests need to test, and then we implement functionality to solve our unit tests which will eventually cause the E2E tests to pass as well.
I know this sounds complex and convoluted, but in my opinion this is actually the easy way to add automated tests to your application. Since there is such a rigidly defined process, you don't need to worry so much about what you should be testing, or when to add tests, or how many tests you should be adding. You just follow the process, and you are going to end up with great test coverage and tests that are more likely to actually be testing your application. Especially for a beginner, having a strict process like this to follow can be a great benefit - even if this does seem like a more advanced approach to testing.
For more background on TDD before we begin, you might want to check out some of my other posts:
- Test Driven Development in Ionic: An Introduction to TDD
- The Role of Unit Tests in Ionic
- The Role of End-To-End (E2E) Tests in Ionic
I also have an in-depth course for Test Driven Development in Angular as part of Elite Ionic.
Before we Get Started
We are going to jump straight into implementing tests in this tutorial, so I will be assuming that you already have a reasonable understanding of how Ionic and StencilJS work together, and that you are at least somewhat familiar with how tests are implemented in StencilJS.
Although we won't be covering the basics of Jest here, the syntax should be reasonably easily to follow along with even if you aren't familiar with it (but you will likely need to brush up on the testing syntax if you want to write your own tests). If you are already familiar with Jasmine, then the Jest syntax will feel very familiar to you.
I will be working from the default ionic-pwa starter which comes with some tests set up by default. If you want to follow along you can create a new Ionic/StencilJS project with:
npm init stencil
and choose the ionic-pwa option.
Writing the first E2E test
By default, you will see that each component has an .e2e.ts
for E2E tests and a .spec.ts
file for unit tests. As to not confuse things, we are going to leave the existing tests and functionality that comes with the default starter template. We won't need a "profile" button and its associated test, but we will remove that later once it starts causing us problems.
I start off the testing process by considering the functionality that I want to implement at a high level. This is an application that will allow users to add timers to track their time on various tasks, so a good starting point might be:
A user should be able to click an add button to create a new timer
This is a good starting point as it gets to the core functionality that we are building, but it doesn't particularly matter what you start with - try not to overthink things too much.
We are going to add a test for this feature to the E2E file for our home page, but... how do we write a test for code that doesn't even exist? This is exactly the point of Test Driven Development, we write tests for the functionality that we want to create. Just write a test for how you want the functionality to work, or at least how you initially think you want it to work. This isn't a contract set in stone, you can always come back and modify the test if your implementation doesn't make sense later.
It might help initially to consider the existing profile page test in app-home.e2e.ts
:
it('contains a "Profile Page" button', async () => {
const page = await newE2EPage();
await page.setContent('<app-home></app-home>');
const element = await page.find('app-home ion-content ion-button');
expect(element.textContent).toEqual('Profile page');
});
Again, if we want a little more background on the methods StencilJS specifically provides for testing, it would be worth having a read through of their testing documentation. We can use this as a template to define our own test:
it('adds a timer element to the page when the add button is clicked', async () => {
// Arrange
const page = await newE2EPage();
await page.setContent('<app-home></app-home>');
const timerElementsBefore = await page.findAll('app-timer');
const timerCountBefore = timerElementsBefore.length;
const addButtonElement = await page.find('app-home ion-toolbar ion-button');
// Act
await addButtonElement.click();
// Assert
const timerElementsAfter = await page.findAll('app-timer');
const timerCountAfter = timerElementsAfter.length;
expect(timerCountAfter).toEqual(timerCountBefore + 1);
});
We are following the AAA philosophy here: Arrange, Act, Assert. We first organise whatever we need to perform the test, we then perform some kind of action, and then we assert (expect) that something specific has happened as a result.
In this case, we are doing the following:
- Arrange:: Generate a new E2E page for
<app-home>
, get a count of how many<app-timer>
elements are on the page initially, and get a reference to the button for adding new timers - Act: Trigger a click on the add button
- Assert: Expect that the amount of timers on the page is now one more than it was at the beginning of the test
This test will fail completely - or at least that's what we hope. It is important that you see a test fail first, because it should fail if the functionality has not been implemented yet. You want to verify that there isn't some mistake in your test that actually just causes it to pass no matter what.
Once we see the test failing, we can then see why it is failing. We can then use that failure to tell us what we need to work on next. You would run your tests at this point either with:
npm run test
or if you want to continuously run your tests in the background you can run:
npm run test.watch
The result for us at this point in time will look like this:
FAIL src/components/app-home/app-home.e2e.ts (21.991 s)
● app-home › adds a timer element to the page when the add button is clicked
TypeError: Cannot read property 'click' of null
30 |
31 | // Act
> 32 | await addButtonElement.click();
| ^
33 |
34 | // Assert
35 | const timerElementsAfter = await page.findAll('app-timer');
at Object.<anonymous> (src/components/app-home/app-home.e2e.ts:32:28)
Test Suites: 1 failed, 4 passed, 5 total
Tests: 1 failed, 11 passed, 12 total
Snapshots: 0 total
Time: 22.925 s
This test is failing because it can't find the add button element with the CSS selector:
app-home ion-toolbar ion-button
This makes sense, because the button doesn't exist! This is where the "test driven" part of test driven development comes in - we created a test first, and that's going to "drive" what we implement in the code (rather than writing our code first, and then writing tests for it).
Now that we have a test defined, we can implement the functionality necessary to satisfy the specific error we are facing:
TypeError: Cannot read property 'click' of null
Keep in mind that you don't have to solve for the entire test at once, just the current error you are facing within that test.
Writing the first unit test
But wait! Although we could just jump into our code and add an add button to the home page to get past this error in the test, we don't want to just have E2E tests - we want unit tests as well. This means that we should write a unit test for the functionality we are about to implement.
If we want to add a button to the home page, then we should first write a unit test that checks for the existence of an add button on the home page. Let's switch into the app-home.spec.ts
file and again look at the existing test to build upon:
it('renders', async () => {
const { root } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
expect(root.querySelector('ion-title').textContent).toEqual('Home');
});
As you can see, this is quite similar to the structure of the E2E test except that we call newSpecPage
instead. This will return us a page
reference, and within that we are able to access the root
component. Since we have used <app-home></app-home>
as the html
for this spec page, the root
will be a reference to the <app-home>
element.
Let's create our unit test now:
it('has a button in the toolbar', async () => {
const { root } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
expect(root.querySelector('ion-toolbar ion-button')).not.toBeNull();
});
This test is quite a bit simpler as we are just checking for the existence of a button, but the AAA steps are all still here. We arrange the test by creating a new spec page, and then we have combined the act and assert steps into one line with our expect
method call.
Let's check that this test fails first:
FAIL src/components/app-home/app-home.spec.ts
● app-home › has a button in the toolbar
expect(received).not.toBeNull()
Received: null
Now that we have a failing unit test, we can implement the code to satisfy that unit test. Make sure that you don't just create a single unit test like this and then go ahead and implement the entire functionality for our E2E test - if our unit test tests for the existence of a button, then all we should do is add a button. If you add code for things you don't already have tests for, then you aren't really doing TDD anymore.
Let's add the button to the header:
<ion-header>
<ion-toolbar color="primary">
<ion-title>Home</ion-title>
<ion-buttons>
<ion-button color="primary"><ion-icon name="add"></ion-icon></ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
and then run the tests again...
FAIL src/components/app-home/app-home.e2e.ts (39.18 s)
● app-home › adds a timer element to the page when the add button is clicked
expect(received).toEqual(expected) // deep equality
Expected: 1
Received: 0
36 | const timerCountAfter = timerElementsAfter.length;
37 |
> 38 | expect(timerCountAfter).toEqual(timerCountBefore + 1);
| ^
39 | });
40 | });
41 |
at Object.<anonymous> (src/components/app-home/app-home.e2e.ts:38:29)
Our unit test is passing now, but our E2E test is still failing. The important thing to note here is that the reason why it is failing has changed:
● app-home › adds a timer element to the page when the add button is clicked
expect(received).toEqual(expected) // deep equality
Expected: 1
Received: 0
This means we are progressing. The button exists and it can be clicked, but it doesn't do anything yet. Our test code is counting the number of app-timer
elements on the home page before and after the add button is clicked. What we would expect to see is that there is initially 0
, and then there will be 1
after the button has been clicked. However, our code is expecting to get a result of 1
, but the actual result is 0
. This is the behaviour we would expect since then button does nothing.
More unit tests
Since our E2E test isn't passing yet, that means we need to go back down to the unit test level. We will keep adding new unit tests until our E2E test passes. To progress, we are going to need our home page to have an array of timer elements that it will display in the DOM. Let's create another unit test for that:
it('should have an array of timers', async () => {
const { rootInstance } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
expect(Array.isArray(rootInstance.timers)).toBeTruthy();
});
Notice that this time we are using rootInstance
instead of root
. This is because we want a reference to the component instance itself rather than the root DOM element. The initial result of this test will be:
FAIL src/components/app-home/app-home.spec.ts
● app-home › should have an array of timers
expect(received).toBeTruthy()
Received: false
Great, we have a failing testing. Now let's try to get it to pass by adding an array of timers to our home page:
@State() timers: string[] = [];
After adding this line, our unit test will pass. However, our E2E test will still fail. Having an array of timers is a critical part in being able to display a list of timers in the home page template, but we aren't actually making use of that array in the template yet and the array is empty anyway.
Let's pause for a second and consider what other functionality we will need to implement to get our E2E test passing:
- We will need to be able to add timers to the timers array
- We will need to use the timers array to to add timers to the template
Time for even more unit tests!
it('should have an addTimer() method that adds a new timer to the timers array', async () => {
const { rootInstance } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
const timerCountBefore = rootInstance.timers.length;
rootInstance.addTimer();
const timerCountAfter = rootInstance.timers.length;
expect(timerCountAfter).toBe(timerCountBefore + 1);
});
This test will make a call to an addTimer()
method and check that the number of timers in the timer array increases as a result. Let's check that it fails:
FAIL src/components/app-home/app-home.spec.ts
● app-home › should have an addTimer() method that adds a new timer to the timers array
TypeError: rootInstance.addTimer is not a function
It is telling us that the addTimer
method does not exist, so let's add that to solve this error.
addTimer(){
}
Notice that we have literally just added the addTimer
method because that is all the error is complaining about - that the method doesn't exist. Realistically, we know that it is still going to fail because we are also checking that the timerCountAfter
has increased by 1
, and so we could also just add that functionality right away. Sticking to coding just in response to the errors, especially in the beginning, is a good way to make sure your tests are actually testing what you think they are.
Here is the result of our test now:
FAIL src/components/app-home/app-home.spec.ts
● app-home › should have an addTimer() method that adds a new timer to the timers array
expect(received).toBe(expected) // Object.is equality
Expected: 1
Received: 0
Now the unit test has moved on because the addTimer
method exists, but it isn't doing what it expects. It is expecting a new element to be added to the timers array, but that's not happening yet. Let's do that:
addTimer(){
this.timers = [...this.timers, 'Untitled'];
}
Great, that solves our unit test but of course our E2E is still failing. We just have an array of elements that are calling themselves timers, but we don't actually do anything with that yet. To solve our E2E test, we are going to have to render out actual timer elements to the template.
Waiting for changes in a test
We don't have an <app-timer>
component in our application yet, but we are going to try to add them anyway. Remember, we are trying to test the way we want our application to work, and then we are using the resulting errors in our tests to determine what to work on next. If tests aren't complaining about missing app-timer
components then it's not our problem... yet.
Before we add code to the template that will render out an <app-timer>
for each of the elements in the array, let's write another unit test to cover that case first:
it('should render out an app-timer element equal to the number of elements in the timers array', async () => {
const { root, rootInstance, waitForChanges } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
rootInstance.timers = ['', '', ''];
await waitForChanges();
const timerElements = root.querySelectorAll('app-timer');
expect(timerElements.length).toBe(3);
});
We add a new trick in this one. The basic idea is to set the timers
array manually to an array, and then check that the number of app-timer
elements rendered out equals the length of the array we used for the test. The trick here is that we are not also destructuring the waitForChanges
method from newSpecPage
. When we change a property on our component (like the timers
array) the component won't be updated as the test is executing. To make sure that the component is able to modify its template in response to the property changing, we await
the waitForChanges()
method before continuing the test.
If we run this test initially we should receive:
FAIL src/components/app-home/app-home.spec.ts (9.666 s)
● app-home › should render out an app-timer element equal to the number of elements in the timers array
expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: 0
Now let's add some code to our template to get the test passing. Since we are now running into some of the boilerplate code for the project that we don't need (i.e. the profile page stuff) we are also going to remove that at the same time. This will cause some of the default tests in the application to fail, but then we will just also remove those.
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Home</ion-title>
<ion-buttons slot="end">
<ion-button color="primary"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding">
{this.timers.map((timer) => (
<app-timer></app-timer>
))}
</ion-content>,
];
}
Let's see what that does to our tests...
FAIL src/components/app-home/app-home.e2e.ts (10.336 s)
● app-home › contains a "Profile Page" button
TypeError: Cannot read property 'textContent' of null
15 |
16 | const element = await page.find('app-home ion-content ion-button');
> 17 | expect(element.textContent).toEqual('Profile page');
| ^
18 | });
19 |
20 | it('adds a timer element to the page when the add button is clicked', async () => {
at Object.<anonymous> (src/components/app-home/app-home.e2e.ts:17:20)
One failure we get is related to us breaking some of the existing functionality, but we can just delete that E2E test as it is no longer needed. However, our E2E test for the timers is still failing for the same reason - it is expecting timers to be added to the DOM and they are not being added.
Spying on method calls and firing events
We have a method to add timers, and a button to call that method, but our button doesn't actually do anything when it is clicked. Now we can hook up that button to our addTimer
method to try and progress this test.
Let's write a unit test for that:
it('should trigger the addTimer() method when the add button is clicked', async () => {
const { root, rootInstance } = await newSpecPage({
components: [AppHome],
html: '<app-home></app-home>',
});
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();
});
NOTE: I mentioned that we aren't bothering to make distinctions between unit and integration tests, but this is a good example of what would often be considered an "integration" test. This test is testing how the button interacts with a method, which is really two different things being integrated.
Again, we have added some new tricks in this unit test. First, we are using a Jest spy
to spy on the addTimer
method. A spy will allow us to monitor what happens with a particular method. In this case, we want to check whether that method was called at any point during the test so we use the spyOn
method to set up our timerSpy
. We are also creating a custom click event that we dispatch on the addButton
to simulate what happens when a user clicks on the button.
Let's check that it fails initially:
FAIL src/components/app-home/app-home.spec.ts (11.318 s)
● app-home › should trigger the addTimer() method when the add button is clicked
expect(jest.fn()).toHaveBeenCalled()
Expected number of calls: >= 1
Received number of calls: 0
Great - the test expects our spy to have been called at least once, but it is called 0
times. Now let's add an onClick
handler to our button in the template:
<ion-button
onClick={() => {
this.addTimer();
}}
color="primary"
>
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
Let's see if this gets us any further:
Test Suites: 5 passed, 5 total
Tests: 16 passed, 16 total
Snapshots: 0 total
Time: 19.06 s, estimated 75 s
Ran all test suites.
Hooray! All of our tests pass!
What next?
The fact that all of our tests are passing might seem odd given that our app-timer
component still doesn't exist yet, but let's consider what our original E2E test was actually testing:
it('adds a timer element to the page when the add button is clicked', async () => {
// Arrange
const page = await newE2EPage();
await page.setContent('<app-home></app-home>');
const timerElementsBefore = await page.findAll('app-timer');
const timerCountBefore = timerElementsBefore.length;
const addButtonElement = await page.find('app-home ion-toolbar ion-button');
// Act
await addButtonElement.click();
// Assert
const timerElementsAfter = await page.findAll('app-timer');
const timerCountAfter = timerElementsAfter.length;
expect(timerCountAfter).toEqual(timerCountBefore + 1);
});
All we are testing is that timers are added when the add button is clicked, and a "timer" is just considered to be any app-timer
element that is found in the DOM. Our application will add app-timer
elements to the DOM - they will just be empty elements that have no functionality whatsoever.
Obviously this isn't what we want, but our testing journey here has been a success. Now we are just ready to move on to the next E2E test. If what we really want is for a timer to actually appear or behave in a specific way, we can create a new E2E test to test the functionality we want to build out. We might choose to test something like this in our next E2E test:
it('can display timers that display the total time elapsed', async () => {});
Then we go through the entire journey again. We define this E2E test to test the functionality we want to develop, and then we create unit tests until our E2E test is eventually satisfied.
I will leave this E2E test with you for now to try and solve, but if there is enough interest in this series I will likely pick up from where we have left off in the next tutorial.