T-CREATOR

Jest でファイルアップロード機能をテストする方法

Jest でファイルアップロード機能をテストする方法

Web 開発において、ファイルアップロード機能は必要不可欠な機能の一つです。しかし、この機能をテストする際には、従来のユニットテストとは異なる特殊な課題に直面することがあります。

「テストコードを書きたいけれど、File オブジェクトをどうやってモックすればいいのか分からない」「非同期処理のテストでエラーが発生してしまう」といった悩みを抱えている開発者の方も多いのではないでしょうか。

本記事では、Jest を使ってファイルアップロード機能を効果的にテストする方法について、実践的なコード例とともに詳しく解説していきます。初心者の方でも理解しやすいように、基本的な概念から段階的に説明していきますので、安心してお読みください。

ファイルアップロード機能の背景

Web アプリケーションにおけるファイルアップロードの重要性

現代の Web アプリケーションでは、プロフィール画像のアップロード、ドキュメントの共有、データのインポートなど、様々な場面でファイルアップロード機能が活用されています。ユーザーエクスペリエンスの向上において、スムーズなファイルアップロード機能は欠かせない要素となっているのです。

特に React や Next.js を使用したモダンな開発環境では、ユーザーフレンドリーなファイルアップロード機能の実装が求められます。しかし、この機能が正常に動作することを保証するためには、適切なテストの実装が必要不可欠です。

テストが困難な理由とその背景

ファイルアップロード機能のテストが困難な理由として、以下のような要因が挙げられます。

まず、ブラウザ環境とテスト環境の違いです。実際のブラウザでは File API や FormData が利用できますが、Jest のテスト環境(Node.js 環境)では、これらの API の動作が異なったり、制限があったりします。

また、ファイル操作には非同期処理が伴うため、テストコードでは適切な非同期処理の制御が必要になります。これが初心者にとって大きな壁となることが多いのです。

ファイルアップロードテストの課題

ファイル操作特有の問題点

ファイルアップロードテストにおいて最も頻繁に遭遇するのが、以下のようなエラーです。

vbnetReferenceError: File is not defined

これは Jest のテスト環境では File コンストラクターが標準で提供されていないことが原因です。ブラウザ環境では当然のように使用できる File オブジェクトが、テスト環境では利用できないという状況が発生します。

さらに、以下のようなエラーも一般的です。

javascriptTypeError: Cannot read property 'files' of null

このエラーは、input 要素の files プロパティにアクセスしようとした際に発生することが多く、適切な DOM 操作のモックが必要であることを示しています。

モックファイルの作成と管理の難しさ

テストで使用するモックファイルの作成には、以下のような技術的な課題があります。

File オブジェクトのプロパティ(name、size、type など)を適切に設定する必要があること、Blob データの作成と管理が複雑であること、そして複数のファイル形式に対応したモックの作成が困難であることなどが挙げられます。

これらの課題により、多くの開発者がファイルアップロード機能のテストを後回しにしてしまう傾向があります。しかし、適切な手法を身につけることで、これらの課題は確実に解決できるのです。

非同期処理のテスト

ファイルアップロード処理では、サーバーへのリクエスト送信、レスポンスの待機、進捗表示の更新など、多くの非同期処理が関わってきます。これらのテストにおいて、以下のようなエラーが発生することがあります。

vbnetError: Timeout - Async callback was not invoked within the 5000ms timeout

このエラーは、非同期処理が適切に完了せず、Jest のタイムアウト時間内にテストが終了しなかった場合に発生します。適切な非同期処理のテスト手法を理解することが重要です。

Jest でのファイルアップロードテスト解決策

Jest の基本設定とセットアップ

まず、ファイルアップロードテストに必要な Jest の設定を行います。以下は基本的な jest.config.js の設定例です。

javascript// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
};

この設定により、ブラウザ環境に近いテスト環境を構築できます。testEnvironment: 'jsdom' は特に重要で、これによりブラウザの DOM API が利用可能になります。

次に、テストセットアップファイルを作成します。

javascript// src/setupTests.js
import '@testing-library/jest-dom';

