The Basics of Unit Testing in StencilJS
Automated testing such as unit tests and end-to-end tests are, in my view, one of the biggest "level up" mechanics available to developers who are looking to improve their skillset. It is difficult and might require a lot of time to learn to get to the point where you feel competent in writing tests for your code, and even once you have the basics down there is always room for improvement to create better tests - just as you can continue to learn to write better and better code.
The funny thing about automated tests is that they are so valuable, but at the same time not at all required (and are therefore often skipped). You don't need tests associated with your code to make them work, and writing tests won't directly impact the functionality of your application at all. However, even though the tests themselves won't change your code directly or the functionality of your application, they will facilitate positive changes in other ways.
Some of the key benefits of investing time in automated tests include:
- Documentation - unit tests are set out in such a way that they accurately describe the intended functionality of the application
- Verification - you can have greater confidence that your application's code is behaving the way you intended
- Regression Testing - when you make changes to your application you can be more confident that you haven't broken anything else, and if you have there is a greater chance that you will find out about it, as you can run the same tests against the new code
- Code Quality - it is difficult to write effective tests for poorly designed/organised applications, whereas it is much easier to write tests for well-designed code. Naturally, writing automated tests for your applications will force you into writing good code
- Sleep - you'll be less of a nervous wreck when deploying your application to the app store because you are going to have a greater degree of confidence that your code works (and the same goes for when you are updating your application)
In this tutorial, we are going to focus on the basics of writing unit tests for StencilJS components. This tutorial will have an emphasis on using Ionic with StencilJS, but the same basic concepts will apply regardless of whether you are using Ionic or not.
If you would like a quick preview of what creating and running unit tests in a StencilJS application looks like, you can check out the video I recorded to accompany this tutorial: Creating Unit Tests for StencilJS Components. Most of the details are in this written tutorial, but the video should help to give you a bit of context.
Outline
Source codeKey Principles
Before we get into working with some unit tests, we need to cover a few basic ideas. StencilJS uses Jest for unit tests, which is a generic JavaScript testing framework. If you are already familiar with Jasmine then you will already understand most of what is required for writing tests with Jest, because the API is extremely similar.
I have already written many articles on creating unit tests with Jasmine, so if you need a bit of an introduction to the basic ideas I would recommend reading this article first: How to Unit Test an Ionic/Angular Application - of course, we are using Jest not Jasmine (as an Ionic/Angular application uses) but the basic concepts are almost identical.
Although the article above goes into more detail, I'll also break down the basic concepts here. The basic structure for a unit test might look something like this:
describe('My Service', () => {
it('should correctly add numbers', () => {
expect(1 + 1).toBe(2);
});
});
Jest provides the describe
, it
, and expect
methods that we are using above to create the test. In this case, we are creating a test that checks that numbers are added together correctly. This is just an example, and since we are just directly executing 1 + 1
(rather than testing some of our actual code that has the task of doing that) we aren't really achieving anything because this is always going to work.
describe()
defines a test suite (e.g. a "collection") of tests (or "specs")it()
defines a specific test or "spec", and it lives inside of a suite (describe()
). This is what defines the expected behaviour of the code you are testing, e.g. "it should do this", "it should do that"expect()
defines the expected result of a test and lives inside ofit()
A unit test is a chunk of code that is written and then executed to test that a single "unit" of your code behaves as expected. A unit test might test that a method returns the expected value for a given input, or perhaps that a particular method is called when the component is initialised. It is important that unit tests are small and isolated, they should only test one very specific thing. For example, we shouldn't create a single "the component works" test that executes a bunch of different expect
statements to test for different things. We should create many small individual unit tests that each have the responsibility of testing one small unit of code.
There are many concepts and principles to learn when it comes to creating automated tests, and you could literally read entire books just on the core concepts of testing in general (without getting into specifics of a particular framework or test runner). However, I think that one of the most important concepts to keep in mind is: Arrange, Act, Assert or AAA. All of your tests will follow the same basic structure of:
- Arrange - get the environment/dependencies/components set up in the state required for the test
- Act - run code in the test that will execute the behaviour you are testing
- Assert - make a statement of what the expected result was (which will determine whether or not the test passed)
We will look at examples of this throughout the tutorial (especially when we get into writing our own unit tests).
Executing Unit Tests in an Ionic/StencilJS Application
Let's start with running some unit tests and taking a look at the output. By default, a newly generated Ionic/StencilJS application will have a few unit tests and end-to-end tests already created for the default functionality provided. This is great because we can take a look at those tests to get a general idea of what they look like, and also execute the tests to see what happens.
First, you should generate a new ionic-pwa
application with the following command:
npm init stencil
Once you have the application generated, and you have made it your current working directory, you should create an initial build of your application with:
npm run build
Then to execute the tests that already exist in the application you will just need to run:
npm test
The first time you run this, it will install all of the required dependencies to run the tests. This might take a while, but it won't take this long every time you run this command.
The default test output for an untouched blank ionic-pwa
application will look like this (I have trimmed this down a little for space):
PASS src/components/app-profile/app-profile.spec.ts
PASS src/components/app-root/app-root.spec.ts
PASS src/components/app-home/app-home.e2e.ts
PASS src/components/app-profile/app-profile.e2e.ts
Console
PASS src/components/app-root/app-root.e2e.ts
Console
PASS src/components/app-home/app-home.spec.ts
Test Suites: 6 passed, 6 total
Tests: 13 passed, 13 total
Snapshots: 0 total
Time: 3.208s
Ran all test suites.
We can see that there are 13
tests in total across the three default components, and all of them have executed successfully. Let's take a closer look at what these tests are actually doing by opening the src/components/app-home/app-home.spec.ts file:
import { AppHome } from './app-home';
describe('app', () => {
it('builds', () => {
expect(new AppHome()).toBeTruthy();
});
});
This looks similar to the example test that we looked at before, except now we are actually testing real code. We import the AppHome
component into the test file, and then we have a test that creates a new instance of AppHome
and checks that it is "truthy". This doesn't mean that the result needs to be true
but that the result has a "true" value such that it is an object or a string or a number, rather than being null
or undefined
which would be "falsy". This test will ensure that AppHome
can be successfully instantiated. This is a basic test that you could add to any of your components.
Now let's have a bit of fun by making it fail by changing toBeTruthy
to toBeFalsy
:
import { AppHome } from './app-home';
describe('app', () => {
it('builds', () => {
expect(new AppHome()).toBeFalsy();
});
});
If we execute the tests now, we will get a failure:
FAIL src/components/app-home/app-home.spec.ts
● app › builds
expect(received).toBeFalsy()
Received: {}
3 | describe("app", () => {
4 | it("builds", () => {
> 5 | expect(new AppHome()).toBeFalsy();
| ^
6 | });
7 | });
8 |
at Object.it (src/components/app-home/app-home.spec.ts:5:27)
Test Suites: 1 failed, 5 passed, 6 total
Tests: 1 failed, 12 passed, 13 total
Snapshots: 0 total
Time: 3.56s
Ran all test suites.
We can see that we are expecting the received value to be "falsy", but it is a "truthy" value and so the test fails. This should highlight some of the usefulness of unit tests. When our code isn't doing what we expect it to, we can see exactly where it is failing and why (assuming the tests are defined well). This is one of the reasons to design small/isolated unit tests, as when a failure occurs it will be more obvious what caused the failure.
NOTE: Remember to change the test back to toBeTruthy
so that it doesn't continue to fail
Now let's take a look at the unit tests for the profile page defined in src/components/app-profile/app-profile.spec.ts which are a little more interesting:
import { AppProfile } from './app-profile';
describe('app-profile', () => {
it('builds', () => {
expect(new AppProfile()).toBeTruthy();
});
describe('normalization', () => {
it('returns a blank string if the name is undefined', () => {
const component = new AppProfile();
expect(component.formattedName()).toEqual('');
});
it('capitalizes the first letter', () => {
const component = new AppProfile();
component.name = 'quincy';
expect(component.formattedName()).toEqual('Quincy');
});
it('lower-cases the following letters', () => {
const component = new AppProfile();
component.name = 'JOSEPH';
expect(component.formattedName()).toEqual('Joseph');
});
it('handles single letter names', () => {
const component = new AppProfile();
component.name = 'q';
expect(component.formattedName()).toEqual('Q');
});
});
});
We still have the basic "builds" test, but we also have some specific tests related to the functionality of this particular component. The profile page has some functionality built into that handles formatting the user's name. These tests cover that functionality. Notice that we don't just have a single "it formats the name correctly" test. The process for formatting the name involves several different things, so these are good unit tests in that they are individually testing for each "unit" of functionality of the name formatting.
Generally speaking, the more unit tests you have and the more isolated they are the better, but especially as a beginner try not to obsess too much over getting things "perfect". Having a bloated unit test is still much better than not testing at all. The one thing you really do have to watch out for is that your tests are actually testing what you think they are. You might design a test in a way where it will always pass, no matter what code you have implemented. This is bad, because it will make you think your code is working as expected when it is not. The Test Driven Development approach, which we will briefly discuss in a moment, can help alleviate this.
Writing Our Own Unit Tests
To finish things off, let's actually build out some tests of our own in a new component. It is simple enough to look at some pre-defined test and have a basic understanding of what is being tested, but when you come to write your own tests it can be really difficult. We will look into creating an additional app-detail
component, which will have the purpose of excepting an id
as a prop from the previous page, and then using that id
to fetch an item from a service.
We are going to follow a loose Test Driven Development (TDD) approach here. This is a whole new topic in itself. It is a structured and strict process for writing tests, but I think it can actually help beginners get into testing. Since there is a strict process to follow, it becomes easier to determine what kind of tests you should be writing and when you should write them.
I won't get into a big spiel about what TDD is in this tutorial, but for some context, I would recommend reading one of my previous articles on the topic: Test Driven Development in Ionic: An Introduction to TDD.
The basic idea behind Test Driven Development is that you write the tests first. This means that you will be attempting to test code that does not even exist yet. You will follow this basic process:
- Write a test that tests the functionality you want to implement
- Run the test (it will fail)
- Write the functionality to satisfy the test
- Run the test (it will hopefully pass - if not, fix the functionality or the test if required)
The key benefit to beginners with this process is that you know what to create tests for, and you can be reasonably confident the test is correct if it fails initially but after implementing the functionality the test works. It is an important step to make sure the test fails first.
Let's get started by defining the files necessary for our new component. Create the following files:
- src/components/app-detail/app-detail.tsx
- src/components/app-detail/app-detail.css
- src/components/app-detail/app-detail.spec.ts
Before we implement any code at all for the app-detail
component, we are going to create a test in the spec file:
import { AppDetail } from './app-detail';
describe('app', () => {
it('builds', () => {
expect(new AppDetail()).toBeTruthy();
});
});
If we try to run this, we are going to get a failure:
FAIL src/components/app-detail/app-detail.spec.ts
● app › builds
TypeError: app_detail_1.AppDetail is not a constructor
3 | describe("app", () => {
4 | it("builds", () => {
> 5 | expect(new AppDetail()).toBeTruthy();
| ^
6 | });
7 | });
8 |
at Object.it (src/components/app-detail/app-detail.spec.ts:5:12)
PASS src/components/app-root/app-root.spec.ts
Test Suites: 1 failed, 6 passed, 7 total
Tests: 1 failed, 13 passed, 14 total
Snapshots: 0 total
Time: 3.363s
Ran all test suites.
This makes sense, because we haven't even created the AppDetail
component yet. Now let's define the component to satisfy the test:
import { Component, h } from '@stencil/core';
@Component({
tag: 'app-home',
styleUrl: 'app-home.css',
})
export class AppHome {
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Detail</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding" />,
];
}
}
If you run the tests again, you should see that all of them pass. We're still in pretty boring territory here, because we've seen all of this in the default tests. Let's create tests for the key functionality of our detail page.
We want two key things to happen in this component:
- An
itemId
prop should be available to pass in an itemsid
- A call to the
getItem()
method of theItemService
should be called with theitemId
from the prop
We are very specifically and intentionally just checking that a call is made to the getItem()
method, not that the correct item is returned from the service. Our unit tests should only be concerned with what is happening in isolation, it is not the role of this unit test to check that the ItemService
is also working as intended (this would be the role of the services own unit tests, or of "integration" or "end-to-end" tests which take into consideration the application as a whole rather than individual units of code).
Let's take a look at how we might implement these tests, and then we will try to satisfy them by implementing some code. We will start with testing the itemId
prop.
import { AppDetail } from './app-detail';
import { newSpecPage } from '@stencil/core/testing';
describe('app', () => {
it('builds', () => {
expect(new AppDetail()).toBeTruthy();
});
describe('item detail fetching', () => {
it('has an itemId prop', async () => {
// Arrange
const page = await newSpecPage({
components: [AppDetail],
html: `<div></div>`,
});
let component = page.doc.createElement('app-detail');
// Act
(component as any).itemId = 3;
page.root.appendChild(component);
await page.waitForChanges();
// Assert
expect(page.rootInstance.itemId).toBe(3);
});
it('calls the getItem method of ItemService with the supplied id', () => {});
});
});
The first thing to notice here is that our "has an itemId prop"
test is async
- this is necessary where you want to make use of asynchronous code in a test. That brings us to the asynchronous code that we are using await
with and that is newSpecPage
.
This is a StencilJS specific concept. Sometimes, we will be able to just instantiate our component with new AppDetail()
and test what we need, but sometimes we need the component to actually be rendered as StencilJS would build the component and display it in the browser. This means that we can do this:
const page = await newSpecPage({
components: [AppDetail],
html: `<app-detail></app-detail>`,
});
To test rendering our app-detail
component. We can supply whatever components
we need available, and then define whatever template we want using those components. We aren't actually doing that here, though. Instead, we are rendering out a simple <div>
and then we are manually creating the app-detail
element and adding it to the page
that we created.
The reason we are doing this is so that we can supply whatever type of prop
value we want to the component, which we are doing here:
(component as any).itemId = 3;
NOTE: We are also using as any
to ignore type warnings.
This is a fantastic concept (using newSpecPage
to create a <div>
and then appending the actual component) that I came across from Tally Barak in this blog post: Unit Testing Stencil One. That post also has a bunch of other great tips you can check out.
With this method, we assign whatever kind of prop we need to the component, and then we use appendChild
to add it to the template. We then wait for any changes to happen, and then we check that the itemId
has been set to 3
. This test is a good example of how tests can be split up into Arrange, Act, Assert.
If we run this now, the test should fail:
FAIL src/components/app-detail/app-detail.spec.ts
● app › item detail fetching › has an itemId prop
expect(received).toBe(expected) // Object.is equality
Expected: 3
Received: undefined
23 |
24 | // Assert
> 25 | expect(page.rootInstance.itemId).toBe(3);
| ^
26 | });
27 |
28 | it("calls the getItem method of ItemService with the supplied id", () => {
at Object.it (src/components/app-detail/app-detail.spec.ts:25:40)
Now let's try to satisfy that test with some code:
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'app-detail',
styleUrl: 'app-detail.css',
})
export class AppDetail {
@Prop() itemId: number;
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Detail</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding" />,
];
}
}
If you run the tests again, unlike Balrogs in the depths of Moria, they should now pass. Now let's implement our test for interfacing with the ItemService
:
import { AppDetail } from "./app-detail";
import { ItemService } from "../../services/items";
import { newSpecPage } from "@stencil/core/testing";
describe("app", () => {
it("builds", () => {
expect(new AppDetail()).toBeTruthy();
});
describe("item detail fetching", () => {
it("has an itemId prop", async () => {
// Arrange
const page = await newSpecPage({
components: [AppDetail],
html: `<div></div>`
});
let component = page.doc.createElement("app-detail");
// Act
(component as any).itemId = 3;
page.root.appendChild(component);
await page.waitForChanges();
// Assert
expect(page.rootInstance.itemId).toBe(3);
});
it("calls the getItem method of ItemService with the supplied id", async () => {
// Arrange
ItemService.getItem = jest.fn();
const page = await newSpecPage({
components: [AppDetail],
html: `<div></div>`
});
let component = page.doc.createElement("app-detail");
// Act
(component as any).itemId = 5;
page.root.appendChild(component);
await page.waitForChanges();
// Assert
expect(ItemService.getItem).toHaveBeenCalledWith(5);
});
});
});
This test is making use of an ItemService
which we haven't talked about or created. Typically, you would create that service in the same way by defining some tests first and then implementing the functionality, but for this tutorial, we are just going to create the service in src/services/items.ts:
class ItemServiceController {
constructor() {}
async getItem(id: number) {
return { id: id };
}
}
export const ItemService = new ItemServiceController();
The test that we have created is very similar to the first one, except now we are setting up a "mock/spy" on our ItemService
. I would recommend reading a little about mock functions here: Mock Functions. The basic idea is that rather than using our real ItemService
we instead swap it out with a "fake" or "mocked" service, which we can then "spy" on to check a bunch of things like whether or not its methods were called in the test.
In this test, we just supply our prop again, but this time we check that at the end of the test the ItemService.getItem
method should have been called. This is because the component should automatically take whatever prop
is supplied, and then use that value to fetch the item from the service using getItem
.
If we run that test now, it should fail:
FAIL src/components/app-detail/app-detail.spec.ts
● app › item detail fetching › calls the getItem method of ItemService with the supplied id
expect(jest.fn()).toHaveBeenCalledWith(expected)
Expected mock function to have been called with:
[5]
But it was not called.
44 |
45 | // Assert
> 46 | expect(ItemService.getItem).toHaveBeenCalledWith(5);
| ^
47 | });
48 | });
49 | });
at Object.it (src/components/app-detail/app-detail.spec.ts:46:35)
Now let's implement the functionality:
import { Component, Prop, h } from '@stencil/core';
import { ItemService } from '../../services/items';
@Component({
tag: 'app-detail',
styleUrl: 'app-detail.css',
})
export class AppDetail {
@Prop() itemId: number;
async componentDidLoad() {
console.log(await ItemService.getItem(this.itemId));
}
render() {
return [
<ion-header>
<ion-toolbar color="primary">
<ion-title>Detail</ion-title>
</ion-toolbar>
</ion-header>,
<ion-content class="ion-padding" />,
];
}
}
and we should see the following result:
Test Suites: 7 passed, 7 total
Tests: 16 passed, 16 total
Snapshots: 0 total
Time: 3.248s
Ran all test suites.
All of our tests are now passing!
Summary
This has been a somewhat long and in-depth tutorial, but it still barely scratches the surface of testing concepts. There is still much more to learn, and it will take time to become competent in creating automated tests. My advice is always to just start writing tests, and don't worry too much about making them perfect or following best-practices (just don't assume that your tests are actually verifying the functionality of your application in the beginning). If you worry too much about this, you might be too intimidated to even start.
If you are already competent in writing tests with Jasmine, Jest, or something similar, then creating tests for StencilJS components likely won't be too difficult. The biggest difference will likely be the use of newSpecPage
to test components where required.