Testing Popup Components with Testing Library

Test extension popup and options UI the way users use it with Testing Library: rendering components with fake chrome APIs, querying by role and label, user-event interactions, async storage updates, messages to the worker, and accessibility checks.

Published October 2, 2026 Updated October 2, 2026 8 min read
Table of Contents

The popup has a search box, a list of saved items with Archive buttons, a settings toggle and an error banner. Its tests check internal state and CSS class names — so a refactor that changes nothing for users breaks twenty tests, while a real bug (the Archive button lost its accessible name, keyboard users can’t reach it) passes them all. Testing Library flips that: tests find elements the way users do — by role, label and text — and interact through realistic events, so they break when behaviour breaks and survive refactors. For extension UI, the remaining work is faking the chrome APIs the components touch and handling their asynchronous updates. This guide does both, for React and framework-free popups. It belongs to unit and integration testing.

The testing model

Each test renders a component (or the whole popup) into JSDOM with a fake chrome providing storage, messaging and i18n. It then queries the DOM with role-based queries (getByRole("button", { name: "Archive" })), interacts with @testing-library/user-event (which types, clicks and tabs like a user, firing the full event sequence), and asserts on what a user would perceive: text, roles, states (aria-pressed, disabled), focus, and — through the fake — messages sent to the service worker and data written to storage. Because popups read storage asynchronously, assertions use findBy* queries or waitFor, which retry until the UI settles.

A Testing Library popup testThe test seeds a fake chrome.storage, renders the popup, finds elements by role and label, interacts with user-event, and asserts on visible text, accessibility states, storage writes and messages sent to the service worker.Seed fake storageitems, settingsrender(<Popup/>)JSDOMfindByRolewait for datainteractuser.type / click / tabuser-eventAssert UItext, roles, focusAssert effectsstorage, messages
Seed, render, act like a user, assert what a user would notice.

Step-by-step: popup component tests

1. Provide a fake chrome with real-ish behaviour

 1// tests/fake-chrome.ts
 2import { vi } from "vitest";
 3export function installFakeChrome(seed: Record<string, unknown> = {}) {
 4  const data = { ...seed };
 5  const listeners = new Set<(c: any, area: string) => void>();
 6  const chrome = {
 7    storage: {
 8      local: {
 9        get: vi.fn(async (keys?: any) => (keys == null ? { ...data } : Object.fromEntries((Array.isArray(keys) ? keys : typeof keys === "string" ? [keys] : Object.keys(keys))
10          .map((k) => [k, k in data ? data[k] : (typeof keys === "object" && !Array.isArray(keys) ? keys[k] : undefined)])))),
11        set: vi.fn(async (v: Record<string, unknown>) => {
12          const changes = Object.fromEntries(Object.entries(v).map(([k, nv]) => [k, { oldValue: data[k], newValue: nv }]));
13          Object.assign(data, v);
14          listeners.forEach((l) => l(changes, "local"));
15        }),
16      },
17      onChanged: { addListener: (l: any) => listeners.add(l), removeListener: (l: any) => listeners.delete(l) },
18    },
19    runtime: { sendMessage: vi.fn(async () => ({ ok: true })), getURL: (p: string) => `chrome-extension://test/${p}` },
20    i18n: { getMessage: (k: string, subs?: string[]) => (subs?.length ? `${k}:${subs.join(",")}` : k) },
21    tabs: { query: vi.fn(async () => [{ id: 1, url: "https://news.example/a" }]) },
22  };
23  (globalThis as any).chrome = chrome;
24  return { chrome, data };
25}

Execution context: a test helper. The fake behaves like the real API where it matters — set fires onChanged, get honours defaults — so components that subscribe to changes work in tests. getMessage returns the key, making assertions independent of English copy; if you prefer real strings, load _locales/en/messages.json into the fake. For a dependency-injected alternative see injecting chrome APIs for testability.

2. Render and wait for data

 1// tests/popup.test.tsx
 2import { render, screen } from "@testing-library/react";
 3import userEvent from "@testing-library/user-event";
 4import { Popup } from "../src/popup/Popup";
 5
 6test("lists saved items from storage", async () => {
 7  installFakeChrome({ items: [{ id: "1", title: "Bread recipe" }, { id: "2", title: "React hooks" }] });
 8  render(<Popup />);
 9  const list = await screen.findByRole("list", { name: "savedItems" });
10  expect(within(list).getAllByRole("listitem")).toHaveLength(2);
11  expect(screen.getByText("Bread recipe")).toBeInTheDocument();
12});

Execution context: Vitest with JSDOM and @testing-library/jest-dom matchers. findByRole waits for the asynchronous storage read to render the list. Querying by role and accessible name (here the i18n key) asserts that the list is labelled — an accessibility property — as a side effect of finding it.

Query priorities in Testing LibraryTesting Library queries ranked by how closely they reflect what users perceive: getByRole with name, getByLabelText, getByText, getByTestId, and container querySelector.QueryReflects usersUse forgetByRole(…, {name})BestButtons, lists, checkboxes, dialogsgetByLabelTextGoodForm fieldsgetByTextGoodNon-interactive contentgetByTestIdWeakLast resortquerySelector('.class')NoneAvoid
Prefer queries that a user or assistive technology would use.

