Testing custom events with Playwright
I’m about to dive into building all of the Web Components for Kelp, my UI library for people who love HTML.
Today, I wanted to share how to test custom events in Playwright, because it’s not quite as straightforward as you’d expect.
Let’s dig in!
Embracing Playwright & TDD
As part of this project, I really want to embrace test-driven development (TDD) now that I’ve had my lightbulb moment about how it works…
Only test what a component does, not how it works.
While I love buildless testing for its simplicity, I’ve decided to use Playwright for Kelp for a few reasons…
- Automation. When I create or receive PRs for features, I don’t want to have to manually test every component to make sure there’s no unintended interactions or side-effects. Being able to run one line of code and test everything is super useful.
- Continuous Integration. I can tie it into a Continuous Integration (CI) process, and have tests automatically run when code is pushed or a PR is opened.
- Real Browsers. Playwright doesn’t emulate browsers. It runs tests in actual browser instances. That means I don’t have to mess around with mocking up browser-native functions for a JS environment. A
fetch()call uses the realfetch()method. - Built for UI. While Playwright is often thought of as an end-to-end testing tool, it’s great for any UI-based testing. It runs code in a browser and runs tests against it. That’s perfect for the kind of testing I want to do.
Testing custom events
Kelp’s web components emit various custom events custom events developers can hook into to extend their behavior, and I want to make sure those work as expected.
document.addEventListener('kelp-accordion-ready', () => {
console.log('The accordion component is ready!');
});
Playwright includes a few helpful methods for listening to events…
Page.on()for listening to eventsPage.once()for listening to an event just oncePage.waitForEvent()for waiting for an event before continuing
As I learned the hard way, though, the events it listens for are a subset of predefined events. These are not general-purpose methods for listening to any event you want.
After a lot of web searches and trial and error, I figured out how to test this.
const component = page.getByTestId('accordion-1');
const readyEvent = await component.evaluate((element) => {
return new Promise((resolve) => {
return element.addEventListener('kelp-accordion-ready', () => {
return resolve(true);
});
})
});
await expect(readyEvent).toBeTruthy();
Let’s break down what’s happening here…
- I’m getting the web component to listen to events on.
- I’m using the
locator.evaluate()function to run some JS and create anew Promise(). - That promise creates an event listener, and resolves as
truewhen the event fires. - I can then
expect()the promise, and make sure it’s truthy.
I’m surprised this isn’t a baked in feature, TBH!
Test utilities
I don’t want to have to write this out every time I test a component, so I created a test-utilities.js file to make testing a bit easier.
In it, I created an asynchronous waitForCustomEvent() function that I export. It accepts the component to test and the eventID to listen for, and returns the resolved promise back out.
/**
* Wait for a custom event to run
* @param {Locator} component The component to listen for the event on
* @param {String} eventName The event name to listen for
* @return {Promise} Resolves to true if event emits
*/
export async function waitForCustomEvent(component, eventID) {
return await component.evaluate((element, eventID) => {
return new Promise((resolve) => {
return element.addEventListener(eventID, () => {
return resolve(true);
});
});
}, eventID);
}
In my test spec, I can do this…
const readyEvent = await waitForCustomEvent(page.getByTestId('accordion-1'), 'kelp-accordion-ready');
expect(readyEvent).toBeTruthy();
This is useful because I can also test other custom events with the same method/approach.
I also created another method to automate testing that the entire set of “this web component is ready” behaviors works…
/**
* Validate component setup was completed
* @param {Locator} component The component to listen for the event on
*/
export async function expectComponentReadyState(component, componentID) {
const readyEvent = await waitForCustomEvent(component, `kelp-${componentID}-ready`);
await expect(readyEvent).toBeTruthy();
await expect(component).toHaveAttribute('is-ready');
}
This listens for the event using the waitForCustomEvent() method, and also checks that an [is-ready] attribute has been added to component.
Testing doesn’t have to be hard
Testing often feels really hard, and I think that’s related to…
- Trying to fit tests into fuzzy and overlapping categories like “unit,” “integration,” and “end-to-end.”
- Testing implementation details.
- Over-fixating on code coverage.
Testing gets a lot easier when you instead focus on writing tests that fail for good reasons and only test the stuff that someone using the code interacts with.