← Back to DevBytes

Testing SolidJS Components: From Unit to E2E Tests

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles