SolidJS のユニットテスト:Testing Library 連携入門

SolidJS の開発において、品質の高いアプリケーションを構築するためには適切なテスト戦略が欠かせません。特に、リアクティブなシステムを持つ SolidJS では、従来の React とは異なるアプローチでテストを行う必要があります。
本記事では、SolidJS における Testing Library の活用方法について詳しく解説いたします。環境構築から実際のテストコード作成まで、実用的な知識をお届けします。
背景
SolidJS のテスト環境の重要性
モダンなフロントエンド開発において、SolidJS は高いパフォーマンスと直感的なAPIで注目を集めています。しかし、その独自のリアクティブシステムは、テスト作成において特別な配慮が必要です。
SolidJS のコンポーネントは、Signal や Store といったリアクティブなプリミティブを中心に構築されています。これらの状態管理システムは実行時に動的に依存関係を構築するため、従来のテスト手法では適切にテストできない場合があります。
typescriptimport { createSignal } from 'solid-js';
function Counter() {
const [count, setCount] = createSignal(0);
return (
<div>
<p>Count: {count()}</p>
<button onClick={() => setCount(count() + 1)}>
Increment
</button>
</div>
);
}
上記のような単純なコンポーネントでも、Signal の動作を適切にテストするには専用のツールが必要になります。
Testing Library の React から SolidJS への移行需要
React エコシステムから SolidJS への移行を検討している開発者にとって、Testing Library の存在は大きなメリットです。React Testing Library で培ったテストの知識とベストプラクティスを、SolidJS でも活用できるからです。
Testing Library は「ユーザーの視点でテストを書く」という一貫した哲学を持っています。この哲学は、React であっても SolidJS であっても変わりません。
項目 | React Testing Library | SolidJS Testing Library |
---|---|---|
基本的なAPI | render, screen, waitFor | 同じAPI構造 |
クエリメソッド | getByText, findByRole等 | 同じクエリメソッド |
イベント処理 | fireEvent, userEvent | 同じイベント処理 |
非同期テスト | waitFor, findBy系 | 同じ非同期パターン |
モダンフロントエンド開発におけるテストの位置づけ
現代の Web 開発では、テストは開発プロセスの中核的な要素です。特に以下の点で重要な役割を果たしています。
継続的インテグレーション(CI)との統合により、コードの品質を自動的に保証できます。リファクタリング時の安全性を確保し、新機能追加時の既存機能への影響を早期に発見します。
チーム開発においては、テストコードがドキュメントとしての役割も果たします。他の開発者がコンポーネントの期待される動作を理解する際の重要な手がかりとなるのです。
課題
SolidJS 特有のリアクティブシステムのテスト方法
SolidJS の最大の特徴は、その細粒度のリアクティブシステムです。この システムでは、Signal や Effect、Store が相互に依存関係を形成し、データの変更が自動的に UI に反映されます。
typescriptimport { createSignal, createEffect } from 'solid-js';
function ReactiveComponent() {
const [name, setName] = createSignal('');
const [greeting, setGreeting] = createSignal('');
// createEffect は依存関係を自動的に追跡
createEffect(() => {
if (name()) {
setGreeting(`Hello, ${name()}!`);
}
});
return (
<div>
<input
value={name()}
onInput={(e) => setName(e.target.value)}
placeholder="Your name"
/>
<p>{greeting()}</p>
</div>
);
}
このような リアクティブな依存関係をテストする際、以下の課題が発生します。
非同期更新の処理が困難です。Signal の変更が Effect を通じて他の Signal に影響する場合、その更新タイミングを正確に制御する必要があります。
依存関係の追跡も複雑になります。どの Signal がどの Effect に影響するかを理解し、適切なタイミングでアサーションを行わなければなりません。
Testing Library との連携設定の複雑さ
SolidJS で Testing Library を使用するには、いくつかの設定が必要です。特に、バンドラーとテストランナーの組み合わせによって、設定方法が異なります。
最も一般的な構成は Vite + Vitest の組み合わせですが、設定ファイルの記述方法や依存関係の解決方法について理解が必要です。
javascript// vite.config.js
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/setupTests.ts'],
},
});
テスト環境の初期化ファイルの設定も重要です。
typescript// setupTests.ts
import '@testing-library/jest-dom';
従来の React Testing Library との違い
API は類似していても、内部的な動作には重要な違いがあります。
React では仮想 DOM の diffing によって更新が行われますが、SolidJS では Signal の変更が直接 DOM を更新します。この違いにより、テストのタイミングや期待値の設定方法が異なる場合があります。
また、React の useEffect
と SolidJS の createEffect
では、依存関係の追跡方法が根本的に異なります。React では依存配列を明示的に指定しますが、SolidJS では自動的に追跡されるため、テスト時の制御方法も変わってきます。
解決策
@solidjs/testing-library の導入と設定
SolidJS Testing Library は、React Testing Library と同様の API を提供しながら、SolidJS 特有の機能に対応したテスティングユーティリティです。
まず、必要なパッケージをインストールします。
bashyarn add -D @solidjs/testing-library @testing-library/jest-dom vitest jsdom
プロジェクトの設定ファイルを作成し、テスト環境を整備します。
javascript// vite.config.js
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
export default defineConfig({
plugins: [solidPlugin()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
transformMode: {
web: [/\.[jt]sx?$/],
},
},
resolve: {
conditions: ['development', 'browser'],
},
});
テストセットアップファイルを作成し、必要な設定を行います。
typescript// src/test-setup.ts
import '@testing-library/jest-dom';
// SolidJS のテスト環境に必要なグローバル設定
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
});
Vitest との組み合わせによる効率的なテスト環境
Vitest は Vite ベースの高速なテストランナーです。SolidJS プロジェクトとの親和性が高く、設定も簡潔に行えます。
package.json にテストスクリプトを追加します。
json{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
TypeScript の型定義を適切に設定します。
typescript// src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />
/// <reference types="@testing-library/jest-dom" />
Vitest の設定をより詳細に調整することで、テストの実行速度と開発体験を向上させられます。
javascript// vitest.config.js の詳細設定
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test-setup.ts',
// ファイル変更時の自動実行
watch: true,
// カバレッジレポート設定
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test-setup.ts'],
},
},
});
SolidJS のリアクティブな状態管理のテスト戦略
SolidJS のリアクティブシステムをテストする際は、以下の戦略が効果的です。
Signal のテスト戦略では、Signal の初期値、更新、および依存関係の変化を段階的にテストします。
typescript// Signal テスト用のユーティリティ関数
import { render, screen, fireEvent } from '@solidjs/testing-library';
function TestSignalComponent() {
const [count, setCount] = createSignal(0);
const doubled = () => count() * 2;
return (
<div>
<span data-testid="count">{count()}</span>
<span data-testid="doubled">{doubled()}</span>
<button onClick={() => setCount(count() + 1)}>
Increment
</button>
</div>
);
}
// テストコード
test('Signal の更新と計算値のテスト', () => {
render(() => <TestSignalComponent />);
expect(screen.getByTestId('count')).toHaveTextContent('0');
expect(screen.getByTestId('doubled')).toHaveTextContent('0');
fireEvent.click(screen.getByRole('button'));
expect(screen.getByTestId('count')).toHaveTextContent('1');
expect(screen.getByTestId('doubled')).toHaveTextContent('2');
});
Store のテスト戦略では、より複雑な状態管理をテストします。
typescriptimport { createStore } from 'solid-js/store';
function StoreComponent() {
const [state, setState] = createStore({
user: {
name: '',
email: '',
},
isValid: false,
});
const updateUser = (field: string, value: string) => {
setState('user', field, value);
// バリデーション ロジック
setState('isValid',
state.user.name.length > 0 && state.user.email.includes('@')
);
};
return (
<form>
<input
data-testid="name-input"
value={state.user.name}
onInput={(e) => updateUser('name', e.target.value)}
/>
<input
data-testid="email-input"
value={state.user.email}
onInput={(e) => updateUser('email', e.target.value)}
/>
<button
type="submit"
disabled={!state.isValid}
data-testid="submit-button"
>
Submit
</button>
</form>
);
}
具体例
基本的なコンポーネントのテスト
最も基本的なコンポーネントのテストから始めましょう。単純な props の受け渡しとレンダリングのテストです。
typescript// components/Greeting.tsx
import { Component } from 'solid-js';
interface GreetingProps {
name: string;
title?: string;
}
const Greeting: Component<GreetingProps> = (props) => {
return (
<div>
<h1>Hello, {props.name}!</h1>
{props.title && <p>Title: {props.title}</p>}
</div>
);
};
export default Greeting;
このコンポーネントのテストコードは以下のようになります。
typescript// components/Greeting.test.tsx
import { render, screen } from '@solidjs/testing-library';
import { describe, test, expect } from 'vitest';
import Greeting from './Greeting';
describe('Greeting Component', () => {
test('名前を正しく表示する', () => {
render(() => <Greeting name="太郎" />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, 太郎!');
});
test('タイトルが提供された場合に表示する', () => {
render(() => <Greeting name="花子" title="エンジニア" />);
expect(screen.getByText('Title: エンジニア')).toBeInTheDocument();
});
test('タイトルが提供されない場合は表示しない', () => {
render(() => <Greeting name="太郎" />);
expect(screen.queryByText(/Title:/)).not.toBeInTheDocument();
});
});
イベントハンドリングのテスト
ユーザーインタラクションを含むコンポーネントのテスト方法を見ていきましょう。
typescript// components/TodoItem.tsx
import { Component, createSignal } from 'solid-js';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoItemProps {
todo: Todo;
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoItem: Component<TodoItemProps> = (props) => {
const [isEditing, setIsEditing] = createSignal(false);
const [editText, setEditText] = createSignal(props.todo.text);
const handleEdit = () => {
setIsEditing(true);
setEditText(props.todo.text);
};
const handleSave = () => {
// 実際のアプリでは onUpdate のような prop を使用
setIsEditing(false);
};
const handleCancel = () => {
setIsEditing(false);
setEditText(props.todo.text);
};
return (
<li class={props.todo.completed ? 'completed' : ''}>
{isEditing() ? (
<div>
<input
data-testid="edit-input"
value={editText()}
onInput={(e) => setEditText(e.target.value)}
/>
<button onClick={handleSave} data-testid="save-button">
Save
</button>
<button onClick={handleCancel} data-testid="cancel-button">
Cancel
</button>
</div>
) : (
<div>
<input
type="checkbox"
checked={props.todo.completed}
onChange={() => props.onToggle(props.todo.id)}
data-testid="toggle-checkbox"
/>
<span data-testid="todo-text">{props.todo.text}</span>
<button onClick={handleEdit} data-testid="edit-button">
Edit
</button>
<button
onClick={() => props.onDelete(props.todo.id)}
data-testid="delete-button"
>
Delete
</button>
</div>
)}
</li>
);
};
このコンポーネントのテストでは、さまざまなユーザーインタラクションを検証します。
typescript// components/TodoItem.test.tsx
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { describe, test, expect, vi } from 'vitest';
import TodoItem from './TodoItem';
describe('TodoItem Component', () => {
const mockTodo = {
id: 1,
text: 'Test todo',
completed: false,
};
const mockProps = {
todo: mockTodo,
onToggle: vi.fn(),
onDelete: vi.fn(),
};
test('todoアイテムを正しく表示する', () => {
render(() => <TodoItem {...mockProps} />);
expect(screen.getByTestId('todo-text')).toHaveTextContent('Test todo');
expect(screen.getByTestId('toggle-checkbox')).not.toBeChecked();
});
test('完了状態をトグルできる', () => {
render(() => <TodoItem {...mockProps} />);
fireEvent.click(screen.getByTestId('toggle-checkbox'));
expect(mockProps.onToggle).toHaveBeenCalledWith(1);
});
test('削除ボタンクリックで削除関数が呼ばれる', () => {
render(() => <TodoItem {...mockProps} />);
fireEvent.click(screen.getByTestId('delete-button'));
expect(mockProps.onDelete).toHaveBeenCalledWith(1);
});
test('編集モードに切り替えられる', () => {
render(() => <TodoItem {...mockProps} />);
fireEvent.click(screen.getByTestId('edit-button'));
expect(screen.getByTestId('edit-input')).toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});
Store(状態管理)のテスト
より複雑な状態管理をテストする方法を見ていきましょう。
typescript// stores/counterStore.ts
import { createStore } from 'solid-js/store';
interface CounterState {
count: number;
history: number[];
maxCount: number;
}
export function createCounterStore() {
const [state, setState] = createStore<CounterState>({
count: 0,
history: [],
maxCount: 0,
});
const increment = () => {
setState('count', (prev) => prev + 1);
setState('history', (prev) => [...prev, state.count]);
setState('maxCount', (prev) => Math.max(prev, state.count));
};
const decrement = () => {
setState('count', (prev) => Math.max(0, prev - 1));
setState('history', (prev) => [...prev, state.count]);
};
const reset = () => {
setState({
count: 0,
history: [],
maxCount: 0,
});
};
return {
state,
increment,
decrement,
reset,
};
}
Store を使用するコンポーネントを作成します。
typescript// components/Counter.tsx
import { Component } from 'solid-js';
import { createCounterStore } from '../stores/counterStore';
const Counter: Component = () => {
const { state, increment, decrement, reset } = createCounterStore();
return (
<div>
<h2>Counter: {state.count}</h2>
<p>Max Count: {state.maxCount}</p>
<p>History: {state.history.join(', ')}</p>
<div>
<button onClick={increment} data-testid="increment-btn">
+
</button>
<button onClick={decrement} data-testid="decrement-btn">
-
</button>
<button onClick={reset} data-testid="reset-btn">
Reset
</button>
</div>
</div>
);
};
export default Counter;
Store を使用するコンポーネントのテストを記述します。
typescript// components/Counter.test.tsx
import { render, screen, fireEvent } from '@solidjs/testing-library';
import { describe, test, expect } from 'vitest';
import Counter from './Counter';
describe('Counter Component with Store', () => {
test('初期状態が正しく表示される', () => {
render(() => <Counter />);
expect(screen.getByText('Counter: 0')).toBeInTheDocument();
expect(screen.getByText('Max Count: 0')).toBeInTheDocument();
expect(screen.getByText('History:')).toBeInTheDocument();
});
test('インクリメント機能が正しく動作する', () => {
render(() => <Counter />);
fireEvent.click(screen.getByTestId('increment-btn'));
expect(screen.getByText('Counter: 1')).toBeInTheDocument();
expect(screen.getByText('Max Count: 1')).toBeInTheDocument();
expect(screen.getByText('History: 0')).toBeInTheDocument();
});
test('デクリメント機能が正しく動作する', () => {
render(() => <Counter />);
// まず値を増やす
fireEvent.click(screen.getByTestId('increment-btn'));
fireEvent.click(screen.getByTestId('increment-btn'));
// デクリメント
fireEvent.click(screen.getByTestId('decrement-btn'));
expect(screen.getByText('Counter: 1')).toBeInTheDocument();
});
test('リセット機能が正しく動作する', () => {
render(() => <Counter />);
// 値を変更
fireEvent.click(screen.getByTestId('increment-btn'));
fireEvent.click(screen.getByTestId('increment-btn'));
// リセット
fireEvent.click(screen.getByTestId('reset-btn'));
expect(screen.getByText('Counter: 0')).toBeInTheDocument();
expect(screen.getByText('Max Count: 0')).toBeInTheDocument();
expect(screen.getByText('History:')).toBeInTheDocument();
});
});
非同期処理を含むコンポーネントのテスト
非同期データ取得を含むコンポーネントのテスト方法を解説します。
typescript// components/UserProfile.tsx
import { Component, createResource, Suspense } from 'solid-js';
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
}
interface UserProfileProps {
userId: number;
}
const UserProfile: Component<UserProfileProps> = (props) => {
const [user] = createResource(() => props.userId, fetchUser);
return (
<Suspense fallback={<div data-testid="loading">Loading...</div>}>
<div data-testid="user-profile">
<h2>{user()?.name}</h2>
<p>Email: {user()?.email}</p>
</div>
</Suspense>
);
};
export default UserProfile;
非同期処理のテストでは、モックとwaitForを活用します。
typescript// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@solidjs/testing-library';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import UserProfile from './UserProfile';
// fetch をモック
global.fetch = vi.fn();
describe('UserProfile Component', () => {
beforeEach(() => {
vi.resetAllMocks();
});
test('ローディング状態が表示される', () => {
// fetchを遅延させるモック
vi.mocked(fetch).mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 1000))
);
render(() => <UserProfile userId={1} />);
expect(screen.getByTestId('loading')).toBeInTheDocument();
});
test('ユーザーデータが正常に表示される', async () => {
const mockUser = {
id: 1,
name: '田中太郎',
email: 'tanaka@example.com',
};
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response);
render(() => <UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByTestId('user-profile')).toBeInTheDocument();
});
expect(screen.getByText('田中太郎')).toBeInTheDocument();
expect(screen.getByText('Email: tanaka@example.com')).toBeInTheDocument();
});
test('エラー時の処理', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('API Error'));
render(() => <UserProfile userId={999} />);
await waitFor(() => {
// エラー処理の実装に応じてアサーションを調整
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
});
});
});
まとめ
SolidJS Testing Library 活用のメリット
SolidJS Testing Library を活用することで、以下のようなメリットが得られます。
一貫性のあるテスト体験を提供します。React Testing Library で培った知識を活かしながら、SolidJS特有の機能もテストできます。
リアクティブシステムの適切なテストが可能になります。Signal や Store の動作を正確に検証し、バグの早期発見につながります。
チーム開発での品質向上に貢献します。統一されたテスト手法により、コードレビューやメンテナンスが効率化されます。
テスト観点 | メリット | 具体的な効果 |
---|---|---|
コンポーネント | Props とレンダリング | UI バグの早期発見 |
イベント処理 | ユーザーインタラクション | 操作性の品質保証 |
状態管理 | Signal/Store の動作 | データフローの検証 |
非同期処理 | API 連携とエラー処理 | 通信エラー対応の確認 |
テスト駆動開発への応用
SolidJS Testing Library は、テスト駆動開発(TDD)のワークフローに適合します。
まず、失敗するテストを書きます。これにより、実装すべき機能が明確になります。
typescripttest('新しい機能:カウンターの倍数表示', () => {
render(() => <Counter />);
// まだ実装されていない機能のテスト
expect(screen.getByTestId('double-count')).toHaveTextContent('0');
fireEvent.click(screen.getByTestId('increment-btn'));
expect(screen.getByTestId('double-count')).toHaveTextContent('2');
});
次に、テストを通すための最小限の実装を行います。
typescript// Counter コンポーネントに追加
const doubleCount = () => state.count * 2;
return (
<div>
{/* 既存のコード */}
<p data-testid="double-count">{doubleCount()}</p>
</div>
);
最後に、コードをリファクタリングして品質を向上させます。
今後の発展性
SolidJS エコシステムの成熟とともに、テスト環境もさらに充実していくことが期待されます。
Visual Regression Testing との統合により、UI の変更を自動的に検出できるようになります。
End-to-End Testing との組み合わせで、アプリケーション全体の動作を包括的にテストできます。
パフォーマンステスト機能の追加により、SolidJS の高性能を維持しながら開発を進められます。
継続的な学習とベストプラクティスの共有により、SolidJS コミュニティ全体のテスト文化が向上していくでしょう。
関連リンク
SolidJS 公式ドキュメント
@solidjs/testing-library ドキュメント
Vitest 公式サイト
関連ツール・ライブラリ
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来