Web component development, the test first way.

If you’ve done javascript development with testing frameworks before, and written web components before then this article is for you. If you are curious about web components then this article is also for you as it covers some of the basics of creating a web component, and sets out the basic principles of testing those functionalities.

There is of course a link to the source code in an obligatory git repository at the end of the article.

This article assumes you understand what a web component is and why you would want to create on. For more information please view https://developer.mozilla.org/en-US/docs/Web/Web_Components

In this post I will be creating an open shadowroot component. For more information on shadowroot components please view https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_shadow_DOM

This article also assumes you are familiar with Karma and Jasmine. For more information please view http://karma-runner.github.io/4.0/intro/configuration.html and https://jasmine.github.io/ respectively.


For this example we will be using Google Chrome, if you do not already have it installed, you can get it from here https://www.google.com/chrome/

All development was done on Ubuntu 18.04

Initial environment setup and configuration

mkdir bddwebcomponent
cd bddwebcomponent
npm init

Then supply the options to npm init as follows :

package name: (bddwebcomponent) <Enter your desired package name or stick with default>
version: (1.0.0) <Enter to stick with default>
description: <Enter for nothing or add a description>
entry point: (index.js) <Enter to accept default>
test command: <Enter karma start karma.conf.js>
git repository: <Probably a good idea>
keywords: <Add some if you like>
author: <No time to be shy>
license: <Leave blank or add one, eg GPL-3.0-or-later
Is this OK? (yes) <Enter yes or you will have just wasted your time>
ls
> package.json

Now install Karma

npm i karma --save-dev

ls
>node_modules  package.json  package-lock.json

Now configure Karma

karma init karma.conf.js

And supply the following values

Which testing framework do you want to use ?
Press tab to list possible options. Enter to move to the next question.
> jasmine <Press enter>

Do you want to use Require.js ?
This will add Require.js plugin.
Press tab to list possible options. Enter to move to the next question.
> no <Press enter>

Do you want to capture any browsers automatically ?
Press tab to list possible options. Enter empty string to move to the next question.
> Chrome <Press enter twice>

What is the location of your source and test files ?
You can use glob patterns, eg. "js/*.js" or "test/**/*Spec.js".
Enter empty string to move to the next question.
> <Enter test/**/*.js> NB This glob pattern means all files with the file extension of .js in the test folder.	 
<Press enter again to ignore the warning ‘WARN [init]: There is no file matching this pattern.’

Should any of the files included by the previous patterns be excluded ?
You can use glob patterns, eg. "**/*.swp".
Enter empty string to move to the next question.<Enter empty string>

Do you want Karma to watch all the files and run the tests on change ?
Press tab to list possible options.
> yes <Enter to accept default>
ls

>karma.conf.js  node_modules  package.json  package-lock.json

Edit karma.conf.js in your text editor of choice and change

// list of files / patterns to load in the browser
    files: [
      'test/**/*.js'
    ],

to

// list of files / patterns to load in the browser
    files: [
      { pattern: 'test/**/*.js', type: 'module', included: true },
      { pattern: 'src/**/*.js', included: false },
    ],

Otherwise when you try and run any tests that use the import keyword to import modules will error with the following :

An error was thrown in afterAll
SyntaxError: Cannot use import statement outside a module

And now we can create our test and source code folders and the source file for our web component and test file respectively.

mkdir test
mkdir src

touch src/bddtodos.js
touch test/bddtodostests.js

A brief description of what we are going to build

It will consist of a button, the button will read “Get ToDos” and when pressed will fetch a list of ToDos from https://jsonplaceholder.typicode.com/ and display them underneath the button.

The web component will be called bdd-todos and have the following html tag <bdd-todos>

Now time to do some coding

First we need some boilerplate to set up our basic web component. Open the bddtodos.js file in your editor of choice, and add the following :

export class BDDTodos extends HTMLElement{
	constructor(){
		super();
		var shadow = this.attachShadow({mode: 'open'});	
	}
}
customElements.define('bdd-todos', BDDTodos);

And now to add our first test case to jasmine, open bddtodotests.js and add the following :

import { BDDTodos } from "../src/bddtodos.js"

describe("BDD Todos Component", () => {
	it("creates itself", async () => {
		const f = (await document.createElement('bdd-todos'));
		expect(f).toBeTruthy();
	})
});

Now run it …

karma start karma.conf.js

>Executed 1 of 1 SUCCESS

