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-eventVitest 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
- 1Test User Behavior: Test what users see and do
- 2Use waitFor for Async: Wait for async operations
- 3Mock External Dependencies: Mock APIs and services
- 4Keep Tests Isolated: Each test should be independent
- 5Use Descriptive Names: Clear test names that describe behavior
- 6Test Edge Cases: Test error states and edge cases
- 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.