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.
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.
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.
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.
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. UsefindBy*for async rendering.fireEventinstead ofuser-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 bothchromeandbrowserglobals. - Safari: same component tests; visual and behavioural checks in Safari remain manual.
Verification
- Remove the
aria-labelfrom an Archive button and confirm the test fails. - Change a class name without behaviour change and confirm tests still pass.
- Break the
onChangedsubscription and confirm the cross-context test fails. - 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.
Related
- Testing a popup and options page with Playwright — real-browser tests.
- Using Vitest with WebExtension mocks — test runner setup.
- Accessible form controls for extension settings — what the role queries assume.
- Unit and integration testing — the parent topic.