Now let’s take it up a notch. We need to add our button.

First we add a template string to our class, then create a template element from this string, then clone the template into our shadow root.

We end up with a code listing like below :

export class BDDTodos extends HTMLElement{
	
	constructor(){
		super();
		this.template = `<button id='gettodos'>Get Todos</button>`;

		var t = document.createElement('template');
		t.id = 'bdd-todos-template';
		t.innerHTML = this.template;
		var shadow = this.attachShadow({mode: 'open'})
			.appendChild(t.cloneNode(true));
	}
}
customElements.define('bdd-todos', BDDTodos);

Please note I have used backticks for the template. This is for 2 reasons.

Firstly you could split the template across multiple lines to make it more readable, particularly if the template was something more complex.

Secondly I prefer to enclose html attribute values in single quotes, and this is only possible if my outer quotation marks are something other than single quotes. Double quotes would work for the outer quotation marks but then the first point would not be possible. It is up to you. You could also take advantage of the new template literals functionality of ES6, which you can read more about here https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

Now we need an additional test in our test suite to make sure this behaviour is working as expected :

it("creates a template in the shadow root", async () => {
		const f = document.createElement('bdd-todos');
		expect(f.shadowRoot.innerHTML).toEqual('<template id="bdd-todos-template"><button id="gettodos">Get Todos</button></template>');
	})

The next behaviour that we want to develop is that our button sends a request to the appropriate REST api when clicked. The first challenge is that we have to simulate a button click.

This will of course need a new test fixture, and this time around we will develop the test first, as we would normally do, but in the previous example did not, for the sake of the explanation and to the cost of best practice.

it("fires a button_click function when the button is clicked", async () => {
		const f = document.createElement('bdd-todos');
		/*Create our synthetic button click here*/
		var e = new Event('MouseEvent',{ type: 'click', button: 0 });
		/*Get a reference to the button to send the click to*/
		var b = f.shadowRoot.getElementById('gettodos');
		/*Create a spy on the button_click function*/
		var s = spyOn(f,'button_click');
		/*Dispatch the event to the button*/
		b.dispatchEvent(e);
		/*Expect that the button_click event was called*/
		expect(s).toHaveBeenCalledTimes(1);
	})

What I would like to draw your attention to is that I have declared a variable b, that references the button we wish to simulate being clicked. Then I have created a new MouseEvent, and sent with it button ‘0’ which is the left mouse button. We could simulate all sorts of events in our test cases, including Keyboard events such as keydown, key up. Also I am using a Jasmine spy to observe any calls to the button_click method.

For more information on dispatching custom Events please see here https://javascript.info/dispatch-events

Now we would expect our test to fail as we haven’t created a function in our web component called button_click. Running the tests confirms this …

Error: <spyOn> : button_click() method does not exist
        Usage: spyOn(<object>, <methodName>)
            at <Jasmine>
            at UserContext.<anonymous> (test/bddtodostests.js:19:11)
            at <Jasmine>
Chrome 78.0.3904 (Linux 0.0.0): Executed 3 of 3 (1 FAILED) (0.012 secs / 0.004 secs)
TOTAL: 1 FAILED, 2 SUCCESS

So let’s add the method to the web component.

export class BDDTodos extends HTMLElement{
	
	constructor(){
		super();
		this.template = `<button id='gettodos'>Get Todos</button>`;

		var t = document.createElement('template');
		t.id = 'bdd-todos-template';
		t.innerHTML = this.template;
		var shadow = this.attachShadow({mode: 'open'})
			.appendChild(t.cloneNode(true));
	}

	button_click(e){}
}
customElements.define('bdd-todos', BDDTodos);




This certainly solves the error, but now we have a new one …

TypeError: Cannot read property 'dispatchEvent' of null
            at <Jasmine>
            at UserContext.<anonymous> (test/bddtodostests.js:21:5)
            at <Jasmine>
Chrome 78.0.3904 (Linux 0.0.0): Executed 3 of 3 (1 FAILED) (0.025 secs / 0.014 secs)
TOTAL: 1 FAILED, 2 SUCCESS

Our call to getElementById on the shadowroot didn’t return anything. This is because our button is hidden from the DOM as it is wrapped in a template tag. Templates are not a part of the rendered DOM, but we can create DOM elements from them. Kind of like the difference between classes and objects in object oriented programming languages.

So we need to change our class once again to build our button from the template. 

We do this using cloneNode.