3. Interact like a user

 1test("filters items as the user types and archives one", async () => {
 2  const { chrome } = installFakeChrome({ items: [{ id: "1", title: "Bread recipe" }, { id: "2", title: "React hooks" }] });
 3  const user = userEvent.setup();
 4  render(<Popup />);
 5  await user.type(await screen.findByRole("searchbox", { name: "searchLabel" }), "react");
 6  expect(screen.queryByText("Bread recipe")).not.toBeInTheDocument();
 7
 8  await user.click(screen.getByRole("button", { name: /archive.*React hooks/i }));
 9  expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ type: "archive", id: "2" });
10  expect(await screen.findByRole("status")).toHaveTextContent("archived");
11});

Execution context: Vitest. user.type fires keydown, input and keyup for each character, catching handlers that listen to the wrong event. Finding the Archive button by a name that includes the item title asserts each button is distinguishable for screen reader users — a common bug in lists. The message assertion verifies the popup delegates work to the worker, and the status assertion checks the live-region feedback described in toasts and inline feedback in popups.

A setting changed elsewhere updates the popupThe test renders the popup with the extension enabled; it then writes enabled false to the fake storage as if from the options page; onChanged fires; the popup re-renders and the toggle's checked state becomes false.TestFake storagePopuprender — toggle checkedset({enabled:false})onChangedre-renderexpect toggle not checked
Simulate other contexts by writing to the fake storage.

4. Test reactions to changes from other contexts

1test("reflects a setting changed in options", async () => {
2  const { chrome } = installFakeChrome({ enabled: true });
3  render(<Popup />);
4  const toggle = await screen.findByRole("switch", { name: "enableOnSites" });
5  expect(toggle).toBeChecked();
6  await act(() => chrome.storage.local.set({ enabled: false }));
7  expect(toggle).not.toBeChecked();
8});

Execution context: Vitest. Writing to the fake’s storage fires onChanged exactly as a write from the options page would. Wrapping it in act lets React flush the resulting update. This covers the subscription logic from building an options page with React.

5. Check keyboard access and focus

 1test("search is focused on open and Tab reaches the first item action", async () => {
 2  installFakeChrome({ items: [{ id: "1", title: "Bread recipe" }] });
 3  const user = userEvent.setup();
 4  render(<Popup />);
 5  const search = await screen.findByRole("searchbox");
 6  expect(search).toHaveFocus();
 7  await user.tab();
 8  await user.tab();
 9  expect(screen.getByRole("button", { name: /archive.*Bread recipe/i })).toHaveFocus();
10});

Execution context: Vitest. JSDOM implements focus and user.tab() follows DOM order and tabindex, so focus order is testable. Visible focus styles require a real browser. See managing focus in popups and dialogs.

6. Add automated accessibility checks

1import { axe } from "vitest-axe";
2test("popup has no detectable accessibility violations", async () => {
3  installFakeChrome({ items: [{ id: "1", title: "Bread recipe" }] });
4  const { container } = render(<Popup />);
5  await screen.findByRole("list");
6  expect(await axe(container)).toHaveNoViolations();
7});

Execution context: Vitest with vitest-axe (or jest-axe). axe catches missing labels, invalid ARIA and some structural issues; it cannot judge colour contrast reliably in JSDOM. It complements, not replaces, role-based queries and manual screen reader testing.

7. Test framework-free popups the same way

@testing-library/dom provides the same queries and user-event without React: load the popup’s HTML into document.body, import the script module (which attaches listeners), and query by role. The fake chrome and assertions are identical. See using web components in extension popups — open shadow roots are queryable with within(host.shadowRoot).

Common mistakes

  • Querying by class names. Tests break on refactors and miss accessibility bugs.
  • getBy* before data loads. Use findBy* for async rendering.
  • fireEvent instead of user-event. Misses real event sequences.
  • Fakes that don’t fire onChanged. Subscription logic goes untested.
  • Expecting layout or contrast checks in JSDOM. Use browser tests for those.

Cross-browser variation

  • Chrome / Edge: popup code under test is the same; the fake models chrome.* promises.
  • Firefox: if code uses browser.*, alias the fake to both chrome and browser globals.
  • Safari: same component tests; visual and behavioural checks in Safari remain manual.

Verification

  1. Remove the aria-label from an Archive button and confirm the test fails.
  2. Change a class name without behaviour change and confirm tests still pass.
  3. Break the onChanged subscription and confirm the cross-context test fails.
  4. Introduce a missing form label and confirm axe reports it.

FAQ

Should I test the popup or individual components?

Both: components for detailed behaviour, the whole popup for a few key flows.

How do I test window.close() calls?

Spy on it: vi.spyOn(window, "close").mockImplementation(() => {}) and assert it was called.

Is snapshot testing the DOM useful here?

Rarely. Behavioural assertions are more meaningful and less brittle.

Do these tests replace end-to-end tests?

No. They cover component behaviour quickly; a handful of end-to-end tests confirm the real extension wiring. See testing a popup and options page with Playwright.

Other Testing, Debugging & Performance Optimization Resources