T-CREATOR

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

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 LibrarySolidJS Testing Library
基本的なAPIrender, 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 公式サイト

関連ツール・ライブラリ