So our webcomponent now looks like this :

export class BDDTodos extends HTMLElement{
	
	constructor(){
		super();
		this.template = `<button id='gettodos'>Get Todos</button>`;
		this.attachShadow({mode: 'open'});
		var t=document.createElement("template");
		t.id = "bdd-todos-template";
		t.innerHTML = this.template;
		this.shadowRoot
.appendChild(t);
		var ti = this.shadowRoot.getElementById('bdd-todos-template');
		this.shadowRoot.appendChild(ti.content.firstChild.cloneNode(true));
	}

	button_click(e){}
}

Note that ti is our reference to the template node and we are appending a clone of its first child, our button element. If we did not clone the first child, it would just move it out of the template and into the shadow root.

We also now need to change our second test fixture to :

it("creates a template in the shadow root", async () => {
		const f = document.createElement('bdd-todos');
		expect(f.shadowRoot.innerHTML).toEqual('<template id="bdd-todos-template"><button id="gettodos">Get Todos</button></template><button id="gettodos">Get Todos</button>');
	})

Now when we run the tests we would expect it to complain that our button_click() method has not been called.

Error: Expected spy button_click to have been called once. It was called 0 times.
            at <Jasmine>
            at UserContext.<anonymous> (test/bddtodostests.js:23:13)
            at <Jasmine>
Chrome 78.0.3904 (Linux 0.0.0): Executed 3 of 3 (1 FAILED) (0.025 secs / 0.011 secs)
TOTAL: 1 FAILED, 2 SUCCESS

So our test is working. Now we just need to wire up the event in our web component.

export class BDDTodos extends HTMLElement{
	
	constructor(){
		super();
		this.template = `<button id='gettodos'>Get Todos</button>`;
		this.shadow = this.attachShadow({mode: 'open'});
		var t=document.createElement("template");
		t.id = "bdd-todos-template";
		t.innerHTML = this.template;
		this.shadowRoot.appendChild(t);
		var ti = this.shadowRoot.getElementById('bdd-todos-template');
		this.shadowRoot.appendChild(ti.content.firstChild.cloneNode(true));
		this.shadowRoot.getElementById('gettodos').addEventListener('click',this.button_click());
	}

	button_click(e){ }
}

customElements.define('bdd-todos', BDDTodos);

Yet when we run our code it still fails with

Error: Expected spy button_click to have been called.
            at <Jasmine>
            at UserContext.<anonymous> (test/bddtodostests.js:24:26)
            at <Jasmine>
Chrome 78.0.3904 (Linux 0.0.0): Executed 3 of 3 (1 FAILED) (0.031 secs / 0.011 secs)

This is because the actual event in the class was fired by the event, but our spy wasn’t. If we re-add the event in our test fixture then the spy is called and it works.

it("It fires a button_click function when the button is clicked", async () => {
		const f = document.createElement('bdd-todos');
		/*Create our synthetic button click here*/
		var e = new Event('MouseEvent',{ type: 'click', button: 0 });
		/*Get a reference to the button to send the click to*/
		var b = f.shadowRoot.getElementById('gettodos');
		/*Create a spy on the button_click function*/
		spyOn(f,'button_click');
		f.addEventListener('click', f.button_click());
		/*Dispatch the event to the button*/
		b.dispatchEvent(e);
		/*Expect that the button_click event was called*/
		expect(f.button_click).toHaveBeenCalled();
	})
Executed 3 of 3 SUCCESS

OK so finally what we need our web component to do is to call a fetch api, in this case the ToDos at https://jsonplaceholder.typicode.com/

But for this to be a true unit test we cannot integrate with an external resource. Instead we have to mock it. This is really easy to do and quite fun.

Firstly we need to start from the expected JSON results and work back from there.
So if we called the TODOs api, with a limit of 5 we would get, from https://jsonplaceholder.typicode.com/todos?_start=0&_limit=5

[
  {
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
  },
  {
    "userId": 1,
    "id": 2,
    "title": "quis ut nam facilis et officia qui",
    "completed": false
  },
  {
    "userId": 1,
    "id": 3,
    "title": "fugiat veniam minus",
    "completed": false
  },
  {
    "userId": 1,
    "id": 4,
    "title": "et porro tempora",
    "completed": true
  },
  {
    "userId": 1,
    "id": 5,
    "title": "laboriosam mollitia et enim quasi adipisci quia provident illum",
    "completed": false
  }
]