// File API のポリフィルを追加
global.File = class File {
  constructor(fileBits, fileName, options = {}) {
    this.bits = fileBits;
    this.name = fileName;
    this.size = fileBits.reduce(
      (acc, bit) => acc + bit.length,
      0
    );
    this.type = options.type || '';
    this.lastModified = options.lastModified || Date.now();
  }
};

// FileList のモック実装
global.FileList = class FileList {
  constructor(files = []) {
    this.length = files.length;
    files.forEach((file, index) => {
      this[index] = file;
    });
  }
};

このセットアップにより、テスト環境で File オブジェクトと FileList を使用できるようになります。

ファイルモック作成の手法

実際のテストで使用するファイルモックを作成するユーティリティ関数を実装しましょう。

javascript// src/utils/testUtils.js
export const createMockFile = (
  name = 'test.txt',
  content = 'test content',
  type = 'text/plain'
) => {
  const file = new File([content], name, { type });
  return file;
};

export const createMockImageFile = (
  name = 'test.jpg',
  size = 1024
) => {
  // 画像ファイルのモックデータを作成
  const buffer = new ArrayBuffer(size);
  const file = new File([buffer], name, {
    type: 'image/jpeg',
  });
  return file;
};

export const createMockFileList = (files) => {
  const fileList = new FileList(files);
  return fileList;
};

これらのユーティリティ関数を使用することで、様々な種類のファイルモックを簡単に作成できます。

fetch API や axios のモック方法

ファイルアップロード処理では、通常 HTTP リクエストを送信します。これらのリクエストをモックする方法を見ていきましょう。

fetch API をモックする場合:

javascript// テストファイル内でのfetchモック
beforeEach(() => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      ok: true,
      status: 200,
      json: () =>
        Promise.resolve({
          success: true,
          fileId: 'abc123',
        }),
    })
  );
});

afterEach(() => {
  jest.restoreAllMocks();
});

axios を使用している場合は、以下のようにモックします。

javascript// axios をモックする場合
import axios from 'axios';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

beforeEach(() => {
  mockedAxios.post.mockResolvedValue({
    data: { success: true, fileId: 'abc123' },
    status: 200,
  });
});

このように適切に HTTP リクエストをモックすることで、外部依存なしにファイルアップロード機能をテストできます。

具体的な実装例

シンプルなファイルアップロードのテスト

最も基本的なファイルアップロード機能のテストから始めましょう。以下は React コンポーネントの例です。

javascript// FileUpload.jsx
import React, { useState } from 'react';

