Preact components with Signals state, tested with Testing Library on jsdom: working versions (tested 2026-10-15)
A Preact 11 cart using @preact/signals signal and computed state plus a hooks-based async form, tested in Vitest on jsdom with @testing-library/preact and user-event, including re-render on signal change without new props.
setup_failed0 passed · 1 failed2026-10-15 14:16 UTC- Status
- setup_failed
- Last passed
- 2026-10-15 14:16 UTC
- Tests
- 0 / 1
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38755782594
The code
Exactly the files that passed. Your agent can pull them with get_recipe("preact-signals-testing-library") (install), or see them on GitHub.
{
"name": "preact-signals-testing-library",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run --reporter=default --reporter=json --outputFile=test-report.json"
},
"dependencies": {
"preact": "11.0.1",
"@preact/signals": "2.11.3"
},
"devDependencies": {
"@testing-library/preact": "3.2.4",
"@testing-library/user-event": "14.6.8",
"jsdom": "30.1.3",
"vitest": "5.0.4",
"@types/node": "26.6.5"
}
}
/** @jsxImportSource preact */
import { signal, computed } from "@preact/signals";
import { useState } from "preact/hooks";
export type Item = { sku: string; name: string; priceCents: number; qty: number };
/** Store as module-level signals; createCart() gives tests a fresh instance. */
export function createCart() {
const items = signal<Item[]>([]);
const count = computed(() => items.value.reduce((n, i) => n + i.qty, 0));
const totalCents = computed(() => items.value.reduce((s, i) => s + i.qty * i.priceCents, 0));
return {
items, count, totalCents,
add(item: Omit<Item, "qty">) {
const existing = items.value.find((i) => i.sku === item.sku);
items.value = existing
? items.value.map((i) => (i.sku === item.sku ? { ...i, qty: i.qty + 1 } : i))
: [...items.value, { ...item, qty: 1 }];
},
remove(sku: string) { items.value = items.value.filter((i) => i.sku !== sku); },
};
}
export type Cart = ReturnType<typeof createCart>;
const money = (c: number) => `$${(c / 100).toFixed(2)}`;
export function CartBadge({ cart }: { cart: Cart }) {
// Reading .value inside render subscribes this component to the signal.
return <span aria-label="cart count">{cart.count.value}</span>;
}
export function CartView({ cart }: { cart: Cart }) {
if (cart.items.value.length === 0) return <p>Your cart is empty</p>;
return (
<section>
<ul>
{cart.items.value.map((i) => (
<li key={i.sku}>
{i.name} x{i.qty} <button onClick={() => cart.remove(i.sku)} aria-label={`remove ${i.name}`}>Remove</button>
</li>
))}
</ul>
<p>Total: <strong data-testid="total">{money(cart.totalCents.value)}</strong></p>
</section>
);
}
export function CouponForm({ onApply }: { onApply: (code: string) => Promise<boolean> }) {
const [code, setCode] = useState("");
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "bad">("idle");
return (
<form onSubmit={async (e) => {
e.preventDefault();
setStatus("checking");
setStatus((await onApply(code.trim().toUpperCase())) ? "ok" : "bad");
}}>
<label>Coupon <input value={code} onInput={(e) => setCode(e.currentTarget.value)} /></label>
<button type="submit" disabled={!code.trim() || status === "checking"}>Apply</button>
{status === "ok" && <p role="status">Coupon applied</p>}
{status === "bad" && <p role="alert">Invalid coupon</p>}
</form>
);
}
/** @jsxImportSource preact */
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { createCart, CartBadge, CartView, CouponForm } from "../src/cart.tsx";
afterEach(cleanup);
describe("signals store", () => {
it("merges quantities and computes totals", () => {
const cart = createCart();
cart.add({ sku: "A", name: "Hat", priceCents: 1250 });
cart.add({ sku: "A", name: "Hat", priceCents: 1250 });
cart.add({ sku: "B", name: "Gloves", priceCents: 899 });
expect(cart.count.value).toBe(3);
expect(cart.totalCents.value).toBe(3399);
});
});
describe("components", () => {
it("badge re-renders when the signal changes, with no props change", async () => {
const cart = createCart();
render(<CartBadge cart={cart} />);
expect(screen.getByLabelText("cart count").textContent).toBe("0");
cart.add({ sku: "A", name: "Hat", priceCents: 1250 });
expect(await screen.findByText("1")).toBeTruthy();
});
it("cart view lists items, totals and removes on click", async () => {
const user = userEvent.setup();
const cart = createCart();
cart.add({ sku: "A", name: "Hat", priceCents: 1250 });
cart.add({ sku: "B", name: "Gloves", priceCents: 899 });
render(<CartView cart={cart} />);
expect(screen.getByTestId("total").textContent).toBe("$21.49");
await user.click(screen.getByRole("button", { name: "remove Hat" }));
expect(screen.queryByText(/Hat/)).toBeNull();
expect(screen.getByTestId("total").textContent).toBe("$8.99");
await user.click(screen.getByRole("button", { name: "remove Gloves" }));
expect(screen.getByText("Your cart is empty")).toBeTruthy();
});
it("coupon form normalises input, disables while checking, and shows the result", async () => {
const user = userEvent.setup();
let resolve!: (ok: boolean) => void;
const onApply = vi.fn(() => new Promise<boolean>((r) => { resolve = r; }));
render(<CouponForm onApply={onApply} />);
const button = screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement;
expect(button.disabled).toBe(true);
await user.type(screen.getByLabelText("Coupon"), " save10 ");
await user.click(button);
expect(onApply).toHaveBeenCalledWith("SAVE10");
expect(button.disabled).toBe(true);
resolve(false);
expect((await screen.findByRole("alert")).textContent).toBe("Invalid coupon");
});
});
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"skipLibCheck": true
}
}
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { environment: "jsdom" },
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
SolidJS signals, memos and resources, tested with Solid Testing Library
SolidJS components using createSignal, createMemo, For, Show and createResource with Suspense, compiled by vite-plugin-solid and tested in Vitest on jsdom with @solidjs/testing-library and user-event.
TanStack Query + React Testing Library hooks and components (Vitest + jsdom)
Test React components and hooks that use TanStack Query v5 with Testing Library on jsdom: fresh QueryClient per test, retries off, mocked fetchers, mutations with invalidation.
Vanilla DOM form testing with Testing Library + user-event on happy-dom (Vitest)
Test framework-free DOM code (a validated signup form) the way users use it, with @testing-library/dom queries and user-event typing on happy-dom.
Astro 7 component rendering tests with the Container API (Vitest)
Render Astro components to HTML in Vitest with the Container API and getViteConfig, checking props, slots and conditional markup.
All preact recipesAll @preact/signals recipesAll @testing-library/preact recipesAll @testing-library/user-event recipesAll jsdom recipesAll vitest recipes