So we can add a beforeAll method to build our expected response, and use the same spyOn class to observe the call to the api

beforeAll(function() {
		promisedData =
		[
			{
			  "userId": 1,
			  "id": 1,
			  "title": "delectus aut autem",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 2,
			  "title": "quis ut nam facilis et officia qui",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 3,
			  "title": "fugiat veniam minus",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 4,
			  "title": "et porro tempora",
			  "completed": true
			},
			{
			  "userId": 1,
			  "id": 5,
			  "title": "laboriosam mollitia et enim quasi adipisci quia provident illum",
			  "completed": false
			}
		  ]
	})

Then we will of course need a new test case :

it("It calls a Rest API when the button is clicked", async () => {
		const f = document.createElement('bdd-todos');
		var e = new Event('MouseEvent',{ type: 'click', button: 0 });
		/*This spy not only observes the fetch api call, but it also supplies the response*/
		spyOn(window, 'fetch').and.returnValue(Promise.resolve({ json: () => Promise.resolve(promisedData)}));
		var b = f.shadowRoot.getElementById('gettodos');
		b.dispatchEvent(e);
		/*Check to make sure the fetch api was called, and only called once*/
		expect(window.fetch).toHaveBeenCalledTimes(1);
	})

Note that a response object returned by the fetch api has a json() method, this is just a promise within a promise that returns the stream of json. In this mock we simply wrap a promise around this using an anonymous method that returns a promise which resolves to our JSON data, and map this anonymous method to a function named json. Your web component will not know the difference. 🙂

Of course when we run this we get a fail as expected, as our web component does not make the fetch api call yet, so we get…

Error: Expected spy fetch to have been called once. It was called 0 times.
            at <Jasmine>
            at UserContext.<anonymous> (test/bddtodostests.js:71:24)
            at <Jasmine>
Chrome 78.0.3904 (Linux 0.0.0): Executed 4 of 4 (1 FAILED) (0.022 secs / 0.01 secs)
TOTAL: 1 FAILED, 3 SUCCESS

So we add the following to our button_click method :

 button_click(e){
		fetch('https://jsonplaceholder.typicode.com/todos?_start=0&_limit=5')
			.then(response => response.json())
			.then(data => {
				this.todos = data;
			});
	}
Executed 4 of 4 SUCCESS

So now we know that the fetch api was called, but we cannot prove the data was retrieved successfully. We need to add another check to our last test fixture.

However, what we really should do is add a completely new describe block, run the event in the beforeAll method and then run our tests in our individual test suites, kind of a separation of concerns. We should only be testing outcomes in our specs, in effect.

describe("BDD Todos Component REST API Tests", () => {
	var promisedData;
	var f;
	beforeAll(function() {
		promisedData =
		[
			{
			  "userId": 1,
			  "id": 1,
			  "title": "delectus aut autem",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 2,
			  "title": "quis ut nam facilis et officia qui",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 3,
			  "title": "fugiat veniam minus",
			  "completed": false
			},
			{
			  "userId": 1,
			  "id": 4,
			  "title": "et porro tempora",
			  "completed": true
			},
			{
			  "userId": 1,
			  "id": 5,
			  "title": "laboriosam mollitia et enim quasi adipisci quia provident illum",
			  "completed": false
			}
		  ];
		f = document.createElement('bdd-todos');
		var e = new Event('MouseEvent',{ type: 'click', button: 0 });
		/*This spy not only observes the fetch api call, but it also supplies the response*/
		spyOn(window, 'fetch')  
			.and.returnValue(Promise.resolve({ json: () => Promise.resolve(promisedData)}));

		f.addEventListener('click', f.button_click());
		
		var b = f.shadowRoot.getElementById('gettodos');
		b.dispatchEvent(e);
	})
	it("calls a Rest API when the button is clicked", async() => {
		expect(window.fetch).toHaveBeenCalledTimes(1);
		expect(f.todos).toEqual(promisedData);
	})
});

Now we know that the rest API was called once, and that the promise returned was successfully processed.

Using the tools here, it would be possible to turn the returned data into a table of results and display them in the component underneath. Each row could be a template that contains a row element that is cloned or you could just create the html elements yourself and append them to the shadowRoot.

Clearly, anything you do can be tested using behavioural driven development in Jasmine, and web components are no exception.

Happy testing!
As promised, the obligatory github code repository can be found here https://github.com/suityou01/unittestwebcomponent

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.