Testing SolidJS Components: From Unit to E2E Tests
SolidJS is a reactive UI library that delivers exceptional performance through fine-grained reactivity. But like any modern frontend framework, building production-grade applications requires a robust testing strategy. In this tutorial, we'll explore how to test SolidJS components at every level — from isolated unit tests to full end-to-end (E2E) scenarios — so you can ship features with confidence.
Why Testing SolidJS Matters
SolidJS's reactive primitives — signals, stores, effects, and memos — make it easy to build fast, declarative UIs. However, reactivity can introduce subtle bugs: a signal that doesn't update when expected, an effect that runs too often, or a store mutation that doesn't trigger a re-render. A layered testing strategy helps you catch these issues early, refactor safely, and document expected behavior.
Testing also pays dividends as your application grows. Components that started simple often accumulate conditional logic, async data fetching, and user interactions. Without tests, every change becomes a gamble. With tests, you can verify behavior in seconds.
The Testing Pyramid for SolidJS
A healthy testing strategy follows a pyramid shape:
- Unit tests — Test individual functions, signals, and reactive logic in isolation.
- Component tests — Render components and assert on their output and interactions.
- Integration tests — Verify multiple components working together with shared state.
- E2E tests — Simulate real user journeys through a browser against a running app.
Most of your tests should be unit and component tests, with a smaller set of E2E tests covering critical user flows.
Setting Up the Testing Environment
SolidJS pairs naturally with Vitest for unit and component testing. For rendering components in a JSDOM environment, the official @solidjs/testing-library package provides utilities modeled after React Testing Library, adapted for Solid's reactivity.
First, install the dependencies:
npm install -D vitest @solidjs/testing-library jsdom @testing-library/jest-dom
Then configure Vitest in vite.config.ts:
import { defineConfig } from "vitest/config";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [solid()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./test/setup.ts"],
deps: {
optimizer: { web: { include: ["solid-js"] } },
},
},
});
Create a setup file at test/setup.ts to register custom matchers:
import "@testing-library/jest-dom";
Add a script to package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run"
}
}
Unit Testing Reactive Logic
Before testing components, start by testing the reactive logic itself. Solid's primitives are just functions, which makes them straightforward to unit test. The key is wrapping reactive code in createRoot so that effects and memos have a proper disposal context.
Consider a simple counter hook built on signals:
// src/lib/counter.ts
import { createSignal } from "solid-js";
export function createCounter(initial = 0) {
const [count, setCount] = createSignal(initial);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
Here's how to test it:
// src/lib/counter.test.ts
import { createRoot } from "solid-js";
import { describe, it, expect } from "vitest";
import { createCounter } from "./counter";
describe("createCounter", () => {
it("starts with the initial value", () => {
createRoot(() => {
const { count } = createCounter(5);
expect(count()).toBe(5);
});
});
it("increments the count", () => {
createRoot(() => {
const { count, increment } = createCounter();
increment();
increment();
expect(count()).toBe(2);
});
});
it("resets to the initial value", () => {
createRoot(() => {
const { count, increment, reset } = createCounter(10);
increment();
reset();
expect(count()).toBe(10);
});
});
});
Using createRoot ensures that any reactive computations created inside the test are properly tracked and disposed. Without it, Solid may warn about missing reactive roots, and effects could leak between tests.
Testing Effects and Memos
Effects (createEffect) run asynchronously after render, which means assertions about their side effects need to wait for Solid's scheduler to flush. Vitest's waitFor helper is perfect for this.
Here's a memo that computes a derived value:
// src/lib/derived.ts
import { createSignal, createMemo } from "solid-js";
export function createDoubler() {
const [value, setValue] = createSignal(1);
const doubled = createMemo(() => value() * 2);
return { value, setValue, doubled };
}
And the test:
// src/lib/derived.test.ts
import { createRoot } from "solid-js";
import { describe, it, expect } from "vitest";
import { createDoubler } from "./derived";
describe("createDoubler", () => {
it("updates the memo when the signal changes", () => {
createRoot(() => {
const { value, setValue, doubled } = createDoubler();
expect(doubled()).toBe(2);
setValue(5);
expect(doubled()).toBe(10);
});
});
});
For effects that perform side effects, use waitFor:
import { createRoot, createSignal, createEffect } from "solid-js";
import { describe, it, expect, vi } from "vitest";
import { waitFor } from "@solidjs/testing-library";
describe("effect", () => {
it("calls the callback when the signal changes", async () => {
const spy = vi.fn();
await createRoot(async (dispose) => {
const [count, setCount] = createSignal(0);
createEffect(() => spy(count()));
setCount(1);
await waitFor(() => expect(spy).toHaveBeenCalledWith(1));
dispose();
});
});
});
Component Testing with Solid Testing Library
Component tests render a component into a JSDOM environment and let you interact with it the way a user would — by finding elements, clicking buttons, and reading text. The @solidjs/testing-library package exposes render, fireEvent, and screen utilities.
Let's build a simple Counter component:
// src/components/Counter.tsx
import { createSignal } from "solid-js";
export function Counter() {
const [count, setCount] = createSignal(0);
return (
<div>
<p data-testid="count">Count: {count()}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
<button onClick={() => setCount((c) => c - 1)}>Decrement</button>
</div>
);
}
Now the test:
// src/components/Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, fireEvent, screen } from "@solidjs/testing-library";
import { Counter } from "./Counter";
describe("Counter", () => {
it("renders the initial count", () => {
render(() => <Counter />);
expect(screen.getByTestId("count")).toHaveTextContent("Count: 0");
});
it("increments when the button is clicked", async () => {
render(() => <Counter />);
const button = screen.getByText("Increment");
fireEvent.click(button);
expect(screen.getByTestId("count")).toHaveTextContent("Count: 1");
});
it("decrements when the button is clicked", async () => {
render(() => <Counter />);
fireEvent.click(screen.getByText("Decrement"));
expect(screen.getByTestId("count")).toHaveTextContent("Count: -1");
});
});
Notice that render accepts a function returning JSX. This is required because Solid needs a reactive root to track the component's computations. Passing raw JSX directly can lead to subtle reactivity bugs.
Testing Async Behavior
Real components often fetch data asynchronously. Solid Testing Library provides waitFor to wait for the DOM to update after an async operation. Let's test a component that fetches a user from an API:
// src/components/UserProfile.tsx
import { createResource, Show } from "solid-js";
async function fetchUser(id: number) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
export function UserProfile(props: { id: number }) {
const [user] = createResource(() => props.id, fetchUser);
return (
<div>
<Show when={user()} fallback={<p>Loading...</p>}>
{(data) => <p data-testid="name">{data().name}</p>}
</Show>
</div>
);
}
To test this, mock fetch and use waitFor:
// src/components/UserProfile.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@solidjs/testing-library";
import { UserProfile } from "./UserProfile";
describe("UserProfile", () => {
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: "Ada Lovelace" }),
})
)
);
});
it("shows the user name after loading", async () => {
render(() => <UserProfile id={1} />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await waitFor(() =>
expect(screen.getByTestId("name")).toHaveTextContent("Ada Lovelace")
);
});
});
Testing Components with Context
Solid's createContext and useContext are commonly used for dependency injection. To test a component that consumes context, wrap it in the provider during rendering.
// src/components/ThemeButton.tsx
import { useContext } from "solid-js";
import { ThemeContext } from "./ThemeContext";
export function ThemeButton() {
const theme = useContext(ThemeContext);
return (
<button data-testid="theme-btn">
Current theme: {theme().mode}
</button>
);
}
The test wraps the component in the provider:
// src/components/ThemeButton.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { ThemeContext } from "./ThemeContext";
import { ThemeButton } from "./ThemeButton";
describe("ThemeButton", () => {
it("displays the current theme", () => {
const theme = createSignal({ mode: "dark" });
render(() => (
<ThemeContext.Provider value={theme}>
<ThemeButton />
</ThemeContext.Provider>
));
expect(screen.getByTestId("theme-btn")).toHaveTextContent(
"Current theme: dark"
);
});
});
Testing Stores
Solid stores use proxies to enable deep reactivity. Testing them follows the same pattern as signals, but you can mutate nested properties directly. Here's a todo store:
// src/lib/todos.ts
import { createStore } from "solid-js/store";
export function createTodoStore() {
const [todos, setTodos] = createStore<{ text: string; done: boolean }[]>([]);
const add = (text: string) => setTodos((t) => [...t, { text, done: false }]);
const toggle = (index: number) =>
setTodos(index, "done", (d) => !d);
return { todos, add, toggle };
}
The test verifies both adding and toggling:
// src/lib/todos.test.ts
import { createRoot } from "solid-js";
import { describe, it, expect } from "vitest";
import { createTodoStore } from "./todos";
describe("createTodoStore", () => {
it("adds a todo", () => {
createRoot(() => {
const { todos, add } = createTodoStore();
add("Write tests");
expect(todos).toHaveLength(1);
expect(todos[0].text).toBe("Write tests");
expect(todos[0].done).toBe(false);
});
});
it("toggles a todo", () => {
createRoot(() => {
const { todos, add, toggle } = createTodoStore();
add("Write tests");
toggle(0);
expect(todos[0].done).toBe(true);
toggle(0);
expect(todos[0].done).toBe(false);
});
});
});
End-to-End Testing with Playwright
While unit and component tests verify isolated behavior, E2E tests run your entire application in a real browser. Playwright is an excellent choice for SolidJS apps because it supports all major browsers and has a clean API.
Install Playwright:
npm install -D @playwright/test
npx playwright install
Create a playwright.config.ts file:
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
Now write an E2E test that exercises the counter component as a user would:
// e2e/counter.spec.ts
import { test, expect } from "@playwright/test";
test("counter increments and decrements", async ({ page }) => {
await page.goto("/");
const count = page.getByTestId("count");
await expect(count).toHaveText("Count: 0");
await page.getByRole("button", { name: "Increment" }).click();
await page.getByRole("button", { name: "Increment" }).click();
await expect(count).toHaveText("Count: 2");
await page.getByRole("button", { name: "Decrement" }).click();
await expect(count).toHaveText("Count: 1");
});
Run the tests with:
npx playwright test
Playwright will start your dev server, launch browsers, and execute the test suite. The webServer configuration means you don't need to manually start the app before running tests.
Best Practices
- Test behavior, not implementation. Query elements by role, label, or visible text rather than internal implementation details. This keeps tests resilient to refactors.
- Wrap reactive code in createRoot. Unit tests that create signals, memos, or effects should run inside
createRootto ensure proper disposal and avoid leaks between tests. - Use waitFor for async updates. Solid's effects run on a microtask scheduler. When asserting on DOM updates triggered by effects or resources, always use
waitFor. - Mock at the boundaries. Mock network requests, browser APIs, and external services — not internal modules. This keeps tests focused on your code's behavior.
- Keep E2E tests focused on critical paths. E2E tests are slower and more brittle. Reserve them for signup, checkout, and other high-stakes flows. Cover edge cases with component tests.
- Co-locate tests with source files. Keeping
Counter.tsxandCounter.test.tsxnext to each other makes it easier to find and maintain tests. - Avoid testing reactivity internals. Don't assert on how many times an effect runs. Instead, assert on the observable outcome — the rendered output or the side effect.
- Use data-testid sparingly. Prefer semantic queries like
getByRoleorgetByLabelText. Reservedata-testidfor elements that have no accessible role.
Conclusion
Testing SolidJS components doesn't require exotic tooling — the same Vitest and Testing Library patterns you know from other frameworks apply, with a few adjustments for Solid's fine-grained reactivity. By wrapping reactive code in createRoot, using waitFor for async updates, and rendering components through @solidjs/testing-library, you can build a fast, reliable unit and component test suite. Layer in Playwright for E2E coverage of critical user journeys, and you'll have a testing strategy that scales with your application. The result is a codebase where you can refactor aggressively, ship features quickly, and trust that your tests will catch regressions before your users do.