Skip to content
All essays
CraftMarch 27, 202616 min

SolidJS Testing: Comprehensive Guide to Testing Solid Applications

Master testing SolidJS applications with Vitest, Solid Testing Library, and best practices for unit and integration testing.

Ü
Ümit Uz
Mobile & Full Stack Developer

Testing is crucial for building reliable applications. SolidJS provides excellent testing support through Vitest and Solid Testing Library, making it easy to write comprehensive tests for components, hooks, and stores.

Testing Setup

Installing Testing Dependencies

bash
npm install -D vitest @solidjs/testing-library solid-js
npm install -D @testing-library/jest-dom
npm install -D @testing-library/user-event

Vitest Configuration

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

export default defineConfig({
  plugins: [solid()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
      ],
    },
  },
});

Test Setup File

typescript
// src/test/setup.ts
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@solidjs/testing-library';

afterEach(() => {
  cleanup();
});

Testing Components

Basic Component Testing

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

export default function Counter() {
  const [count, setCount] = createSignal(0);

  return (
    <div>
      <span>Count: {count()}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

// Counter.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';

describe('Counter', () => {
  it('renders initial count', () => {
    render(() => <Counter />);
    expect(screen.getByText('Count: 0')).toBeInTheDocument();
  });

  it('increments count when button is clicked', () => {
    render(() => <Counter />);

    const button = screen.getByRole('button', { name: /increment/i });
    button.click();

    expect(screen.getByText('Count: 1')).toBeInTheDocument();
  });
});

Testing User Interactions

tsx
// Form.tsx
import { createSignal } from 'solid-js';

export default function Form() {
  const [name, setName] = createSignal('');
  const [email, setEmail] = createSignal('');
  const [submitted, setSubmitted] = createSignal(false);

  const handleSubmit = (e: Event) => {
    e.preventDefault();
    setSubmitted(true);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Name"
        value={name()}
        onInput={(e) => setName(e.currentTarget.value)}
      />
      <input
        type="email"
        placeholder="Email"
        value={email()}
        onInput={(e) => setEmail(e.currentTarget.value)}
      />
      <button type="submit">Submit</button>

      <Show when={submitted()}>
        <p>Submitted: {name()} ({email()})</p>
      </Show>
    </form>
  );
}

// Form.test.tsx
import { render, screen } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Form from './Form';

describe('Form', () => {
  it('submits form with data', async () => {
    const user = userEvent.setup();
    render(() => <Form />);

    const nameInput = screen.getByPlaceholderText('Name');
    const emailInput = screen.getByPlaceholderText('Email');
    const submitButton = screen.getByRole('button', { name: /submit/i });

    await user.type(nameInput, 'John Doe');
    await user.type(emailInput, 'john@example.com');
    await user.click(submitButton);

    expect(screen.getByText('Submitted: John Doe (john@example.com)')).toBeInTheDocument();
  });
});

Testing Conditional Rendering

tsx
// UserGreeting.tsx
import { createSignal, Show } from 'solid-js';

export default function UserGreeting() {
  const [isLoggedIn, setIsLoggedIn] = createSignal(false);

  return (
    <div>
      <Show when={isLoggedIn()} fallback={<button onClick={() => setIsLoggedIn(true)}>Login</button>}>
        <h1>Welcome back!</h1>
        <button onClick={() => setIsLoggedIn(false)}>Logout</button>
      </Show>
    </div>
  );
}

// UserGreeting.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect } from 'vitest';
import UserGreeting from './UserGreeting';

describe('UserGreeting', () => {
  it('shows login button when not logged in', () => {
    render(() => <UserGreeting />);
    expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
  });

  it('shows welcome message after login', () => {
    render(() => <UserGreeting />);

    const loginButton = screen.getByRole('button', { name: /login/i });
    loginButton.click();

    expect(screen.getByText('Welcome back!')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument();
  });
});

Testing List Rendering

tsx
// TodoList.tsx
import { createSignal, For } from 'solid-js';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export default function TodoList() {
  const [todos, setTodos] = createSignal<Todo[]>([]);

  const addTodo = (text: string) => {
    setTodos((todos) => [
      ...todos,
      { id: Date.now(), text, completed: false },
    ]);
  };

  return (
    <div>
      <ul>
        <For each={todos()}>
          {(todo) => (
            <li>{todo.text}</li>
          )}
        </For>
      </ul>
      <button onClick={() => addTodo('New Todo')}>Add Todo</button>
    </div>
  );
}

// TodoList.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { describe, it, expect } from 'vitest';
import TodoList from './TodoList';

describe('TodoList', () => {
  it('renders empty list', () => {
    render(() => <TodoList />);
    expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
  });

  it('adds todo when button is clicked', () => {
    render(() => <TodoList />);

    const addButton = screen.getByRole('button', { name: /add todo/i });
    addButton.click();

    expect(screen.getByText('New Todo')).toBeInTheDocument();
  });
});

Testing Reactive Primitives

Testing Signals

tsx
// useCounter.ts
import { createSignal } from 'solid-js';

export function useCounter(initialValue = 0) {
  const [count, setCount] = createSignal(initialValue);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

// useCounter.test.ts
import { describe, it, expect } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { count } = useCounter();
    expect(count()).toBe(0);
  });

  it('initializes with custom value', () => {
    const { count } = useCounter(5);
    expect(count()).toBe(5);
  });

  it('increments count', () => {
    const { count, increment } = useCounter();
    increment();
    expect(count()).toBe(1);
  });

  it('decrements count', () => {
    const { count, decrement } = useCounter(5);
    decrement();
    expect(count()).toBe(4);
  });

  it('resets to initial value', () => {
    const { count, increment, reset } = useCounter(5);
    increment();
    increment();
    reset();
    expect(count()).toBe(5);
  });
});

Testing Effects

tsx
// useDocumentTitle.ts
import { createEffect, createSignal } from 'solid-js';

export function useDocumentTitle(initialTitle: string) {
  const [title, setTitle] = createSignal(initialTitle);

  createEffect(() => {
    document.title = title();
  });

  return { title, setTitle };
}

// useDocumentTitle.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useDocumentTitle } from './useDocumentTitle';

describe('useDocumentTitle', () => {
  beforeEach(() => {
    vi.stubGlobal('document', {
      get title() { return this._title || ''; },
      set title(value) { this._title = value; },
    });
  });

  it('sets initial document title', () => {
    const { setTitle } = useDocumentTitle('Home');
    expect(document.title).toBe('Home');
  });

  it('updates document title', () => {
    const { setTitle } = useDocumentTitle('Home');
    setTitle('About');
    expect(document.title).toBe('About');
  });
});

Testing Stores

tsx
// todoStore.ts
import { createStore } from 'solid-js/store';

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

export function createTodoStore() {
  const [todos, setTodos] = createStore<Todo[]>([]);

  const addTodo = (text: string) => {
    setTodos((todos) => [
      ...todos,
      { id: Date.now(), text, completed: false },
    ]);
  };

  const toggleTodo = (id: number) => {
    setTodos(
      (todo) => todo.id === id,
      'completed',
      (completed) => !completed
    );
  };

  const removeTodo = (id: number) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  };

  return { todos, addTodo, toggleTodo, removeTodo };
}

// todoStore.test.ts
import { describe, it, expect } from 'vitest';
import { createTodoStore } from './todoStore';

describe('createTodoStore', () => {
  it('adds todo', () => {
    const { todos, addTodo } = createTodoStore();
    addTodo('Learn SolidJS');

    expect(todos.length).toBe(1);
    expect(todos[0].text).toBe('Learn SolidJS');
  });

  it('toggles todo', () => {
    const { todos, addTodo, toggleTodo } = createTodoStore();
    addTodo('Learn SolidJS');

    toggleTodo(todos[0].id);

    expect(todos[0].completed).toBe(true);
  });

  it('removes todo', () => {
    const { todos, addTodo, removeTodo } = createTodoStore();
    addTodo('Learn SolidJS');

    removeTodo(todos[0].id);

    expect(todos.length).toBe(0);
  });
});

Testing Async Operations

Testing Async Components

tsx
// UserProfile.tsx
import { createSignal, onMount, Show } from 'solid-js';

interface User {
  id: number;
  name: string;
  email: string;
}

export default function UserProfile(props: { userId: number }) {
  const [user, setUser] = createSignal<User | null>(null);
  const [isLoading, setIsLoading] = createSignal(true);
  const [error, setError] = createSignal<string | null>(null);

  onMount(async () => {
    try {
      const response = await fetch(`/api/users/${props.userId}`);
      if (!response.ok) throw new Error('User not found');
      const userData = await response.json();
      setUser(userData);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setIsLoading(false);
    }
  });

  return (
    <div>
      <Show when={isLoading()}>
        <p>Loading...</p>
      </Show>
      <Show when={error()}>
        <p>Error: {error()}</p>
      </Show>
      <Show when={user()}>
        {(user) => (
          <div>
            <h1>{user().name}</h1>
            <p>{user().email}</p>
          </div>
        )}
      </Show>
    </div>
  );
}

// UserProfile.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import { describe, it, expect, vi } from 'vitest';
import UserProfile from './UserProfile';

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    render(() => <UserProfile userId={1} />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  it('shows user data after loading', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({
          id: 1,
          name: 'John Doe',
          email: 'john@example.com',
        }),
      })
    ) as any;

    render(() => <UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument();
      expect(screen.getByText('john@example.com')).toBeInTheDocument();
    });
  });

  it('shows error message on failure', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: false,
      })
    ) as any;

    render(() => <UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('Error: User not found')).toBeInTheDocument();
    });
  });
});

Testing createResource

tsx
// useProducts.ts
import { createResource } from 'solid-js';

interface Product {
  id: number;
  name: string;
  price: number;
}

export function useProducts(category: string) {
  const [products] = createResource(category, async (cat) => {
    const response = await fetch(`/api/products?category=${cat}`);
    if (!response.ok) throw new Error('Failed to fetch products');
    return response.json() as Promise<Product[]>;
  });

  return products;
}

// useProducts.test.ts
import { describe, it, expect, vi } from 'vitest';
import { useProducts } from './useProducts';

describe('useProducts', () => {
  it('fetches products by category', async () => {
    const mockProducts = [
      { id: 1, name: 'Product 1', price: 10 },
      { id: 2, name: 'Product 2', price: 20 },
    ];

    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve(mockProducts),
      })
    ) as any;

    const products = useProducts('electronics');

    await Promise.resolve(); // Wait for async updates

    expect(products()).toEqual(mockProducts);
  });
});

Integration Testing

Testing User Flows

tsx
// ShoppingCart.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import ShoppingCart from './ShoppingCart';

describe('ShoppingCart Flow', () => {
  it('adds product to cart and updates total', async () => {
    const user = userEvent.setup();
    render(() => <ShoppingCart />);

    // Add first product
    await user.click(screen.getByRole('button', { name: /add product 1/i }));

    // Add second product
    await user.click(screen.getByRole('button', { name: /add product 2/i }));

    // Verify cart updates
    await waitFor(() => {
      expect(screen.getByText('Total: $30.00')).toBeInTheDocument();
      expect(screen.getByText('Items: 2')).toBeInTheDocument();
    });

    // Remove first product
    await user.click(screen.getByRole('button', { name: /remove product 1/i }));

    // Verify cart updates again
    await waitFor(() => {
      expect(screen.getByText('Total: $20.00')).toBeInTheDocument();
      expect(screen.getByText('Items: 1')).toBeInTheDocument();
    });
  });
});

Testing Best Practices

  1. 1Test User Behavior: Test what users see and do
  2. 2Use waitFor for Async: Wait for async operations
  3. 3Mock External Dependencies: Mock APIs and services
  4. 4Keep Tests Isolated: Each test should be independent
  5. 5Use Descriptive Names: Clear test names that describe behavior
  6. 6Test Edge Cases: Test error states and edge cases
  7. 7Avoid Testing Implementation: Test behavior, not implementation

Conclusion

Testing SolidJS applications is straightforward with Vitest and Solid Testing Library. By following these patterns and best practices, you can write comprehensive tests that ensure your components, hooks, and stores work correctly.

A well-tested codebase is more maintainable, reliable, and easier to refactor. Invest time in writing good tests, and your future self will thank you.

Enjoyed this essay? Share it.

Related essays

Next essay
Backend for Frontend (BFF) Pattern: Complete Implementation Guide