Skip to main content

Scott Nath Frontend Architect

Scott Nath's picture
GitHubdev.toLinkedInMastodon
Article header image shows the Guidepup, webcomponents, and Storybook logos between greater-than and less-than characters with text below them that reads "Storybook Setup for Virtual Screen Reader + Web Components"

Storybook setup: Virtual Screen Reader in Web Components

How to set up Storybook to test shadowroot elements in web components using shadow-dom-testing-library and Guidepup's Virtual Screen Reader.


Simulated user testing is difficult with web components due to the unique nature of the shadowroot - but it can be done in Storybook! This article details the basic setup with examples to get you writing tests for customElements that simulate user behavior.

tl;dr

Two modules, @guidepup/virtual-screen-reader and shadow-dom-testing-library give your Storybook the power to test web components inside Storybook’s Interaction Tests. This means you can test the user-interactions and screen reader output of your web components. But beware … testing-library/user-test can’t simulate TAB navigation inside shadowroots yet.

Prerequisites

What is this article about?

  1. Problems testing web components
  2. How to test web components in Storybook Interaction Tests
  3. How to test web components using virtual-screen-reader

1. Problems testing web components

It’s the shadowroot. It’s always the shadowroot.

Lots of libraries haven’t been updated to integrate shadow DOM. This is not to knock anybody - I think after years of waiting on browsers-makers to catch up, we were all genuinely surprised when extensive customElements support actually got integrated into major browsers 🤷.

The below problems are found within testing-library, but are also inherited by @storybook/test. This means the bugs affect APIs like userEvent and screen regardless of the source module.

Problem A: screen doesn’t look into the shadowroot

Whether from testing-library/dom or @storybook/test, the screen and all it’s subsequent queries won’t find the elements in your shadow DOM. Real world - that translates to screen.queryAllByRole('button') not seeing the BUTTON inside your fancy <fancy-button> web component.

And it definitely comes up empty on recursive shadowroots (e.g. web components slotted into other web components)

Fix: npm i shadow-dom-testing-library

To get around the screen and queries problems and allow our Storybook interaction tests to find the shadow DOM’s secrets, we’ll use KonnorRogers/shadow-dom-testing-library.

Your new friend, shadow-dom-testing-library, includes various methods in its api which mimic the queries made by testing-library - but now with shadowroot! The win here is that shadow-dom-testing-library gives your tests the power to query elements within the shadow DOM. To do this, screen.findByLabelText is cloned and changed to become screen.findByShadowLabelText, which has the smarts to see into the shadowroot.

More examples below, but see the shadow-dom-testing-library github repo for detailed usage of that NPM module. I’m not covering it all here.

Problem B: userEvent can’t traverse the shadowroot

This doesn’t mean userEvent doesn’t work on elements in the shadowroot. If you can get the element and give it to userEvent, hot dog! it can do all the clicks and typin you need. But not TAB. Traversing via TAB is no.

We’ll be using virtual-screen-reader’s .next() method to navigate through the shadow DOM.

AFAIK, internally TAB uses global window inside Storybook…which can’t naturally see into the shadowroot. I’m still investigating this, but I found a lot of unresolved issues doing searches for “userEvent” + “shadowroot” - lemme know if I’ve missed something!

Fix: npm i @guidepup/virtual-screen-reader

One cool aspect of virtual-screen-reader is that it navigates through your elements. We can use this to programmatically traverse the inner shadow DOM.

Returning folks be aware - this article has some repeat-examples from Simple setup: Virtual Screen Reader in Storybook … with different usage for web components natch.


2. How to test web components in Storybook Interaction Tests

See the MyElement.stories.js file in the storybook-web-components-testing repo for the full version of these examples.

The following examples use Storybook’s Component Story Format.

Step 2a. install shadow-dom-testing-library

npm i shadow-dom-testing-library -D

Step 2b. use within from shadow-dom-testing-library (I alias it to shadowWithin)

// CSF file: MyElement.stories.js
import { within as shadowWithin } from 'shadow-dom-testing-library';
import { expect } from '@storybook/test';

export default {
  title: 'MyElement',
  component: 'my-element',
  render: (args) => `<my-element count=${args.count}></my-element>`;
};

Step 2c. use shadow-dom-testing-library screen queries, then test

// ...CSF file contents...

export const SomeExample = {
  args: {
    count: 0
  },
  play: async ({ args, canvasElement, step }) => {
    // get a screen from the canvasElement generated by Storybook
    const screen = shadowWithin(canvasElement);
    // get the button from within the shadowroot with `queryBy`
    const myButton = await screen.queryByShadowRole('button')
    // test the button exists
    expect(myButton).toBeTruthy();
  }
}

3. How to test web components with virtual-screen-reader

You’ll be capturing your element with queryByShadowMEOW('the-meow') (MEOW of course being whatever query you’re using from the testing-library core API queries). After you have your element, using virtual-screen-reader is the same as the usage broken down in Simple setup: Virtual Screen Reader in Storybook.

Step 3a. install virtual-screen-reader (VSR)

npm i @guidepup/virtual-screen-reader -D

Step 3b. import it into your stories

// CSF file: MyElement.stories.js, now with VSR!
import { within as shadowWithin } from 'shadow-dom-testing-library';
import { virtual } from '@guidepup/virtual-screen-reader';
import { expect } from '@storybook/test';

export default {
  title: 'MyElement',
  component: 'my-element',
  render: (args) => `<my-element count=${args.count}></my-element>`;
};

Step 3c: once you have your main Shadow container, give it to VSR

If the HTML in your shadowroot is…

<!-- note: `section` by default has an aria-role of `region` -->
<section aria-label="My Element"><button>my button</button></section>

…then your story’s play method will be like:

// ...CSF file contents...

export const SomeExample = {
  args: {
    count: 0
  },
  play: async ({ args, canvasElement, step }) => {
    // make a `screen` from the canvasElement by finding it's shadowroot
    const screen = shadowWithin(canvasElement);
    // get the outer-most SECTION element in the shadowroot with `aria-label="My Element"`
    const myShadowContainer = await screen.findByShadowLabelText(/My Element/i)
    // Start up the Virtual Screen Reader, giving it the SECTION container
    await virtual.start({ container: myShadowContainer });
    
    // Breakdown of elements-found, in order:
    // 1. `virtual` starts on the container, a `SECTION` has the role `region`, 
    await expect(await virtual.lastSpokenPhrase()).toEqual('region, My Element');
    
    // 2. Go to next speak-able text
    await virtual.next();
    
    // 3. we're now on `BUTTON`
    await expect(await virtual.lastSpokenPhrase()).toEqual('button, my button');
    
    // 4. Go to next speak-able text
    await virtual.next();
    
    // 5. We're done and VSR says so by closing the `region`:
    await expect(await virtual.lastSpokenPhrase()).toEqual('end of region, My Element');
  }
}

Conclusion

I hope this gets you unstuck. Web components are definitely the new hotness, the DevX tooling just needs a little help still.

Bonus content for reading this far - check out this hacky workaround that uses VSR to find all tabbable nodes inside a shadowroot!