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

  1. Why Node-centered testing tools fall short
  2. @web/test-runner: running tests in real browsers
  3. Installation and minimal configuration
  4. Anatomy of a test: describe, it, fixture, expect
  5. Accessing the Shadow DOM from a test
  6. First test: <task-card> renders the correct title
  7. Second test: the status badge changes with the property
  8. Waiting for asynchronous updates inside a test
  9. Running the test suite

  1. 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.

  1. @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.

  1. 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):

npm install --save-dev @web/test-runner @open-wc/testing

A minimal configuration, in a web-test-runner.config.js file at the project root, is enough to get started:

// web-test-runner.config.js
export default {
  files: 'test/**/*.test.js',
  nodeResolve: true,
};

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.

  1. Anatomy of a test: describe, it, fixture, expect

A 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.

  1. 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.

  1. First test: <task-card> renders the correct title

With 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.

  1. 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.

  1. 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.

  1. Running the test suite

With the tests already written, a script in package.json allows running them from the command line:

{
  "scripts": {
    "test": "web-test-runner"
  }
}
npm test

@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-runner specifically for UI components.
  • Forgetting await before fixture(...): fixture returns a promise that resolves only once the component has completed its first update; without await, the test would receive the promise itself instead of the element, and any later el.shadowRoot would fail with a type error, not a clear assertion failure.
  • Querying document.querySelector instead of el.shadowRoot.querySelector: exactly the same encapsulation mistake explained in module 4; a querySelector on 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 updateComplete after simulating an interaction: as seen in section 8, any change that triggers an asynchronous Lit update (a property, an event that modifies it) needs that await before 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

  1. Write a test for <task-card> that checks that, when clicking the <article> (simulating the click with el.shadowRoot.querySelector('article').click()), an element with the .detalle class appears inside the shadow root; remember to wait for el.updateComplete after the click, since alternarExpandida changes an internal reactive state.
  2. Write a test that checks that <task-card>, when receiving prioridad="5" as an attribute, shows the text "Prioridad: 5" somewhere in its shadow root (hint: you can check this with el.shadowRoot.textContent and .to.include(...), without needing to locate an exact selector).
  3. 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');
});
  1. Checking el.estado === 'hecha' only verifies that the component's JavaScript property has changed correctly, that is, that gestionarCambioDeSelector'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 what renderInsigniaEstado() 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 if renderInsigniaEstado() 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

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved