Solid.js Testing Guide: Vitest + @solidjs/testing-library (2026)

Solid.js Testing Guide: Vitest + @solidjs/testing-library (2026)

Solid.js is fast by design — fine-grained reactivity, no virtual DOM, components that run once. That architecture is a strength at runtime, but it means your component tests can't rely on patterns from React or Vue. Signals update synchronously. Components don't re-render — they update in place. You need to test for effects, not renders.

This is the testing stack that works for Solid in 2026: Vitest for units and components, @solidjs/testing-library for DOM interactions, and Playwright for full E2E flows.

Project Setup

Install the testing dependencies:

npm install -D vitest @solidjs/testing-library @testing-library/jest-dom @testing-library/user-event jsdom vite-plugin-solid

Create vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import solidPlugin from 'vite-plugin-solid';

export default defineConfig({
  plugins: [solidPlugin()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    transformMode: { web: [/\.[jt]sx?$/] },
  },
  resolve: {
    conditions: ['development', 'browser'],
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

The resolve.conditions and transformMode settings are required — Solid's compiler transforms JSX differently from React, and Vitest needs to know to use the browser build.

Testing Reactive Primitives

Solid's core primitives — createSignal, createMemo, createEffect — are pure functions that work in any JS environment. Test them without any DOM or browser setup.

// src/utils/cart.ts
import { createSignal, createMemo } from 'solid-js';

export function createCart() {
  const [items, setItems] = createSignal<{ id: string; price: number }[]>([]);

  const total = createMemo(() =>
    items().reduce((sum, item) => sum + item.price, 0)
  );

  const addItem = (item: { id: string; price: number }) =>
    setItems((prev) => [...prev, item]);

  const removeItem = (id: string) =>
    setItems((prev) => prev.filter((i) => i.id !== id));

  return { items, total, addItem, removeItem };
}
// src/utils/cart.test.ts
import { describe, it, expect } from 'vitest';
import { createRoot } from 'solid-js';
import { createCart } from './cart';

describe('createCart', () => {
  it('starts empty with zero total', () => {
    createRoot((dispose) => {
      const { items, total } = createCart();
      expect(items()).toEqual([]);
      expect(total()).toBe(0);
      dispose();
    });
  });

  it('adds items and updates total', () => {
    createRoot((dispose) => {
      const { items, total, addItem } = createCart();
      addItem({ id: 'a', price: 25 });
      addItem({ id: 'b', price: 30 });
      expect(items()).toHaveLength(2);
      expect(total()).toBe(55);
      dispose();
    });
  });

  it('removes an item by id', () => {
    createRoot((dispose) => {
      const { items, removeItem, addItem } = createCart();
      addItem({ id: 'a', price: 25 });
      addItem({ id: 'b', price: 30 });
      removeItem('a');
      expect(items()).toHaveLength(1);
      expect(items()[0].id).toBe('b');
      dispose();
    });
  });
});

createRoot is required for signals and memos to function correctly outside a component tree. Always call dispose() to clean up reactive roots after each test.

Component Testing with @solidjs/testing-library

@solidjs/testing-library wraps Solid's renderer with the familiar render API and integrates with @testing-library/jest-dom matchers.

// src/components/Counter.tsx
import { createSignal } from 'solid-js';

interface Props {
  initial?: number;
  step?: number;
}

export function Counter({ initial = 0, step = 1 }: Props) {
  const [count, setCount] = createSignal(initial);

  return (
    <div>
      <span data-testid="count">{count()}</span>
      <button onClick={() => setCount((c) => c - step)}>Decrement</button>
      <button onClick={() => setCount((c) => c + step)}>Increment</button>
      <button onClick={() => setCount(initial)}>Reset</button>
    </div>
  );
}
// src/components/Counter.test.tsx
import { render, screen, fireEvent, cleanup } from '@solidjs/testing-library';
import { describe, it, expect, afterEach } from 'vitest';
import { Counter } from './Counter';

afterEach(cleanup);

describe('Counter', () => {
  it('starts at the initial value', () => {
    render(() => <Counter initial={10} />);
    expect(screen.getByTestId('count')).toHaveTextContent('10');
  });

  it('increments by the step value', () => {
    render(() => <Counter initial={0} step={5} />);
    fireEvent.click(screen.getByText('Increment'));
    expect(screen.getByTestId('count')).toHaveTextContent('5');
  });

  it('decrements correctly', () => {
    render(() => <Counter initial={10} />);
    fireEvent.click(screen.getByText('Decrement'));
    expect(screen.getByTestId('count')).toHaveTextContent('9');
  });

  it('resets to the initial value', () => {
    render(() => <Counter initial={5} />);
    fireEvent.click(screen.getByText('Increment'));
    fireEvent.click(screen.getByText('Increment'));
    fireEvent.click(screen.getByText('Reset'));
    expect(screen.getByTestId('count')).toHaveTextContent('5');
  });
});

Call cleanup() after each test to unmount the component and remove it from jsdom. Without it, components from previous tests can interfere with later ones.

Testing Async and Resources

Solid's createResource handles async data fetching. Test it by providing a mock fetcher and awaiting screen.findBy* queries.

// src/components/UserProfile.tsx
import { createResource, Show } from 'solid-js';

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('User not found');
  return res.json();
}

export function UserProfile(props: { userId: string }) {
  const [user] = createResource(() => props.userId, fetchUser);

  return (
    <Show when={!user.loading} fallback={<p>Loading...</p>}>
      <Show when={!user.error} fallback={<p>Error: {user.error?.message}</p>}>
        <div>
          <h1>{user()?.name}</h1>
          <p>{user()?.email}</p>
        </div>
      </Show>
    </Show>
  );
}
// src/components/UserProfile.test.tsx
import { render, screen, cleanup } from '@solidjs/testing-library';
import { describe, it, expect, afterEach, vi, beforeEach } from 'vitest';
import { UserProfile } from './UserProfile';

afterEach(cleanup);

beforeEach(() => {
  vi.restoreAllMocks();
});

describe('UserProfile', () => {
  it('shows a loading state initially', async () => {
    vi.spyOn(global, 'fetch').mockImplementation(
      () => new Promise(() => {}) // never resolves
    );

    render(() => <UserProfile userId="1" />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('renders user data after fetch resolves', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValueOnce(
      new Response(JSON.stringify({ name: 'Alice', email: 'alice@example.com' }), {
        status: 200,
      })
    );

    render(() => <UserProfile userId="1" />);
    expect(await screen.findByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('alice@example.com')).toBeInTheDocument();
  });

  it('shows an error message when the fetch fails', async () => {
    vi.spyOn(global, 'fetch').mockResolvedValueOnce(
      new Response(null, { status: 404 })
    );

    render(() => <UserProfile userId="999" />);
    expect(await screen.findByText(/User not found/i)).toBeInTheDocument();
  });
});

Testing Solid Stores

createStore gives you mutable nested state with fine-grained updates. Test stores the same way as signals — inside createRoot.

// src/stores/todos.ts
import { createStore } from 'solid-js/store';

export function createTodoStore() {
  const [todos, setTodos] = createStore<{ id: number; text: string; done: boolean }[]>([]);

  return {
    todos,
    add: (text: string) =>
      setTodos((prev) => [...prev, { id: Date.now(), text, done: false }]),
    toggle: (id: number) =>
      setTodos((todo) => todo.id === id, 'done', (d) => !d),
    remove: (id: number) =>
      setTodos((prev) => prev.filter((t) => t.id !== id)),
  };
}
// src/stores/todos.test.ts
import { describe, it, expect } from 'vitest';
import { createRoot } from 'solid-js';
import { createTodoStore } from './todos';

describe('createTodoStore', () => {
  it('adds todos to the list', () => {
    createRoot((dispose) => {
      const store = createTodoStore();
      store.add('Write tests');
      store.add('Ship feature');
      expect(store.todos).toHaveLength(2);
      expect(store.todos[0].text).toBe('Write tests');
      expect(store.todos[0].done).toBe(false);
      dispose();
    });
  });

  it('toggles a todo done state', () => {
    createRoot((dispose) => {
      const store = createTodoStore();
      store.add('Write tests');
      const id = store.todos[0].id;
      store.toggle(id);
      expect(store.todos[0].done).toBe(true);
      store.toggle(id);
      expect(store.todos[0].done).toBe(false);
      dispose();
    });
  });

  it('removes a todo by id', () => {
    createRoot((dispose) => {
      const store = createTodoStore();
      store.add('Task 1');
      store.add('Task 2');
      const id = store.todos[0].id;
      store.remove(id);
      expect(store.todos).toHaveLength(1);
      expect(store.todos[0].text).toBe('Task 2');
      dispose();
    });
  });
});

Playwright E2E for Solid Apps

Unit tests cover component logic in isolation. Playwright tests cover full user workflows in a real browser — routing, form submission, SolidRouter navigation, and anything that requires actual browser APIs.

npm install -D @playwright/test
npx playwright install chromium
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:3000',
  },
});
// e2e/todo-app.test.ts
import { test, expect } from '@playwright/test';

test.describe('todo app', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/todos');
  });

  test('adds a new todo', async ({ page }) => {
    await page.getByPlaceholder('Add a task...').fill('Buy groceries');
    await page.getByRole('button', { name: 'Add' }).click();
    await expect(page.getByText('Buy groceries')).toBeVisible();
  });

  test('marks a todo as done', async ({ page }) => {
    await page.getByPlaceholder('Add a task...').fill('Write tests');
    await page.getByRole('button', { name: 'Add' }).click();
    await page.getByLabel('Write tests').check();
    await expect(page.getByLabel('Write tests')).toBeChecked();
  });

  test('removes a todo', async ({ page }) => {
    await page.getByPlaceholder('Add a task...').fill('Temporary task');
    await page.getByRole('button', { name: 'Add' }).click();
    await page.getByRole('button', { name: 'Delete Temporary task' }).click();
    await expect(page.getByText('Temporary task')).not.toBeVisible();
  });
});

What Your Tests Won't Catch

Vitest and Playwright cover the code you write. Production failures are different:

  • A signal update loop causes a memory leak in a long-running SPA session
  • A createResource call that succeeds in dev but times out against the production API under load
  • A SolidRouter navigation that works locally but breaks on a CDN due to missing 404 fallback configuration
  • A third-party script that mutates the DOM and breaks Solid's fine-grained updates

These show up in production, not in test runs. Monitoring catches them.

Monitoring Solid.js Apps in Production with HelpMeTest

Install HelpMeTest:

curl -fsSL https://helpmetest.com/install | bash

Write plain-English tests that run against your live app on a schedule:

Go to https://mysolidapp.com
Fill in the task input with "Integration test task"
Click the Add button
Verify the text "Integration test task" is visible on the page
Click the delete button for "Integration test task"
Verify the text "Integration test task" is no longer visible

HelpMeTest runs these every few minutes and alerts you when they fail. If your Solid app breaks in production — a signal stops updating, a resource silently returns stale data, a route stops rendering — you know immediately, not from a user support ticket.

Free tier: 10 tests, unlimited health checks. Pro: $100/month for unlimited tests and parallel execution.

Your Vitest tests prove the reactivity is correct. Playwright proves the flows work. HelpMeTest proves the deployed app is working right now.


Start at helpmetest.com — free tier, no credit card.

Read more