テストしやすいコードへの移行 – Jest入門

「jQueryのテストって、どうやって書けばいいの?」

「テスト駆動開発(TDD)って何から始めればいいんだろう?」

「モック化やスパイって、実際どう使うの?」

前回はjQueryプラグインのリファクタリングについて学びました。今回は、モダンなテストフレームワークであるJestを使用して、テストしやすいコードへの移行方法を解説します。

テストしにくいコードの例

まず、テストが難しい従来のコードを見てみましょう:

JS
// jQuery版
function UserManager() {
    this.loadUsers = function() {
        $.ajax({
            url: '/api/users',
            success: function(users) {
                users.forEach(function(user) {
                    $('#userList').append(
                        '<li>' + user.name + '</li>'
                    );
                });
            },
            error: function(xhr) {
                $('#error').text('Failed to load users');
            }
        });
    };
}

このコードの問題点:

  • 外部APIへの依存
  • DOMへの直接操作
  • グローバルなjQuery依存
  • 非同期処理のテストが困難

テスト可能なコードへのリファクタリング

JS
// モダンな実装
class UserManager {
    constructor(apiClient, renderer) {
        this.apiClient = apiClient;
        this.renderer = renderer;
    }

    async loadUsers() {
        try {
            const users = await this.apiClient.getUsers();
            this.renderer.renderUsers(users);
        } catch (error) {
            this.renderer.showError('Failed to load users');
        }
    }
}

// 依存するクラスの実装
class APIClient {
    async getUsers() {
        const response = await fetch('/api/users');
        return response.json();
    }
}

class UserRenderer {
    constructor(container) {
        this.container = container;
    }

    renderUsers(users) {
        const html = users.map(user => 
            `<li>${user.name}</li>`
        ).join('');
        this.container.innerHTML = html;
    }

    showError(message) {
        this.container.innerHTML = `<div class="error">${message}</div>`;
    }
}

改善点:

  • 依存性の注入
  • 関心の分離
  • テスト可能なインターフェース
  • 非同期処理の明確化

Jestでのテスト実装

JS
// user-manager.test.js
describe('UserManager', () => {
    // モックの準備
    let mockApiClient;
    let mockRenderer;
    let userManager;

    beforeEach(() => {
        mockApiClient = {
            getUsers: jest.fn()
        };
        mockRenderer = {
            renderUsers: jest.fn(),
            showError: jest.fn()
        };
        userManager = new UserManager(mockApiClient, mockRenderer);
    });

    test('正常系: ユーザー一覧の取得と描画', async () => {
        // テストデータの準備
        const mockUsers = [
            { id: 1, name: 'John' },
            { id: 2, name: 'Jane' }
        ];
        mockApiClient.getUsers.mockResolvedValue(mockUsers);

        // テスト対象の実行
        await userManager.loadUsers();

        // 検証
        expect(mockApiClient.getUsers).toHaveBeenCalled();
        expect(mockRenderer.renderUsers)
            .toHaveBeenCalledWith(mockUsers);
    });

    test('異常系: APIエラーの処理', async () => {
        // エラーのモック
        mockApiClient.getUsers.mockRejectedValue(new Error('API Error'));

        // テスト対象の実行
        await userManager.loadUsers();

        // 検証
        expect(mockRenderer.showError)
            .toHaveBeenCalledWith('Failed to load users');
    });
});

テストのポイント:

  • テストの準備(Arrange)、実行(Act)、検証(Assert)の明確な分離
  • モックを使用した依存性の制御
  • 非同期処理のテスト方法
  • エラーケースのテスト

APIクライアントのテスト

JS
// api-client.test.js
describe('APIClient', () => {
    beforeEach(() => {
        // fetchのモック化
        global.fetch = jest.fn();
    });

    test('getUsers メソッドのテスト', async () => {
        // モックレスポンスの準備
        const mockUsers = [{ id: 1, name: 'John' }];
        global.fetch.mockResolvedValue({
            json: () => Promise.resolve(mockUsers)
        });

        const client = new APIClient();
        const result = await client.getUsers();

        expect(fetch).toHaveBeenCalledWith('/api/users');
        expect(result).toEqual(mockUsers);
    });
});

レンダラーのテスト

JS
// user-renderer.test.js
describe('UserRenderer', () => {
    let container;
    let renderer;

    beforeEach(() => {
        // DOMの準備
        document.body.innerHTML = '<div id="container"></div>';
        container = document.querySelector('#container');
        renderer = new UserRenderer(container);
    });

    test('ユーザーリストの描画', () => {
        const users = [
            { name: 'John' },
            { name: 'Jane' }
        ];

        renderer.renderUsers(users);

        expect(container.innerHTML).toContain('John');
        expect(container.innerHTML).toContain('Jane');
    });

    test('エラーメッセージの表示', () => {
        renderer.showError('Test Error');

        expect(container.innerHTML)
            .toContain('Test Error');
        expect(container.querySelector('.error'))
            .not.toBeNull();
    });
});

テストカバレッジの確認

JS
// jest.config.js
module.exports = {
    collectCoverage: true,
    coverageReporters: ['text', 'html'],
    coverageThreshold: {
        global: {
            branches: 80,
            functions: 80,
            lines: 80,
            statements: 80
        }
    }
};

カバレッジレポートの見方:

  • Statements: 実行された文の割合
  • Branches: 条件分岐の網羅率
  • Functions: テストされた関数の割合
  • Lines: 実行されたコードの行数割合

スナップショットテスト

JS
// user-renderer.test.js
describe('UserRenderer Snapshots', () => {
    test('ユーザーリストのスナップショット', () => {
        const users = [
            { name: 'John' },
            { name: 'Jane' }
        ];
        const renderer = new UserRenderer(document.createElement('div'));
        renderer.renderUsers(users);

        expect(renderer.container.innerHTML)
            .toMatchSnapshot();
    });
});

スナップショットテストの利点:

  • UI変更の検出が容易
  • 意図しない変更の防止
  • ビジュアルレグレッションの防止

まとめ

テスト可能なコードへの移行で得られるメリット:

  • バグの早期発見
  • リファクタリングの安全性向上
  • 設計の改善
  • ドキュメントとしての役割

次回は「デバッグテクニック – 開発者ツールを使いこなす」について解説し、より効率的なデバッグ方法を学んでいきましょう。