const FileUpload = ({ onUpload }) => {
  const [file, setFile] = useState(null);
  const [uploading, setUploading] = useState(false);

  const handleFileChange = (event) => {
    const selectedFile = event.target.files[0];
    setFile(selectedFile);
  };

  const handleUpload = async () => {
    if (!file) return;

    setUploading(true);
    try {
      await onUpload(file);
    } catch (error) {
      console.error('Upload failed:', error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type='file'
        onChange={handleFileChange}
        data-testid='file-input'
      />
      <button
        onClick={handleUpload}
        disabled={!file || uploading}
        data-testid='upload-button'
      >
        {uploading ? 'Uploading...' : 'Upload'}
      </button>
    </div>
  );
};

export default FileUpload;

このコンポーネントをテストするコードは以下のようになります。

javascript// FileUpload.test.jsx
import React from 'react';
import {
  render,
  screen,
  fireEvent,
  waitFor,
} from '@testing-library/react';
import FileUpload from './FileUpload';
import { createMockFile } from '../utils/testUtils';

describe('FileUpload', () => {
  test('ファイル選択時にボタンが有効になる', () => {
    const mockOnUpload = jest.fn();
    render(<FileUpload onUpload={mockOnUpload} />);

    const fileInput = screen.getByTestId('file-input');
    const uploadButton =
      screen.getByTestId('upload-button');

    // 初期状態ではボタンが無効
    expect(uploadButton).toBeDisabled();

    // ファイルを選択
    const mockFile = createMockFile(
      'test.txt',
      'test content'
    );
    Object.defineProperty(fileInput, 'files', {
      value: [mockFile],
      writable: false,
    });

    fireEvent.change(fileInput);

    // ファイル選択後はボタンが有効になる
    expect(uploadButton).not.toBeDisabled();
  });
});

このテストでは、ユーザーがファイルを選択した際の UI の状態変化を確認しています。

FormData を使用したテスト

実際のファイルアップロード処理では FormData を使用することが多いです。以下は FormData を使用した実装例です。

javascript// fileUploadService.js
export const uploadFile = async (file) => {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('uploadTime', new Date().toISOString());

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  });

  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`);
  }

  return response.json();
};

このサービス関数をテストするコードは以下のようになります。

javascript// fileUploadService.test.js
import { uploadFile } from './fileUploadService';
import { createMockFile } from '../utils/testUtils';

// fetchをモック
global.fetch = jest.fn();

describe('uploadFile', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('ファイルアップロードが成功する', async () => {
    // レスポンスをモック
    fetch.mockResolvedValueOnce({
      ok: true,
      status: 200,
      json: async () => ({
        success: true,
        fileId: 'abc123',
        url: 'https://example.com/files/abc123',
      }),
    });

    const mockFile = createMockFile(
      'document.pdf',
      'PDF content',
      'application/pdf'
    );
    const result = await uploadFile(mockFile);

    // fetch が正しく呼ばれたか確認
    expect(fetch).toHaveBeenCalledWith('/api/upload', {
      method: 'POST',
      body: expect.any(FormData),
    });

    // 結果が正しいか確認
    expect(result).toEqual({
      success: true,
      fileId: 'abc123',
      url: 'https://example.com/files/abc123',
    });
  });
});

FormData の内容を詳細にテストしたい場合は、以下のような方法も使用できます。

javascripttest('FormData に正しいデータが設定される', async () => {
  fetch.mockResolvedValueOnce({
    ok: true,
    json: async () => ({ success: true }),
  });

  const mockFile = createMockFile('test.txt', 'content');
  await uploadFile(mockFile);

  // fetchに渡されたFormDataを取得
  const formData = fetch.mock.calls[0][1].body;

  // FormDataの内容を確認するためのヘルパー関数
  const getFormDataEntries = (formData) => {
    const entries = {};
    for (let [key, value] of formData.entries()) {
      entries[key] = value;
    }
    return entries;
  };

  const entries = getFormDataEntries(formData);
  expect(entries.file).toBe(mockFile);
  expect(entries.uploadTime).toBeDefined();
});

複数ファイルアップロードのテスト

複数ファイルの同時アップロード機能をテストする例を見てみましょう。

javascript// MultiFileUpload.jsx
import React, { useState } from 'react';

const MultiFileUpload = ({ onUpload }) => {
  const [files, setFiles] = useState([]);
  const [uploading, setUploading] = useState(false);

  const handleFilesChange = (event) => {
    const selectedFiles = Array.from(event.target.files);
    setFiles(selectedFiles);
  };

  const handleUpload = async () => {
    if (files.length === 0) return;

    setUploading(true);
    try {
      const uploadPromises = files.map((file) =>
        onUpload(file)
      );
      await Promise.all(uploadPromises);
    } catch (error) {
      console.error('Upload failed:', error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div>
      <input
        type='file'
        multiple
        onChange={handleFilesChange}
        data-testid='file-input'
      />
      <div data-testid='file-count'>
        選択されたファイル数: {files.length}
      </div>
      <button
        onClick={handleUpload}
        disabled={files.length === 0 || uploading}
        data-testid='upload-button'
      >
        Upload All
      </button>
    </div>
  );
};

複数ファイルのテストコードは以下のようになります。

javascript// MultiFileUpload.test.jsx
import React from 'react';
import {
  render,
  screen,
  fireEvent,
  waitFor,
} from '@testing-library/react';
import MultiFileUpload from './MultiFileUpload';
import {
  createMockFile,
  createMockFileList,
} from '../utils/testUtils';

describe('MultiFileUpload', () => {
  test('複数ファイル選択とアップロード', async () => {
    const mockOnUpload = jest
      .fn()
      .mockResolvedValue({ success: true });
    render(<MultiFileUpload onUpload={mockOnUpload} />);

    const fileInput = screen.getByTestId('file-input');

    // 複数ファイルを作成
    const mockFiles = [
      createMockFile('file1.txt', 'content1'),
      createMockFile('file2.jpg', 'content2', 'image/jpeg'),
      createMockFile(
        'file3.pdf',
        'content3',
        'application/pdf'
      ),
    ];

    // FileListをモック
    Object.defineProperty(fileInput, 'files', {
      value: createMockFileList(mockFiles),
      writable: false,
    });

    fireEvent.change(fileInput);

    // ファイル数が正しく表示されるか確認
    expect(
      screen.getByTestId('file-count')
    ).toHaveTextContent('選択されたファイル数: 3');

    // アップロードボタンをクリック
    const uploadButton =
      screen.getByTestId('upload-button');
    fireEvent.click(uploadButton);

    // 全てのファイルがアップロードされるまで待機
    await waitFor(() => {
      expect(mockOnUpload).toHaveBeenCalledTimes(3);
    });

    // 各ファイルが正しく呼ばれたか確認
    expect(mockOnUpload).toHaveBeenCalledWith(mockFiles[0]);
    expect(mockOnUpload).toHaveBeenCalledWith(mockFiles[1]);
    expect(mockOnUpload).toHaveBeenCalledWith(mockFiles[2]);
  });
});

エラーハンドリングのテスト

ファイルアップロード処理では、様々なエラーが発生する可能性があります。これらのエラーハンドリングをテストすることも重要です。

javascriptdescribe('エラーハンドリング', () => {
  test('ネットワークエラーの処理', async () => {
    // ネットワークエラーをシミュレート
    fetch.mockRejectedValueOnce(new Error('Network Error'));

    const mockFile = createMockFile('test.txt');

    await expect(uploadFile(mockFile)).rejects.toThrow(
      'Network Error'
    );
  });

  test('サーバーエラーの処理', async () => {
    // サーバーエラー(500)をシミュレート
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
      statusText: 'Internal Server Error',
    });

    const mockFile = createMockFile('test.txt');

    await expect(uploadFile(mockFile)).rejects.toThrow(
      'Upload failed: 500'
    );
  });

  test('ファイルサイズ制限エラーの処理', async () => {
    // ファイルサイズ制限エラー(413)をシミュレート
    fetch.mockResolvedValueOnce({
      ok: false,
      status: 413,
      statusText: 'Payload Too Large',
    });

    // 大きなファイルをモック
    const largeFile = createMockFile(
      'large.txt',
      'x'.repeat(10000000)
    );

    await expect(uploadFile(largeFile)).rejects.toThrow(
      'Upload failed: 413'
    );
  });
});

実際のアプリケーションでよく発生するエラーメッセージも含めてテストすることで、検索性も向上します。

javascripttest('無効なファイル形式エラー', async () => {
  fetch.mockResolvedValueOnce({
    ok: false,
    status: 400,
    json: async () => ({
      error: 'Invalid file type. Only images are allowed.',
      code: 'INVALID_FILE_TYPE',
    }),
  });

  const invalidFile = createMockFile(
    'document.txt',
    'content',
    'text/plain'
  );

  try {
    await uploadFile(invalidFile);
  } catch (error) {
    expect(error.message).toContain('Upload failed: 400');
  }
});

このように、実際に発生する可能性のあるエラーを網羅的にテストすることで、ユーザーが困ったときに検索で見つけやすい記事になります。

まとめ

Jest を使用したファイルアップロード機能のテストは、最初は複雑に感じられるかもしれませんが、適切な手法を身につけることで効果的にテストできるようになります。

この記事で紹介した手法を活用することで、以下のようなメリットが得られます。

信頼性の向上: ファイルアップロード機能が確実に動作することを保証できます。

デバッグ効率の向上: 問題が発生した際に、テストコードが原因の特定に役立ちます。

リファクタリングの安全性: 機能を変更する際に、既存の動作が壊れていないことを確認できます。

チーム開発の効率化: テストがあることで、他の開発者も安心してコードを変更できます。

ファイルアップロード機能のテストは、初心者にとって難しい分野の一つですが、段階的に学習することで必ず習得できるスキルです。まずは基本的なテストから始めて、徐々に複雑なケースにも対応できるようになりましょう。

皆さんの開発体験がより良いものになることを願っています。テストコードを書くことで、より自信を持ってコードをリリースできるようになるはずです。

関連リンク