Testing Web Components with @web/test-runner
So, you write web components and you're interested in expanding the work you put into unit testing them? Well, you've come to the right place. This is just the beginning, but Testing Web Components: the Series is going to lay out for you how Open Web Components and Modern Web help you to do just that. We'll start with how the Open Web Components generator can get you up and running in no time with @web/test-runner
right out of the box.
While the component and tests that come with the generator are a great place to get started, in future posts we'll dig even deeper into what it means to test your web components in real browsers. Along the way, we'll focus on several realities around unit testing web interfaces, like:
- the capabilities (and limitations) of testing UI from the JS thread
- how you can test more like a user will consume your component, whether that's with a mouse, a keyboard, or a little slower the normal JS execution time
- what sort of accessibility guarantees you can test at unit time
- and more...
Before we get started
To prepare, let's take a look at the component that comes with the Open Web Components generator and the tests that are already written for it. To get started run npm init @open-wc@latest
in your terminal. Be aware that npm init
has a pretty sticky cache. If you've run the command before, the inclusion of @latest
will ensure you get the most current output. We'll be scaffolding a new project, in which we will build a web component, to which we'll want to add testing, all of which we'll do in Typescript, and will call our component testing-components
. If you give the generator permission to write this content to disk and install dependencies with NPM, the terminal output should look like the following:
npm init @open-wc@latest
Need to install the following packages:
@open-wc/create@0.38.69
Ok to proceed? (y) y
_.,,,,,,,,,._
.d'' ``b. Open Web Components Recommendations
.p' Open `q.
.d' Web Components `b. Start or upgrade your web component project with
.d' `b. ease. All our recommendations at your fingertips.
:: ................. ::
`p. .q'
`p. open-wc.org .q'
`b. @openWc .d'
`q.. ..,' See more details at https://open-wc.org/init/
'',,,,,,,,,,''
Note: you can exit any time with Ctrl+C or Esc
✔ What would you like to do today? › Scaffold a new project
✔ What would you like to scaffold? › Web Component
✔ What would you like to add? › Testing (web-test-runner)
✔ Would you like to use typescript? › Yes
✔ What is the tag name of your web component? … testing-components
./
├── testing-components/
│ ├── .vscode/
│ │ └── extensions.json
│ ├── demo/
│ │ └── index.html
│ ├── src/
│ │ ├── index.ts
│ │ ├── testing-components.ts
│ │ └── TestingComponents.ts
│ ├── test/
│ │ └── testing-components.test.ts
│ ├── .editorconfig
│ ├── .gitignore
│ ├── LICENSE
│ ├── package.json
│ ├── README.md
│ ├── tsconfig.json
│ ├── web-dev-server.config.mjs
│ └── web-test-runner.config.mjs
✔ Do you want to write this file structure to disk? › Yes
Writing..... done
✔ Do you want to install dependencies? › Yes, with npm
Now, if you run cd testing-components
to get into the generated project, and open the directory in your editor of choice, we'll dive into what's been created for you.
The generated project
The first thing you'll notice are the choices from the Open Web Components generator that we didn't adopt; Linting (eslint & prettier) and Demoing (Storybook). As they aren't specifically germane to actually testing our web component, I've left them out of the repo as generated for this article, but that doesn't mean you have to do so. Whether you chose to include them now, or you choose to "Upgrade an existing project" later, these things won't specifically get in the way, but in a world where everything happens 90 miles a minute, it can be useful to start with some focus.
Then, you'll see what we did include:
- the
demo
directory holds a very simpleindex.html
that is served by@web/dev-server
via thenpm start
command, if you'd like to check out the web component that comes stock with the generator in action - the
dist
directory holds the JS output from runningtsc
against our Typescript source, this command is included before almost all other scripts you may choose to run, so you shouldn't ever need to do so directly - the
src
directory holds our web component which is broken into anindex.js
re-exporting the class definition exported fromTestingComponents.ts
as well astesting-components.ts
that specifically registers that same class as thetesting-component
custom element name - the
test
directory with one test file,testing-components.test.ts
, that can be run via thenpm test
command
Don't let me stop you, now. I know you want to see for yourself, "will it blend?". Give npm start
a run... you'll get something like:
You can also give the tests a blend with the npm test
command. If all things have gone as planned the output should be:
npm test
> testing-components@0.0.0 test
> tsc && wtr --coverage
dist/test/testing-components.test.js:
🚧 Browser logs:
Lit is in dev mode. Not recommended for production! See https://lit.dev/msg/dev-mode for more information.
Chrome: |██████████████████████████████| 1/1 test files | 4 passed, 0 failed
Code coverage: 100 %
View full coverage report at coverage/lcov-report/index.html
Finished running tests in 9s, all tests passed! 🎉
So, we've got a pretty minimal web component and four passing tests. Let's dig in a little further to see what have we gotten for our trouble and what is actually under test by default here.
The <testing-components>
element
There's not much to our <testing-component>
element, but here it is in all of its glory:
import { html, css, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
export class TestingComponents extends LitElement {
static styles = css`
:host {
display: block;
padding: 25px;
color: var(--testing-components-text-color, #000);
}
`;
@property({ type: String }) header = 'Hey there';
@property({ type: Number }) counter = 5;
__increment() {
this.counter += 1;
}
render() {
return html`
<h2>${this.header} Nr. ${this.counter}!</h2>
<button @click=${this.__increment}>increment</button>
`;
}
}
The generated Typescript above heavily relies on Lit to keep your web component both fast and simple. Here are some important facts to take away from this code regarding the tests we'll investigate next:
- the class definition leverages the
LitElement
base class, so check out lit.dev for more information about what that means when we get to adding new functionality in later installments - the
property
decorator is being leveraged to upgrade a couple of properties to be both reactive and bound to attributes on the element - the
html
template literal from is being leveraged to performantly render out template - the
@
sigil is being leveraged to declaratively listen to events on elements within our template
The tests
In our tests, a web component defined by our TestingComponents
class is ensured to have the following characteristics:
- it has a default header "Hey there" and counter 5
- it increases the counter on button clicks
- it can override the header via attribute
- it asses the a11y audit
Defaults, interactivity, customization, and accessibility, form a great baseline for testing most web UI, so let's look closer at how this is done for our <testing-components>
element, and what else might be useful in these areas.
Defaults
As we saw above, the @property
decorator is being leveraged to create reactive properties on our class definition. header
is being decorated with type: string
meaning when its value is accepted from the attribute header
the value will be coerced to a string. Similarly, the counter
is decorated with type: number
, which means values applied to the counter
attribute will be coerced to a number. A handful of these conversions are handled out of the box by Lit, but in case you have something more complex in mind, learn about custom converters to solve those use cases.
Numbers and strings being pretty de rigueur, the following test confirms that those values are actually applied to the custom element when attached to the DOM:
it('has a default header "Hey there" and counter 5', async () => {
const el = await fixture<TestingComponents>(html`<testing-components></testing-components>`);
expect(el.header).to.equal('Hey there');
expect(el.counter).to.equal(5);
});
Testing that a property equals its default value by default may feel a bit superfluous, but it ensures the baseline of your web component before you dig into anything else. Testing that a property equals a property may also look a little underwhelming, it doesn't actually test anything about the property, and you may be right. Depending on what such a property does in a more complete web component, it may be advantageous to confirm that those properties appear appropriately in a DOM snapshot or in a visual regression test. We'll get into these techniques, and more, in future installments.
Interactivity
When testing web UI it is important to think of your test inputs as a future visitor interacting with your web component or a future developer building with your web component. A visitor can interact with your code in many ways, including a mouse and a keyboard, and @web/test-runner
offers some powerful ways to emulate those interactions that we will investigate, later. This generated test acts as a developer building with your web component; leveraging the fact that @web/test-runner
exists on the JS thread of an actual browser to leverage element APIs like querySelector()
and click()
within your test code:
it('increases the counter on button click', async () => {
const el = await fixture<TestingComponents>(html`<testing-components></testing-components>`);
el.shadowRoot!.querySelector('button')!.click();
expect(el.counter).to.equal(6);
});
If you're wondering why someone building with your web component might reach into your element and manually click()
something within your element, you're probably not the only one. However, at test time, doing this can sometimes be the only way to initiate interactions from the JS context. Other times, it can be a clear sign the API of your element hasn't fully considered the requirements of its public interface.
Is clicking this button something that a consuming application should be able to do without human intervention? The answer may be "no", as is the case in the generated code, but, if the answer is "yes", you may want to refactor your element to prevent consuming developers from having to reach into your web component in this way. Understanding the various techniques of testing a web component can help surface these questions, and their answers before your code ships to consumers.
Customization
By using the property
decorator in our class definition we not only upgraded those properties to be reactive, but we also bound those properties to attributes on our custom element. In this way, both header
and counter
can be supplied new values via the attributes of the same name. The following test ensures that this relationship is maintained for the header
attribute/property:
it('can override the header via attribute', async () => {
const el = await fixture<TestingComponents>(
html`<testing-components header="attribute header"></testing-components>`,
);
expect(el.header).to.equal('attribute header');
});
If you are one for being "complete", the same could be done for counter
as well. This test specifically leverages the html
template literal to create an instance of the <testing-components>
element and applies the value attribute header
as an attribute to that element. You could similarly apply that attribute manually by leveraging the element reference maintained in the el
variable; this would be the equivalent imperative operation el.setAttribute('header', 'attribute header');
, if you were one for being "complete"...
Accessibility (a11y)
Not only does the Open Web Components project provide the generator that we are basing our web component on in this conversation, but they also vend Chai A11y aXe which binds axe-core
with Chai assertions for easy consumption in the @web/test-runner
environment. This means that you can use the industry standard accessibility testing engine to confirm the accessibility of your web component at unit test time. This looks like the following:
it('passes the a11y audit', async () => {
const el = await fixture<TestingComponents>(html`<testing-components></testing-components>`);
await expect(el).shadowDom.to.be.accessible();
});
Important note: expect().to.be.accessible()
is an asynchronous command. If you do not await
this expectation you will get false positives in your test results.
But, when you do await
it, ensuring the snapshot accessibility of your custom element is just that simple. I saw "snapshot" here in that .accessible()
knows nothing about your web component other than the state from which you called it. That means if further interactions with the element change the accessibility model, you should be sure to add a test, or tests, that takes your web component into that state and confirms its accessibility in that context. In future articles, we'll take a close look at what that means, as well as the difference between a component possessing an accessible tree of DOM nodes and specifically delivering a unique accessibility tree to assistive technology.
What have we got?
Now you've got a pretty well-tested <testing-components>
element that features a header
and counter
attribute that delivers a button to your consumers that allows them to increment the counter. What more could you want?
Oh... a lot?
That seems fair. There isn't a lot in the component we've generated, which is good because it means that were you actually starting a new project with actual requirements there wouldn't be much to take away to start focusing on those. When we come back together in the future, we'll start from right here where we've left off and go deeper into testing web components in the browser with @web/test-runner
. Hopefully, you've got at least a rough handle on how things come together in this area and are excited to come along for the ride.
What's next...
Now we know how to get started with a new web component project leveraging Open Web Components generator and understand the basics of testing the defaults, interactivity, customization and accessibility of that element with @web/test-runner
, but, in the long story of UI development and testing, we're just getting started. Throughout of future conversations, we'll be digging into more topics important to testing web components, like:
- the capabilities (and limitations) of testing UI from the JS thread
- how you can test more like a user will consume your component, whether that's with a mouse, a keyboard, or a little slower the normal JS execution time
- what sort of accessibility guarantees you can test at unit time
- testing with DOM snapshots
- testing for visual regression
- and more...
If you've got a sharp eye, you'll notice the list is already getting longer as we go. Help me find things that I've missed. Take to the comments below with any concepts you would like to see covered in future installments. I can't guarantee that I'll be able to cover them, but now that you've gotten started testing web components, maybe you'll be able to teach me all about your favorite facet of the work.