Over eight modules, TaskFlow has grown component by component —<task-card>, <task-list>, <task-board>, <user-avatar>, <task-filter>— checking each new piece by reloading the browser and eyeballing the result. That's a perfectly reasonable way to learn each concept as you go, but it doesn't scale: nobody wants to manually click the status selector on half a dozen cards every time a single line of <task-card> code changes, just to confirm nothing broke. This lesson introduces @web/test-runner, the tool the Lit team itself recommends for writing automated tests for Web Components, and uses it to write the first real tests for <task-card>.
Contents
- Why Node-centered testing tools fall short
@web/test-runner: running tests in real browsers- Installation and minimal configuration
- Anatomy of a test:
describe,it,fixture,expect - Accessing the Shadow DOM from a test
- First test:
<task-card>renders the correct title - Second test: the status badge changes with the property
- Waiting for asynchronous updates inside a test
- Running the test suite
- Why Node-centered testing tools fall short
The most common way to run JavaScript unit tests, with tools like Jest, is to run the code directly on Node.js, without opening any real browser. To test code that manipulates the DOM, these tools usually rely on jsdom, an implementation of the DOM APIs written in pure JavaScript, capable of simulating an HTML document without needing an actual browser.
That simulation works reasonably well for conventional HTML and JavaScript, but it falls short precisely on the two pillars this whole course rests on: Shadow DOM and Custom Elements. jsdom implements both APIs only partially and, in some respects (the exact behavior of <slot> and content distribution, the full lifecycle of a custom element as it connects and disconnects from the document, or fine details of how the browser applies encapsulated styles inside a shadow root), its behavior diverges from that of a real browser in ways subtle enough to produce false positives or false negatives in a test: code that passes the test in jsdom but fails in a real browser, or the other way around.
| Aspect | jsdom (simulated in Node) | Real browser |
|---|---|---|
Custom Elements (customElements.define) |
Partial support, with behavior differences in specific cases | Full native implementation |
Shadow DOM and <slot> |
Partial support, especially for content distribution and styles | Full native implementation |
| Startup speed | Very fast, without opening any browser process | Somewhat slower, since it depends on a real browser |
| Reliability for Web Components | Risk of false positives/negatives for platform-specific behavior | Maximum: it's the same environment where the component actually runs |
For this reason, Lit's official documentation does not recommend Jest with jsdom as the first choice for testing components, and instead points directly to @web/test-runner, a tool from the same Open Web Components ecosystem that runs tests inside real browsers (Chromium, Firefox, or WebKit, depending on configuration), eliminating from the root any divergence between what the test checks and what a real user would experience.
@web/test-runner: running tests in real browsers
@web/test-runner: running tests in real browsers@web/test-runner works, broadly speaking, like this: it takes test files written in JavaScript (regular ES modules, requiring no prior transformation), serves them through a small development server, and runs them inside a real browser instance, controlled under the hood by Playwright. The result of each assertion is collected back and shown in the terminal, exactly as with any other testing framework, but with the added guarantee that each test ran against a full native implementation of Custom Elements and Shadow DOM.
This way of working has an important practical consequence for TaskFlow: the tests written in this module need no simulation or patch to make <task-card> "work as if it were in a browser"; they are genuinely running inside one, with the same customElements.define, the same attachShadow, and the same rendering engine already used throughout the course when opening index.html with Vite.
- Installation and minimal configuration
To start using @web/test-runner in the TaskFlow project, it needs to be installed together with @open-wc/testing, a companion package that provides utilities designed specifically for Web Components (fixture, html, and an extended version of expect, detailed in the next section):
A minimal configuration, in a web-test-runner.config.js file at the project root, is enough to get started:
files indicates the file pattern where tests live (by convention, inside a test/ directory, with the .test.js suffix); nodeResolve: true lets the test files themselves import packages installed in node_modules (like lit or @open-wc/testing) with the usual import syntax, resolving those modules the same way Vite already does during TaskFlow's normal development.
- Anatomy of a test:
describe, it, fixture, expect
describe, it, fixture, expectA typical @web/test-runner test combines, on one hand, describe and it, the standard pair of functions from the Mocha/BDD format that organizes tests into groups and individual cases (a convention shared by virtually every JavaScript testing framework, not exclusive to this tool), and, on the other, two utilities from @open-wc/testing designed specifically for Web Components: fixture and the html template tag.
import { fixture, html, expect } from '@open-wc/testing';
import '../src/components/task-card.js';
describe('task-card', () => {
it('se registra como elemento personalizado', async () => {
const el = await fixture(html`<task-card></task-card>`);
expect(el).to.exist;
});
});fixture(html\creates a real instance of, inserts it into the test document, and **waits for Lit to complete its first update** before returning the element ready for inspection; it is, in essence, the test equivalent of writing inindex.htmland waiting for the page to finish rendering. Thehtmltag from@open-wc/testinghas no direct relation to the Lithtmltag used inrender()` throughout the course: it's a general-purpose template for describing HTML in a test, even though it shares the same syntactic look (backticks with interpolations) for convenience and familiarity.
expect, also imported from @open-wc/testing, provides a chained-assertion style (expect(value).to.equal(...), expect(value).to.exist, expect(value).to.be.true) inherited from the Chai library, widely used in the JavaScript ecosystem and chosen by Open Web Components itself as the standard for its testing utilities.
- Accessing the Shadow DOM from a test
The element returned by fixture is the real component instance, with its Shadow DOM already built; to inspect what <task-card> has actually rendered inside its <article>, that boundary needs to be crossed exactly as explained in module 4 regarding style encapsulation: through el.shadowRoot.
const el = await fixture(html`<task-card titulo="Revisar el PR"></task-card>`);
const titulo = el.shadowRoot.querySelector('h3');
expect(titulo.textContent).to.equal('Revisar el PR');el.shadowRoot.querySelector('h3') looks, inside the component's shadow root (not in the main document, where a regular querySelector would find nothing, for exactly the same encapsulation reason explained in the "Encapsulated CSS with Shadow DOM" lesson), for the first <h3> element, which is exactly where render() interpolates this.titulo. This pattern —el.shadowRoot.querySelector(...), followed by an assertion on textContent, on some CSS class, or on the presence or absence of a node— is the basis for practically all the tests written in this module for TaskFlow's components.
- First test:
<task-card> renders the correct title
<task-card> renders the correct titleWith the pieces already explained, the first real test for <task-card> looks like this:
// test/task-card.test.js
import { fixture, html, expect } from '@open-wc/testing';
import '../src/components/task-card.js';
describe('task-card', () => {
it('renderiza el título recibido como propiedad', async () => {
const el = await fixture(
html`<task-card titulo="Preparar la demo del sprint"></task-card>`
);
const h3 = el.shadowRoot.querySelector('h3');
expect(h3).to.exist;
expect(h3.textContent).to.equal('Preparar la demo del sprint');
});
it('usa el título por defecto si no se le pasa ninguno', async () => {
const el = await fixture(html`<task-card></task-card>`);
const h3 = el.shadowRoot.querySelector('h3');
expect(h3.textContent).to.equal('Tarea sin título');
});
});The second case checks, in passing, something already established back in module 3: the default value assigned in TaskCard's constructor (this.titulo = 'Tarea sin título') when no titulo attribute is passed. Writing both cases as independent tests, rather than a single one, is deliberate: each it describes a single expected behavior, and if either one stops holding true in the future, the name of the failing test ("usa el título por defecto si no se le pasa ninguno") immediately points to exactly which behavior broke, without needing to read the test body to figure it out.
- Second test: the status badge changes with the property
renderInsigniaEstado(), the <task-card> method introduced in the "Conditional Rendering" lesson, decides which badge to show based on the value of this.estado. It's a perfect candidate for a parameterized test, which checks several input values without repeating the test structure:
// test/task-card.test.js (continuation)
describe('task-card: insignia de estado', () => {
const casos = [
{ estado: 'pendiente', textoEsperado: 'Pendiente' },
{ estado: 'en-progreso', textoEsperado: 'En progreso' },
{ estado: 'hecha', textoEsperado: 'Hecha' },
];
casos.forEach(({ estado, textoEsperado }) => {
it(`muestra "${textoEsperado}" cuando estado es "${estado}"`, async () => {
const el = await fixture(html`<task-card estado="${estado}"></task-card>`);
const insignia = el.shadowRoot.querySelector('.insignia');
expect(insignia.textContent).to.include(textoEsperado);
});
});
});The casos array collects the three valid estado combinations together with the text fragment expected inside the badge; forEach generates an independent it for each one, so a failure in a single case (for example, if someone changes the "En progreso" text to "En curso" without updating the test) points to exactly which of the three states stopped behaving as expected, instead of a single generic test that would only say "something in the badge failed." expect(...).to.include(...), rather than to.equal(...), is used here because renderInsigniaEstado() prepends an icon (✓, ◐, ○) to the text, and the test only needs to check that the relevant text is present, not the exact character accompanying it.
- Waiting for asynchronous updates inside a test
So far, every test has checked <task-card>'s state right after fixture(...), which already waits for the first update. But some behaviors, like the status <select> explained in the "Custom Events" lesson, change the component's state after it's already rendered, in response to a simulated interaction:
it('actualiza la insignia al cambiar el selector de estado', async () => {
const el = await fixture(html`<task-card estado="pendiente"></task-card>`);
const selector = el.shadowRoot.querySelector('select');
selector.value = 'hecha';
selector.dispatchEvent(new Event('change'));
await el.updateComplete;
const insignia = el.shadowRoot.querySelector('.insignia');
expect(insignia.textContent).to.include('Hecha');
});Here el.updateComplete shows up, the same promise introduced in the "Reactive Hooks" lesson from module 6: after firing the change event on the <select> (which triggers, in cascade, gestionarCambioDeSelector assigning this.estado = 'hecha'), the test needs to explicitly wait for Lit to finish processing that update before inspecting the Shadow DOM again. Without that await el.updateComplete, the assertion would run too early —potentially before render() has run again— and the test could fail intermittently, depending on a timing difference of a few milliseconds.
- Running the test suite
With the tests already written, a script in package.json allows running them from the command line:
@web/test-runner then launches a browser (Chromium by default, if none specific has been configured), loads every test file matching the test/**/*.test.js pattern, and shows in the terminal a summary of how many it cases passed and how many failed, with the corresponding assertion message for each failure. Adding the --watch flag (web-test-runner --watch), the tool automatically reruns the tests every time a code change is saved, a convenient workflow while continuing to develop <task-card> or any other TaskFlow component alongside its tests.
Common Mistakes and Tips
- Testing Web Components with Jest and jsdom without being aware of their limits: as explained in section 1, jsdom can hide real Shadow DOM or Custom Elements problems that would only show up in a real browser; if a team already uses Jest for the rest of its JavaScript code, it's still reasonable to keep
@web/test-runnerspecifically for UI components. - Forgetting
awaitbeforefixture(...):fixturereturns a promise that resolves only once the component has completed its first update; withoutawait, the test would receive the promise itself instead of the element, and any laterel.shadowRootwould fail with a type error, not a clear assertion failure. - Querying
document.querySelectorinstead ofel.shadowRoot.querySelector: exactly the same encapsulation mistake explained in module 4; aquerySelectoron the main document never finds elements living inside a component's shadow root, and the test would fail with an "element not found" error that can, at first glance, be mistaken for a real failure in the component itself. - Not waiting for
updateCompleteafter simulating an interaction: as seen in section 8, any change that triggers an asynchronous Lit update (a property, an event that modifies it) needs thatawaitbefore inspecting the result; skipping it produces intermittent tests, which sometimes pass and sometimes fail depending on the exact execution timing, one of the hardest kinds of error to diagnose in any test suite.
Exercises
- Write a test for
<task-card>that checks that, when clicking the<article>(simulating the click withel.shadowRoot.querySelector('article').click()), an element with the.detalleclass appears inside the shadow root; remember to wait forel.updateCompleteafter the click, sincealternarExpandidachanges an internal reactive state. - Write a test that checks that
<task-card>, when receivingprioridad="5"as an attribute, shows the text "Prioridad: 5" somewhere in its shadow root (hint: you can check this withel.shadowRoot.textContentand.to.include(...), without needing to locate an exact selector). - A teammate proposes writing a test that checks
el.estado === 'hecha'directly after simulating the<select>change, instead of inspecting the badge's content as in section 8. Explain what difference there is between the two approaches in terms of which part of the component's behavior is actually verified.
Solutions
it('muestra el detalle expandido al hacer clic en la tarjeta', async () => {
const el = await fixture(html`<task-card titulo="Tarea de prueba"></task-card>`);
el.shadowRoot.querySelector('article').click();
await el.updateComplete;
const detalle = el.shadowRoot.querySelector('.detalle');
expect(detalle).to.exist;
});it('muestra la prioridad recibida como atributo', async () => {
const el = await fixture(html`<task-card prioridad="5"></task-card>`);
expect(el.shadowRoot.textContent).to.include('Prioridad: 5');
});- Checking
el.estado === 'hecha'only verifies that the component's JavaScript property has changed correctly, that is, thatgestionarCambioDeSelector's internal logic works; it does not verify, however, that this change has translated into something visible to whoever uses the card, which is, ultimately, what a real user cares about and whatrenderInsigniaEstado()should guarantee. Inspecting the badge's content, as in section 8, checks end-to-end behavior —from the simulated interaction to the visual result in the Shadow DOM— and is preferable in most cases, because a test that only looked at the internal property could keep passing even ifrenderInsigniaEstado()had a bug and always showed the same text, something a DOM-focused test would catch immediately.
Conclusion
This lesson introduced @web/test-runner as the recommended tool for testing Web Components, precisely because it runs each test inside a real browser instead of a partial simulation like jsdom, thereby avoiding false positives and negatives in Shadow DOM and Custom Elements behavior. With fixture, html, and expect from @open-wc/testing, and with the pattern of accessing the Shadow DOM via el.shadowRoot.querySelector(...), <task-card> now has a first test suite that checks its title, its status badge, and its behavior after a simulated interaction, always waiting for updateComplete when needed.
The tests written in this lesson check that <task-card> does what it should, but they say nothing about whether it does so accessibly: whether someone navigating with only a keyboard, or with a screen reader, can expand a card or change its status as easily as someone using a mouse. The next lesson, "Accessibility in Web Components," revisits <task-card> and <task-filter> from that angle, with ARIA roles, focus management, and dynamically announced updates.
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
